Chapter 09

测试与质量保障

Angular 测试全栈:TestBed 单元测试、Mock HttpClient、Playwright E2E 端到端

1. Angular 测试架构

2. 服务单元测试(Mock HttpClient)

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

describe('UserService', () => {
  let service: UserService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],  // 用 Mock HTTP 替代真实请求
      providers: [UserService]
    });
    service = TestBed.inject(UserService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify();  // 确保没有未处理的请求
  });

  it('should fetch users', () => {
    const mockUsers: User[] = [
      { id: 1, name: 'Alice', email: 'alice@example.com', role: 'user' },
      { id: 2, name: 'Bob', email: 'bob@example.com', role: 'admin' },
    ];

    let result: User[] = [];
    service.getUsers().subscribe(users => result = users);

    // 拦截 HTTP 请求并返回 mock 数据
    const req = httpMock.expectOne('/api/users');
    expect(req.request.method).toBe('GET');
    req.flush(mockUsers);  // 模拟服务器响应

    expect(result.length).toBe(2);
    expect(result[0].name).toBe('Alice');
  });

  it('should handle HTTP error', () => {
    let error: any;
    service.getUsers().subscribe({
      error: (err) => error = err
    });

    const req = httpMock.expectOne('/api/users');
    req.flush('Server Error', { status: 500, statusText: 'Internal Server Error' });

    expect(error.status).toBe(500);
  });
});

3. 组件单元测试

// login.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { LoginComponent } from './login.component';
import { AuthService } from '../auth.service';
import { of, throwError } from 'rxjs';

describe('LoginComponent', () => {
  let component: LoginComponent;
  let fixture: ComponentFixture<LoginComponent>;
  let authServiceSpy: jasmine.SpyObj<AuthService>;

  beforeEach(async () => {
    // 创建服务的 spy(Mock 对象)
    authServiceSpy = jasmine.createSpyObj('AuthService', ['login']);

    await TestBed.configureTestingModule({
      imports: [LoginComponent, ReactiveFormsModule],
      providers: [
        { provide: AuthService, useValue: authServiceSpy }
      ]
    }).compileComponents();

    fixture = TestBed.createComponent(LoginComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();  // 触发 ngOnInit
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should show validation errors when form is invalid', () => {
    // 让字段 dirty(模拟用户操作)
    component.form.get('email')!.markAsTouched();
    fixture.detectChanges();

    const errorEl = fixture.debugElement.query(By.css('.error'));
    expect(errorEl).toBeTruthy();
    expect(errorEl.nativeElement.textContent).toContain('必填');
  });

  it('should call auth.login on valid submit', () => {
    authServiceSpy.login.and.returnValue(of({ id: 1, name: 'Alice' } as any));

    component.form.setValue({
      email: 'alice@example.com',
      password: 'Password123'
    });
    fixture.detectChanges();

    const btn = fixture.debugElement.query(By.css('button[type="submit"]'));
    btn.nativeElement.click();

    expect(authServiceSpy.login).toHaveBeenCalledOnceWith({
      email: 'alice@example.com',
      password: 'Password123'
    });
  });

  it('should disable submit button when form is invalid', () => {
    const btn = fixture.debugElement.query(By.css('button[type="submit"]'));
    expect(btn.nativeElement.disabled).toBe(true);
  });
});

4. 测试带 Signal 的组件

// counter.component.spec.ts
describe('CounterComponent', () => {
  let fixture: ComponentFixture<CounterComponent>;
  let component: CounterComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [CounterComponent]
    }).compileComponents();
    fixture = TestBed.createComponent(CounterComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should start at 0', () => {
    expect(component.count()).toBe(0);
    expect(fixture.nativeElement.querySelector('p').textContent).toContain('0');
  });

  it('should increment on button click', () => {
    const btn = fixture.nativeElement.querySelector('button:first-of-type');
    btn.click();
    fixture.detectChanges();  // Signal 变化后刷新视图

    expect(component.count()).toBe(1);
  });
});

5. E2E 测试:Playwright

Angular 17+ 官方推荐使用 Playwright 进行端到端测试,替代旧版 Protractor。

# 安装 Playwright
npm init playwright@latest

# 或通过 Angular 原理图安装
ng add @playwright/test
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';

test.describe('登录流程', () => {
  test('正确凭据登录成功', async ({ page }) => {
    await page.goto('http://localhost:4200/login');

    // 填写表单
    await page.fill('input[name="email"]', 'admin@example.com');
    await page.fill('input[name="password"]', 'Admin123!');
    await page.click('button[type="submit"]');

    // 验证跳转到仪表盘
    await page.waitForURL('**/dashboard');
    await expect(page.locator('h1')).toContainText('仪表盘');
  });

  test('错误密码显示错误提示', async ({ page }) => {
    await page.goto('http://localhost:4200/login');
    await page.fill('input[name="email"]', 'user@example.com');
    await page.fill('input[name="password"]', 'wrongpassword');
    await page.click('button[type="submit"]');

    await expect(page.locator('.error-toast')).toBeVisible();
    await expect(page.locator('.error-toast')).toContainText('用户名或密码错误');
  });
});

6. 测试覆盖率

# 运行测试并生成覆盖率报告
ng test --code-coverage

# 覆盖率报告位于:coverage/my-app/index.html
# 包含:语句、分支、函数、行覆盖率

angular.json 中配置覆盖率阈值,确保代码质量:

// angular.json
"test": {
  "options": {
    "codeCoverage": true,
    "codeCoverageExclude": ["src/environments/**"]
  }
}

// karma.conf.js — 设置覆盖率阈值
coverageReporter: {
  thresholds: {
    emitWarning: false,
    global: {
      statements: 80,
      branches: 70,
      functions: 80,
      lines: 80
    }
  }
}

本章小结:Angular 内置完善的测试工具链。TestBed + HttpClientTestingModule 让服务测试简单可靠,ComponentFixture + detectChanges 完成组件测试,Playwright 提供现代 E2E 能力。最后一章讲性能优化与部署。