NativeScript 测试策略 #

测试概述 #

测试是保证应用质量的重要手段,NativeScript 支持多种测试方式。

text
┌─────────────────────────────────────────────────────────────┐
│                    测试类型                                  │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  单元测试                                                    │
│  ├── 测试独立函数和类                                       │
│  ├── Jest / Mocha                                           │
│  └── 快速执行                                               │
│                                                             │
│  组件测试                                                    │
│  ├── 测试 UI 组件                                           │
│  └── 测试组件交互                                           │
│                                                             │
│  E2E 测试                                                    │
│  ├── 端到端测试                                             │
│  ├── Appium / Detox                                         │
│  └── 真实设备/模拟器                                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

单元测试 #

配置 Jest #

bash
npm install jest @types/jest ts-jest --save-dev
javascript
// jest.config.js
module.exports = {
    preset: 'ts-jest',
    testEnvironment: 'node',
    testMatch: ['**/*.spec.ts'],
    moduleFileExtensions: ['ts', 'js'],
    transform: {
        '^.+\\.ts$': 'ts-jest'
    },
    moduleNameMapper: {
        '^@/(.*)$': '<rootDir>/app/$1'
    }
};

测试服务 #

typescript
// services/user.service.ts
export class UserService {
    constructor(private api: ApiService) {}
    
    async getUser(id: number): Promise<User> {
        return this.api.get<User>(`/users/${id}`);
    }
    
    async createUser(data: CreateUserDto): Promise<User> {
        return this.api.post<User>('/users', data);
    }
}

// services/user.service.spec.ts
describe('UserService', () => {
    let service: UserService;
    let apiMock: jest.Mocked<ApiService>;
    
    beforeEach(() => {
        apiMock = {
            get: jest.fn(),
            post: jest.fn()
        } as any;
        
        service = new UserService(apiMock);
    });
    
    describe('getUser', () => {
        it('should return user by id', async () => {
            const mockUser = { id: 1, name: 'John' };
            apiMock.get.mockResolvedValue(mockUser);
            
            const result = await service.getUser(1);
            
            expect(result).toEqual(mockUser);
            expect(apiMock.get).toHaveBeenCalledWith('/users/1');
        });
        
        it('should handle error', async () => {
            apiMock.get.mockRejectedValue(new Error('Not found'));
            
            await expect(service.getUser(999)).rejects.toThrow('Not found');
        });
    });
    
    describe('createUser', () => {
        it('should create user', async () => {
            const createData = { name: 'John', email: 'john@example.com' };
            const mockUser = { id: 1, ...createData };
            apiMock.post.mockResolvedValue(mockUser);
            
            const result = await service.createUser(createData);
            
            expect(result).toEqual(mockUser);
            expect(apiMock.post).toHaveBeenCalledWith('/users', createData);
        });
    });
});

测试 ViewModel #

typescript
// viewmodels/home.viewmodel.ts
export class HomeViewModel extends Observable {
    private _items: ObservableArray<Item>;
    private _isLoading: boolean = false;
    
    constructor(private itemService: ItemService) {
        super();
        this._items = new ObservableArray();
    }
    
    get items(): ObservableArray<Item> {
        return this._items;
    }
    
    get isLoading(): boolean {
        return this._isLoading;
    }
    
    async loadItems(): Promise<void> {
        this._isLoading = true;
        this.notifyPropertyChange('isLoading', true);
        
        try {
            const items = await this.itemService.getItems();
            this._items.splice(0, this._items.length, ...items);
        } finally {
            this._isLoading = false;
            this.notifyPropertyChange('isLoading', false);
        }
    }
}

// viewmodels/home.viewmodel.spec.ts
describe('HomeViewModel', () => {
    let viewModel: HomeViewModel;
    let itemServiceMock: jest.Mocked<ItemService>;
    
    beforeEach(() => {
        itemServiceMock = {
            getItems: jest.fn()
        } as any;
        
        viewModel = new HomeViewModel(itemServiceMock);
    });
    
    describe('loadItems', () => {
        it('should load items', async () => {
            const mockItems = [
                { id: 1, title: 'Item 1' },
                { id: 2, title: 'Item 2' }
            ];
            itemServiceMock.getItems.mockResolvedValue(mockItems);
            
            await viewModel.loadItems();
            
            expect(viewModel.items.length).toBe(2);
            expect(viewModel.items.getItem(0)).toEqual(mockItems[0]);
        });
        
        it('should set loading state', async () => {
            itemServiceMock.getItems.mockImplementation(() => 
                new Promise(resolve => setTimeout(resolve, 100))
            );
            
            const promise = viewModel.loadItems();
            
            expect(viewModel.isLoading).toBe(true);
            
            await promise;
            
            expect(viewModel.isLoading).toBe(false);
        });
    });
});

