Ionic测试策略 #

一、测试概述 #

1.1 测试金字塔 #

text
        /\
       /  \
      / E2E\         端到端测试
     /------\        数量少、成本高
    /        \
   / Integration\    集成测试
  /--------------\   数量中等
 /                \
/    Unit Tests    \ 单元测试
-------------------- 数量多、成本低

1.2 测试工具 #

工具 用途
Jest 单元测试
Jasmine 单元测试
Karma 测试运行器
Cypress E2E测试
Protractor E2E测试

二、单元测试 #

2.1 Jest配置 #

bash
npm install jest @types/jest jest-preset-angular
javascript
// jest.config.js
module.exports = {
  preset: 'jest-preset-angular',
  setupFilesAfterEnv: ['<rootDir>/setup-jest.ts'],
  testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/e2e/'],
  moduleNameMapper: {
    '@app/(.*)': '<rootDir>/src/app/$1'
  }
};
typescript
// setup-jest.ts
import 'jest-preset-angular/setup-jest';

2.2 服务测试 #

typescript
// 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 be created', () => {
    expect(service).toBeTruthy();
  });
  
  it('should get users', () => {
    const mockUsers = [
      { id: '1', name: 'User 1' },
      { id: '2', name: 'User 2' }
    ];
    
    service.getUsers().subscribe(users => {
      expect(users).toEqual(mockUsers);
    });
    
    const req = httpMock.expectOne('/api/users');
    expect(req.request.method).toBe('GET');
    req.flush(mockUsers);
  });
});

2.3 组件测试 #

typescript
// home.component.spec.ts
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { HomePage } from './home.page';

describe('HomePage', () => {
  let component: HomePage;
  let fixture: ComponentFixture<HomePage>;
  
  beforeEach(waitForAsync(() => {
    TestBed.configureTestingModule({
      declarations: [HomePage],
      imports: [IonicModule.forRoot()]
    }).compileComponents();
    
    fixture = TestBed.createComponent(HomePage);
    component = fixture.componentInstance;
    fixture.detectChanges();
  }));
  
  it('should create', () => {
    expect(component).toBeTruthy();
  });
  
  it('should display title', () => {
    component.title = 'Test Title';
    fixture.detectChanges();
    
    const element = fixture.nativeElement.querySelector('ion-title');
    expect(element.textContent).toContain('Test Title');
  });
});

2.4 管道测试 #

typescript
// filter.pipe.spec.ts
import { FilterPipe } from './filter.pipe';

describe('FilterPipe', () => {
  let pipe: FilterPipe;
  
  beforeEach(() => {
    pipe = new FilterPipe();
  });
  
  it('should filter items', () => {
    const items = [
      { name: 'Apple' },
      { name: 'Banana' },
      { name: 'Apricot' }
    ];
    
    const result = pipe.transform(items, 'Ap');
    
    expect(result.length).toBe(2);
    expect(result[0].name).toBe('Apple');
    expect(result[1].name).toBe('Apricot');
  });
  
  it('should return empty array for no match', () => {
    const items = [
      { name: 'Apple' },
      { name: 'Banana' }
    ];
    
    const result = pipe.transform(items, 'Orange');
    
    expect(result.length).toBe(0);
  });
});

三、Ionic组件测试 #

3.1 测试Ionic组件 #

typescript
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { IonicModule, AlertController } from '@ionic/angular';
import { MyComponent } from './my.component';

describe('MyComponent', () => {
  let component: MyComponent;
  let fixture: ComponentFixture<MyComponent>;
  let alertCtrl: AlertController;
  
  beforeEach(async () => {
    TestBed.configureTestingModule({
      declarations: [MyComponent],
      imports: [IonicModule.forRoot()],
      providers: [
        AlertController
      ]
    });
    
    fixture = TestBed.createComponent(MyComponent);
    component = fixture.componentInstance;
    alertCtrl = TestBed.inject(AlertController);
  });
  
  it('should show alert', async () => {
    spyOn(alertCtrl, 'create').and.returnValue(Promise.resolve({
      present: () => Promise.resolve()
    } as any));
    
    await component.showAlert();
    
    expect(alertCtrl.create).toHaveBeenCalled();
  });
});

3.2 测试导航 #

typescript
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { IonicModule } from '@ionic/angular';
import { HomePage } from './home.page';

