Angular测试组件与服务

Angular提供了强大的测试工具链,使用Jasmine作为测试框架,Karma作为测试运行器。本章将详细介绍如何编写组件和服务的单元测试。

测试前提:Angular CLI项目默认已配置好测试环境。运行 ng test 启动测试。

1. 测试环境配置

// karma.conf.js - Karma配置文件
module.exports = function (config) {
  config.set({
    basePath: '',
    frameworks: ['jasmine', '@angular-devkit/build-angular'],
    plugins: [
      require('karma-jasmine'),
      require('karma-chrome-launcher'),
      require('karma-jasmine-html-reporter'),
      require('karma-coverage'),
      require('@angular-devkit/build-angular/plugins/karma')
    ],
    client: {
      jasmine: {
        // 可以在这里配置Jasmine选项
      },
      clearContext: false // 在浏览器中保留测试结果
    },
    coverageReporter: {
      dir: require('path').join(__dirname, './coverage/my-app'),
      subdir: '.',
      reporters: [
        { type: 'html' },
        { type: 'text-summary' }
      ]
    },
    reporters: ['progress', 'kjhtml'],
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['Chrome'],
    singleRun: false,
    restartOnFileChange: true
  });
};

2. 测试组件

2.1 基本组件测试

// app.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
import { By } from '@angular/platform-browser';

describe('AppComponent', () => {
  let component: AppComponent;
  let fixture: ComponentFixture<AppComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [AppComponent]
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(AppComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('应该创建组件', () => {
    expect(component).toBeTruthy();
  });

  it(`应该包含标题 'my-app'`, () => {
    expect(component.title).toEqual('my-app');
  });

  it('应该渲染标题', () => {
    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector('.content h1')?.textContent)
      .toContain('my-app app is running!');
  });

  it('点击按钮应该增加计数', () => {
    const button = fixture.debugElement.query(By.css('button'));
    expect(component.count).toBe(0);

    button.triggerEventHandler('click', null);
    fixture.detectChanges();

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

    const countElement = fixture.debugElement.query(By.css('.count'));
    expect(countElement.nativeElement.textContent).toContain('1');
  });
});

2.2 带输入输出的组件测试

// user-item.component.spec.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';

@Component({
  selector: 'app-user-item',
  template: `
    <div class="user-item">
      <span>{{user.name}}</span>
      <button (click)="onDelete()">删除</button>
    </div>
  `
})
export class UserItemComponent {
  @Input() user: any;
  @Output() delete = new EventEmitter<number>();

  onDelete() {
    this.delete.emit(this.user.id);
  }
}

describe('UserItemComponent', () => {
  let component: UserItemComponent;
  let fixture: ComponentFixture<UserItemComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [UserItemComponent]
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(UserItemComponent);
    component = fixture.componentInstance;
    component.user = { id: 1, name: '张三' };
    fixture.detectChanges();
  });

  it('应该正确显示用户名', () => {
    const span = fixture.nativeElement.querySelector('span');
    expect(span.textContent).toContain('张三');
  });

  it('点击删除按钮应该触发事件', () => {
    spyOn(component.delete, 'emit');
    const button = fixture.nativeElement.querySelector('button');

    button.click();

    expect(component.delete.emit).toHaveBeenCalledWith(1);
  });

  it('应该根据输入更新显示', () => {
    component.user = { id: 2, name: '李四' };
    fixture.detectChanges();

    const span = fixture.nativeElement.querySelector('span');
    expect(span.textContent).toContain('李四');
  });
});

2.3 异步操作测试

// async-data.component.spec.ts
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { AsyncDataComponent } from './async-data.component';

describe('AsyncDataComponent', () => {
  let component: AsyncDataComponent;
  let fixture: ComponentFixture<AsyncDataComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [AsyncDataComponent]
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(AsyncDataComponent);
    component = fixture.componentInstance;
  });

  it('应该处理Promise数据 - 使用async/await', async () => {
    await component.loadData();
    expect(component.data).toBe('加载的数据');
  });

  it('应该处理Promise数据 - 使用fakeAsync', fakeAsync(() => {
    component.loadData();
    tick(1000); // 模拟时间流逝
    expect(component.data).toBe('加载的数据');
  }));

  it('应该处理定时器', fakeAsync(() => {
    component.startTimer();
    expect(component.timerValue).toBe(0);

    tick(1000);
    expect(component.timerValue).toBe(1);

    tick(2000);
    expect(component.timerValue).toBe(3);

    component.stopTimer();
    tick(1000);
    expect(component.timerValue).toBe(3); // 停止后不再增加
  }));
});

3. 测试服务