测试工具函数 #

typescript
// utils/formatters.ts
export function formatPrice(price: number, currency: string = 'USD'): string {
    return new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency
    }).format(price);
}

export function formatDate(date: Date | string): string {
    const d = typeof date === 'string' ? new Date(date) : date;
    return d.toLocaleDateString();
}

// utils/formatters.spec.ts
describe('Formatters', () => {
    describe('formatPrice', () => {
        it('should format price with default currency', () => {
            expect(formatPrice(100)).toBe('$100.00');
        });
        
        it('should format price with custom currency', () => {
            expect(formatPrice(100, 'EUR')).toBe('€100.00');
        });
        
        it('should handle decimal values', () => {
            expect(formatPrice(99.99)).toBe('$99.99');
        });
    });
    
    describe('formatDate', () => {
        it('should format date', () => {
            const date = new Date('2024-01-15');
            const result = formatDate(date);
            expect(result).toContain('2024');
        });
        
        it('should handle string date', () => {
            const result = formatDate('2024-01-15');
            expect(result).toContain('2024');
        });
    });
});

Angular 测试 #

配置测试 #

typescript
// src/app.component.spec.ts
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';

describe('AppComponent', () => {
    beforeEach(async () => {
        await TestBed.configureTestingModule({
            declarations: [AppComponent]
        }).compileComponents();
    });
    
    it('should create the app', () => {
        const fixture = TestBed.createComponent(AppComponent);
        const app = fixture.componentInstance;
        expect(app).toBeTruthy();
    });
});

测试组件 #

typescript
// components/home/home.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HomeComponent } from './home.component';
import { UserService } from '../../services/user.service';

describe('HomeComponent', () => {
    let component: HomeComponent;
    let fixture: ComponentFixture<HomeComponent>;
    let userServiceMock: jasmine.SpyObj<UserService>;
    
    beforeEach(async () => {
        userServiceMock = jasmine.createSpyObj('UserService', ['getUsers']);
        
        await TestBed.configureTestingModule({
            declarations: [HomeComponent],
            providers: [
                { provide: UserService, useValue: userServiceMock }
            ]
        }).compileComponents();
        
        fixture = TestBed.createComponent(HomeComponent);
        component = fixture.componentInstance;
    });
    
    it('should create', () => {
        expect(component).toBeTruthy();
    });
    
    it('should load users on init', () => {
        const mockUsers = [{ id: 1, name: 'John' }];
        userServiceMock.getUsers.and.returnValue(of(mockUsers));
        
        component.ngOnInit();
        
        expect(component.users).toEqual(mockUsers);
    });
});

测试服务 #

typescript
// services/user.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { UserService } from './user.service';

describe('UserService', () => {
    let service: UserService;
    let httpMock: HttpTestingController;
    
    beforeEach(() => {
        TestBed.configureTestingModule({
            imports: [HttpClientTestingModule],
            providers: [UserService]
        });
        
        service = TestBed.inject(UserService);
        httpMock = TestBed.inject(HttpTestingController);
    });
    
    afterEach(() => {
        httpMock.verify();
    });
    
    it('should get users', () => {
        const mockUsers = [{ id: 1, name: 'John' }];
        
        service.getUsers().subscribe(users => {
            expect(users).toEqual(mockUsers);
        });
        
        const req = httpMock.expectOne('/api/users');
        expect(req.request.method).toBe('GET');
        req.flush(mockUsers);
    });
});

E2E 测试 #

配置 Appium #

bash
npm install appium @nativescript/appium --save-dev
typescript
// e2e/setup.ts
import { AppiumDriver, createDriver } from 'nativescript-appium';