describe('HomePage', () => {
  let component: HomePage;
  let fixture: ComponentFixture<HomePage>;
  let router: Router;
  
  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [HomePage],
      imports: [IonicModule.forRoot()],
      providers: [
        { provide: Router, useValue: { navigate: jasmine.createSpy('navigate') } }
      ]
    });
    
    fixture = TestBed.createComponent(HomePage);
    component = fixture.componentInstance;
    router = TestBed.inject(Router);
  });
  
  it('should navigate to detail', () => {
    component.goToDetail('123');
    
    expect(router.navigate).toHaveBeenCalledWith(['/detail', '123']);
  });
});

四、E2E测试 #

4.1 Cypress安装 #

bash
npm install cypress @cypress/angular
npx cypress open

4.2 Cypress配置 #

javascript
// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:8100',
    supportFile: 'cypress/support/e2e.ts',
    specPattern: 'cypress/e2e/**/*.cy.ts'
  }
});

4.3 登录测试 #

typescript
// cypress/e2e/login.cy.ts
describe('Login', () => {
  beforeEach(() => {
    cy.visit('/login');
  });
  
  it('should display login form', () => {
    cy.get('ion-input[formControlName="email"]').should('exist');
    cy.get('ion-input[formControlName="password"]').should('exist');
    cy.get('ion-button[type="submit"]').should('exist');
  });
  
  it('should login successfully', () => {
    cy.get('ion-input[formControlName="email"] input').type('user@example.com');
    cy.get('ion-input[formControlName="password"] input').type('password123');
    cy.get('ion-button[type="submit"]').click();
    
    cy.url().should('include', '/home');
  });
  
  it('should show error for invalid credentials', () => {
    cy.get('ion-input[formControlName="email"] input').type('wrong@example.com');
    cy.get('ion-input[formControlName="password"] input').type('wrongpassword');
    cy.get('ion-button[type="submit"]').click();
    
    cy.get('ion-toast').should('exist');
  });
});

4.4 列表测试 #

typescript
// cypress/e2e/users.cy.ts
describe('Users List', () => {
  beforeEach(() => {
    cy.login();
    cy.visit('/users');
  });
  
  it('should display users list', () => {
    cy.get('ion-item').should('have.length.gt', 0);
  });
  
  it('should navigate to user detail', () => {
    cy.get('ion-item').first().click();
    
    cy.url().should('include', '/users/');
  });
  
  it('should search users', () => {
    cy.get('ion-searchbar input').type('John');
    
    cy.get('ion-item').each($item => {
      cy.wrap($item).should('contain', 'John');
    });
  });
});

4.5 自定义命令 #

typescript
// cypress/support/e2e.ts
Cypress.Commands.add('login', () => {
  cy.request({
    method: 'POST',
    url: '/api/auth/login',
    body: {
      email: 'test@example.com',
      password: 'password123'
    }
  }).then(response => {
    window.localStorage.setItem('token', response.body.token);
  });
});

// 使用
cy.login();

五、测试覆盖率 #

5.1 Jest覆盖率 #

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

5.2 运行覆盖率 #

bash
npm run test -- --coverage

六、测试最佳实践 #

6.1 AAA模式 #

typescript
it('should calculate total', () => {
  // Arrange
  const items = [
    { price: 10, quantity: 2 },
    { price: 20, quantity: 1 }
  ];
  
  // Act
  const total = service.calculateTotal(items);
  
  // Assert
  expect(total).toBe(40);
});

6.2 测试隔离 #

typescript
describe('UserService', () => {
  let service: UserService;
  
  beforeEach(() => {
    // 每个测试用例前重新创建服务
    TestBed.configureTestingModule({});
    service = TestBed.inject(UserService);
  });
  
  afterEach(() => {
    // 每个测试用例后清理
    // ...
  });
});

6.3 Mock外部依赖 #

typescript
describe('MyComponent', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        {
          provide: ExternalService,
          useValue: {
            getData: jasmine.createSpy('getData').and.returnValue(of(mockData))
          }
        }
      ]
    });
  });
});

七、持续集成 #

7.1 GitHub Actions #

yaml
# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          
      - name: Install dependencies
        run: npm ci
        
      - name: Run unit tests
        run: npm run test -- --no-watch --browsers=ChromeHeadless
        
      - name: Run E2E tests
        run: npm run e2e

八、总结 #

8.1 测试要点 #

类型 工具 用途
单元测试 Jest 测试函数和服务
组件测试 Jest + TestBed 测试组件
E2E测试 Cypress 测试用户流程
覆盖率 Jest 衡量测试完整性

8.2 下一步 #

掌握了测试策略后,接下来让我们学习 部署发布,了解Ionic应用的部署流程!

最后更新:2026-03-28