3.1 基本服务测试

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

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(); // 确保没有未处理的HTTP请求
  });

  it('应该被创建', () => {
    expect(service).toBeTruthy();
  });

  it('应该获取用户列表', () => {
    const mockUsers = [
      { id: 1, name: '张三' },
      { id: 2, name: '李四' }
    ];

    service.getUsers().subscribe(users => {
      expect(users.length).toBe(2);
      expect(users).toEqual(mockUsers);
    });

    const req = httpMock.expectOne('api/users');
    expect(req.request.method).toBe('GET');
    req.flush(mockUsers);
  });

  it('应该创建新用户', () => {
    const newUser = { name: '王五' };
    const createdUser = { id: 3, name: '王五' };

    service.createUser(newUser).subscribe(user => {
      expect(user).toEqual(createdUser);
    });

    const req = httpMock.expectOne('api/users');
    expect(req.request.method).toBe('POST');
    expect(req.request.body).toEqual(newUser);
    req.flush(createdUser);
  });

  it('应该处理HTTP错误', () => {
    service.getUsers().subscribe({
      next: () => fail('应该返回错误'),
      error: (error) => {
        expect(error.status).toBe(404);
        expect(error.statusText).toBe('Not Found');
      }
    });

    const req = httpMock.expectOne('api/users');
    req.flush('Not Found', {
      status: 404,
      statusText: 'Not Found'
    });
  });
});

3.2 带依赖的服务测试

// auth.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { AuthService } from './auth.service';
import { LocalStorageService } from './local-storage.service';
import { HttpClientTestingModule } from '@angular/common/http/testing';

// 创建模拟服务
class MockLocalStorageService {
  private store: { [key: string]: string } = {};

  setItem(key: string, value: string): void {
    this.store[key] = value;
  }

  getItem(key: string): string | null {
    return this.store[key] || null;
  }

  removeItem(key: string): void {
    delete this.store[key];
  }
}

describe('AuthService', () => {
  let service: AuthService;
  let localStorageService: LocalStorageService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [
        AuthService,
        { provide: LocalStorageService, useClass: MockLocalStorageService }
      ]
    });

    service = TestBed.inject(AuthService);
    localStorageService = TestBed.inject(LocalStorageService);
  });

  it('应该登录并保存token', () => {
    const token = 'fake-jwt-token';
    spyOn(localStorageService, 'setItem');

    service.login('user', 'pass').subscribe(result => {
      expect(result).toBeTrue();
      expect(localStorageService.setItem).toHaveBeenCalledWith('auth_token', token);
    });
  });

  it('应该登出并清除token', () => {
    spyOn(localStorageService, 'removeItem');

    service.logout();

    expect(localStorageService.removeItem).toHaveBeenCalledWith('auth_token');
    expect(service.isLoggedIn()).toBeFalse();
  });

  it('应该检查登录状态', () => {
    // 模拟已登录状态
    spyOn(localStorageService, 'getItem').and.returnValue('fake-token');
    expect(service.isLoggedIn()).toBeTrue();

    // 模拟未登录状态
    (localStorageService.getItem as jasmine.Spy).and.returnValue(null);
    expect(service.isLoggedIn()).toBeFalse();
  });
});

4. 测试管道

// capitalize.pipe.spec.ts
import { CapitalizePipe } from './capitalize.pipe';

describe('CapitalizePipe', () => {
  let pipe: CapitalizePipe;

  beforeEach(() => {
    pipe = new CapitalizePipe();
  });

  it('应该创建管道', () => {
    expect(pipe).toBeTruthy();
  });

  it('应该转换字符串为首字母大写', () => {
    expect(pipe.transform('angular')).toBe('Angular');
    expect(pipe.transform('ANGULAR')).toBe('Angular');
    expect(pipe.transform('aNgUlAr')).toBe('Angular');
  });

  it('应该处理空字符串', () => {
    expect(pipe.transform('')).toBe('');
  });

  it('应该处理null和undefined', () => {
    expect(pipe.transform(null as any)).toBe('');
    expect(pipe.transform(undefined as any)).toBe('');
  });

  it('应该处理多个单词', () => {
    expect(pipe.transform('angular framework')).toBe('Angular framework');
  });
});

5. 测试指令

// highlight.directive.spec.ts
import { Component, DebugElement } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { HighlightDirective } from './highlight.directive';

// 创建测试组件
@Component({
  template: `
    <div>
      <p appHighlight>默认高亮</p>
      <p appHighlight="yellow">黄色高亮</p>
      <p appHighlight [defaultColor]="'pink'">粉色高亮</p>
    </div>
  `
})
class TestComponent {}