describe('E2E Tests', () => {
    let driver: AppiumDriver;
    
    before(async () => {
        driver = await createDriver({
            appPath: '/path/to/app.apk',
            platformName: 'Android'
        });
    });
    
    after(async () => {
        await driver.quit();
    });
    
    it('should display login screen', async () => {
        const title = await driver.findElementByText('Login');
        expect(await title.text()).toBe('Login');
    });
    
    it('should login successfully', async () => {
        const emailInput = await driver.findElementByXPath('//TextField[@hint="Email"]');
        await emailInput.sendKeys('test@example.com');
        
        const passwordInput = await driver.findElementByXPath('//TextField[@hint="Password"]');
        await passwordInput.sendKeys('password123');
        
        const loginButton = await driver.findElementByText('Login');
        await loginButton.click();
        
        const homeTitle = await driver.findElementByText('Home');
        expect(await homeTitle.isDisplayed()).toBe(true);
    });
});

使用 Detox #

bash
npm install detox --save-dev
javascript
// .detoxrc.js
module.exports = {
    testRunner: {
        args: {
            '$0': 'jest',
            config: 'e2e/jest.config.js'
        },
        jest: {
            setupTimeout: 120000
        }
    },
    apps: {
        'ios.debug': {
            type: 'ios.app',
            binaryPath: 'platforms/ios/build/Debug-iphonesimulator/YourApp.app'
        },
        'android.debug': {
            type: 'android.apk',
            binaryPath: 'platforms/android/app/build/outputs/apk/debug/app-debug.apk'
        }
    },
    devices: {
        simulator: {
            type: 'ios.simulator',
            device: { type: 'iPhone 14' }
        },
        emulator: {
            type: 'android.emulator',
            device: { avdName: 'Pixel_5_API_33' }
        }
    },
    configurations: {
        'ios.sim.debug': {
            device: 'simulator',
            app: 'ios.debug'
        },
        'android.emu.debug': {
            device: 'emulator',
            app: 'android.debug'
        }
    }
};
typescript
// e2e/login.test.ts
describe('Login', () => {
    beforeAll(async () => {
        await device.launchApp();
    });
    
    beforeEach(async () => {
        await device.reloadReactNative();
    });
    
    it('should show login screen', async () => {
        await expect(element(by.text('Login'))).toBeVisible();
    });
    
    it('should login successfully', async () => {
        await element(by.id('email-input')).typeText('test@example.com');
        await element(by.id('password-input')).typeText('password123');
        await element(by.id('login-button')).tap();
        
        await expect(element(by.text('Home'))).toBeVisible();
    });
});

测试最佳实践 #

测试原则 #

text
┌─────────────────────────────────────────────────────────────┐
│                    测试原则                                  │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. AAA 模式                                                │
│     Arrange - 准备测试数据                                  │
│     Act - 执行测试操作                                      │
│     Assert - 验证结果                                       │
│                                                             │
│  2. 单一职责                                                │
│     每个测试只验证一个功能                                  │
│                                                             │
│  3. 独立性                                                  │
│     测试之间不应相互依赖                                    │
│                                                             │
│  4. 可重复                                                  │
│     测试结果应该可重复                                      │
│                                                             │
│  5. 命名清晰                                                │
│     测试名称应描述测试内容                                  │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Mock 最佳实践 #

typescript
// 创建可复用的 Mock
export function createMockApiService(): jest.Mocked<ApiService> {
    return {
        get: jest.fn(),
        post: jest.fn(),
        put: jest.fn(),
        delete: jest.fn()
    };
}

// 使用工厂函数
export function createMockUser(overrides?: Partial<User>): User {
    return {
        id: 1,
        name: 'Test User',
        email: 'test@example.com',
        ...overrides
    };
}

测试覆盖率 #

javascript
// jest.config.js
module.exports = {
    collectCoverage: true,
    coverageDirectory: 'coverage',
    coverageReporters: ['text', 'lcov'],
    coverageThreshold: {
        global: {
            branches: 70,
            functions: 70,
            lines: 70
        }
    }
};

下一步 #

现在你已经掌握了测试策略,接下来学习 Angular集成,了解如何与 Angular 框架集成!

最后更新:2026-03-29