describe('HighlightDirective', () => {
  let fixture: ComponentFixture<TestComponent>;
  let debugElements: DebugElement[];

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [TestComponent, HighlightDirective]
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(TestComponent);
    fixture.detectChanges();
    debugElements = fixture.debugElement.queryAll(By.directive(HighlightDirective));
  });

  it('应该有3个元素使用了指令', () => {
    expect(debugElements.length).toBe(3);
  });

  it('第一个元素应该有默认背景色', () => {
    const bgColor = debugElements[0].nativeElement.style.backgroundColor;
    expect(bgColor).toBe('rgb(255, 255, 0)'); // yellow
  });

  it('第二个元素应该有指定背景色', () => {
    const bgColor = debugElements[1].nativeElement.style.backgroundColor;
    expect(bgColor).toBe('rgb(255, 255, 0)'); // yellow
  });

  it('第三个元素应该有默认颜色输入', () => {
    const directive = debugElements[2].injector.get(HighlightDirective);
    expect(directive.defaultColor).toBe('pink');
  });

  it('鼠标进入应该改变背景色', () => {
    const de = debugElements[0];
    de.triggerEventHandler('mouseenter', null);
    fixture.detectChanges();

    expect(de.nativeElement.style.backgroundColor).toBe('rgb(173, 216, 230)'); // lightblue
  });

  it('鼠标离开应该恢复背景色', () => {
    const de = debugElements[0];

    // 先触发进入
    de.triggerEventHandler('mouseenter', null);
    fixture.detectChanges();

    // 再触发离开
    de.triggerEventHandler('mouseleave', null);
    fixture.detectChanges();

    expect(de.nativeElement.style.backgroundColor).toBe('rgb(255, 255, 0)'); // yellow
  });
});

6. 测试覆盖率

使用Istanbul收集测试覆盖率:

// package.json 脚本配置
{
  "scripts": {
    "test": "ng test",
    "test:coverage": "ng test --code-coverage",
    "test:watch": "ng test --watch",
    "test:ci": "ng test --watch=false --browsers=ChromeHeadless"
  }
}

测试覆盖率报告示例

Statements: 85% Branches: 78% Functions: 92% Lines: 87%

运行 ng test --code-coverage 后,在 coverage/ 目录下查看HTML报告。

7. 测试最佳实践

DO:应该做的事
  • 测试行为,而非实现 - 关注组件/服务的功能,而不是内部实现细节
  • 使用描述性的测试名称 - 使用 "should..." 格式,清晰表达预期行为
  • 每个测试一个断言 - 保持测试简单,易于理解和维护
  • 使用适当的beforeEach - 合理设置测试前置条件
  • 模拟外部依赖 - 使用TestBed和spy隔离测试单元
  • 清理测试环境 - 确保测试之间不会相互影响
DON'T:不应该做的事
  • 不要测试私有方法 - 测试公共API即可
  • 避免测试Angular框架本身 - 相信框架已经过测试
  • 不要编写脆弱的测试 - 避免测试中硬编码实现细节
  • 不要忽略异步测试 - 正确处理异步操作
  • 不要过度模拟 - 只在必要时使用模拟
  • 不要依赖测试顺序 - 每个测试应该独立运行

8. 常见测试模式

// 常用的测试工具和模式
import { TestBed, ComponentFixture, fakeAsync, tick } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';

// 1. 模拟用户输入
it('应该响应用户输入', () => {
  const input = fixture.debugElement.query(By.css('input'));
  input.nativeElement.value = '新值';
  input.nativeElement.dispatchEvent(new Event('input'));
  fixture.detectChanges();

  expect(component.value).toBe('新值');
});

// 2. 测试表单提交
it('应该提交表单数据', () => {
  const form = fixture.debugElement.query(By.css('form'));
  spyOn(component, 'onSubmit');

  form.triggerEventHandler('submit', null);

  expect(component.onSubmit).toHaveBeenCalled();
});

// 3. 测试路由导航
it('应该导航到详情页', () => {
  const router = TestBed.inject(Router);
  spyOn(router, 'navigate');

  component.viewDetails(1);

  expect(router.navigate).toHaveBeenCalledWith(['/details', 1]);
});

// 4. 测试HTTP拦截器
it('应该添加认证头', () => {
  const httpRequest = new HttpRequest('GET', '/api/data');
  const next: HttpHandler = {
    handle: jasmine.createSpy('handle')
  };

  interceptor.intercept(httpRequest, next);

  expect(next.handle).toHaveBeenCalled();
  const interceptedRequest = (next.handle as jasmine.Spy).calls.mostRecent().args[0];
  expect(interceptedRequest.headers.has('Authorization')).toBeTrue();
});
测试配置技巧
  • 使用 TestBed.configureTestingModule() 配置测试模块
  • 使用 HttpClientTestingModule 测试HTTP请求
  • 使用 RouterTestingModule 测试路由
  • 使用 ComponentFixtureAutoDetect 自动检测变更
  • 在CI/CD中使用 ChromeHeadless 无头浏览器
测试金字塔原则:
  • 单元测试(大量):测试单个组件、服务、管道、指令
  • 集成测试(适量):测试组件之间的交互
  • 端到端测试(少量):测试完整用户流程