发布于 2026-01-06 0 阅读
0

如何使用 Spectator 编写简单的 Angular 集成测试

如何使用 Spectator 编写简单的 Angular 集成测试

集成测试是前端代码测试中至关重要的工具。单元测试确保每个组件或服务在独立运行的情况下都能按预期工作,而集成测试则确保所有这些独立部分能够协同工作。Kent C. Dodds 的这篇精彩文章指出,集成测试的投资回报率最高,因此应该占测试总量的绝大部分。

Spectator是一个库,它封装了内置的 Angular 测试框架,提供了一个简单但强大的 API,从而减少了样板代码,使测试更清晰、更易读。

在本文中,我将向您展示 Spectator 如何帮助您轻松编写集成测试。

如果您想直接查看代码,可以在我的GitHubStackBlitz上找到。

方法

编写集成测试时,我倾向于编写更贴近最终用户实际使用应用方式的测试。具体来说,这意味着:

  • 无需测试实现细节——只需测试用户交互和输出
  • 减少模拟对象(包括非浅渲染组件)
  • 更长的测试,包含多个断言,更接近于手动测试工作流程。

示例申请

为了演示这种方法,我测试了一个简单的应用程序,该应用程序执行以下操作:

  • 页面加载时,它会从JSONPlaceholder API获取帖子列表,并将响应渲染到屏幕上。
  • 数据加载时会显示进度条。
  • 数据加载完毕后,我们可以从下拉菜单中选择用户,这样就能筛选出仅包含该用户帖子的内容。
  • 我们可以在搜索框中输入内容,根据搜索条件进行筛选,防抖时间为 300 毫秒。

完整代码请查看StackBlitz,以下是我们的PostsService

export class PostsService {
  constructor(private dataService: DataService) {}

  private posts = new BehaviorSubject<Post[]>(null);
  private loading = new BehaviorSubject<boolean>(true);

  posts$ = this.posts.asObservable();
  loading$ = this.loading.asObservable();

  // call the DataService which is responsible for making HTTP requests and then update the posts and loading states
  load() {
    this.loading.next(true);

    return this.dataService.fetch().pipe(
      tap(response => {
        this.posts.next(response);
        this.loading.next(false);
      })
    );
  }

  // return an observable with the filtered posts
  getPosts(searchTerm: string, userId: number) {
    const filterFunction = (post: Post) =>
      (!searchTerm ||
        post.title.toLowerCase().includes(searchTerm.toLowerCase())) &&
      (!userId || post.userId === userId);

    return this.posts$.pipe(map(posts => posts.filter(filterFunction)));
  }
}
Enter fullscreen mode Exit fullscreen mode

我不会在这里深入探讨细节(我会向你展示,实现细节对于我们的测试实际上并不重要),但它PostsService负责存储我们的状态以及加载和过滤帖子数据。

不过需要指出的一点是,该应用程序使用了一个服务DataService来发出 HTTP 请求。无论如何,创建一个单独的服务来发出 HTTP 请求都是最佳实践,而且我发现这样做也使测试更容易,因为模拟该服务比模拟 Angular 的服务要简单得多HTTPClient

以下是我们的PostsComponent

export class PostsComponent implements OnInit {
  searchTermControl = new FormControl('');
  userFilterControl = new FormControl(null);
  posts$: Observable<Post[]>;
  loading$: Observable<boolean>;

  constructor(private service: PostsService) {}

  ngOnInit(): void {
    // request the posts data
    this.service.load().subscribe();

    // when the searchterm or the user filter changes get the filtered posts based on the filter
    this.posts$ = combineLatest(
      this.searchTermControl.valueChanges.pipe(
        debounceTime(300),
        startWith('')
      ),
      this.userFilterControl.valueChanges.pipe(startWith(null))
    ).pipe(
      switchMap(([searchTerm, userId]) => {
        return this.service.getPosts(searchTerm, userId);
      })
    );

    // loading state observable
    this.loading$ = this.service.loading$;
  }
}
Enter fullscreen mode Exit fullscreen mode

测试设置

首先,我们需要在项目中安装 Spectator。

npm install @ngneat/spectator --save-dev
Enter fullscreen mode Exit fullscreen mode

无论你使用 Jasmine 还是 Jest,Spectator 都能开箱即用,无需额外配置,但在这个例子中,我将使用 Jasmine。

posts.component.spec.ts首先,我们使用 Spectator 的函数来设置测试。createComponentFactory这里,我们需要告诉 Spectator 我们要测试哪个组件,并导入该组件所需的任何模块。在本例中,该组件依赖于 `<component>`FormsModule和 `<module>` ReactiveFormsModule,因此我们将它们添加到imports数组中。

在这里,我们还可以声明任何子组件或我们需要提供的任何服务,类似于设置一个组件的方式ngModule,但在这个简单的例子中,没有任何子组件或服务。

// posts.component.spec.ts
describe('PostsComponent', () => {
  const createComponent = createComponentFactory({
    component: PostsComponent,
    imports: [FormsModule, ReactiveFormsModule],
    mocks: [DataService],
    detectChanges: false
  });

  ...
});
Enter fullscreen mode Exit fullscreen mode

编写集成测试时,我们需要决定哪些组件和服务需要测试,哪些需要模拟。在本例中,我希望测试覆盖 `<Component>` 和 `<Service>` 。我不想测试 `<Component>`PostsComponent`<Service>`,因此我将其添加到模拟数组中。这样,Spectator 会自动模拟 `<Component>` ,并将其每个函数转换为 Jasmine 间谍对象PostsServiceDataServiceDataServicejasmine.createSpy()

我不想ngOnInit立即运行,因为我首先需要告诉它DataService如何模拟fetch响应,所以我将其设置detectChangesfalse

编写第一个测试

一切准备就绪,我们就可以编写第一个测试了!它将测试以下内容:

  • 最初只显示进度条,没有任何帖子。
  • 初始状态下,用户下拉菜单设置为“全部”。
  • 一旦我们从 API 收到帖子数据,页面就会显示帖子,进度条将不再显示。

以下是测试内容:

it('should load a list of posts for all users by default', fakeAsync(() => {
  // create the test component
  const spectator = createComponent();

  // get the mocked instance of the DataService
  const dataService = spectator.get(DataService);

  // mock the fetch function to wait 100ms and return 2 posts
  dataService.fetch.and.returnValue(
    timer(100).pipe(
      mapTo([
        {
          userId: 1,
          id: 1,
          title: 'First Post'
        },
        {
          userId: 2,
          id: 2,
          title: 'Another Post'
        }
      ])
    )
  );

  // run ngOnInit
  spectator.detectChanges();

  // assert that the progress bar is showing
  expect(spectator.query(MatProgressBar)).toExist();
  expect(spectator.query(byText('First Post'))).not.toExist();

  // get the user select element
  const select = spectator.query(
    byLabel('Filter by user')
  ) as HTMLSelectElement;

  // assert that it is showing 'All' by default
  expect(select).toHaveSelectedOptions(
    spectator.query(byText('All')) as HTMLOptionElement
  );

  // advance the time 100ms to simulate the HTTP request being made
  spectator.tick(100);

  // assert that the progress bar is not showing and that both our posts are showing
  expect(spectator.query(MatProgressBar)).not.toExist();
  expect(spectator.queryAll(MatListItem).length).toEqual(2);
  expect(spectator.query(byText('First Post'))).toExist();

  expect(dataService.fetch).toHaveBeenCalledTimes(1);
}));
Enter fullscreen mode Exit fullscreen mode

好吧,这里发生的事情很多。

首先,我在Angular的测试区域中执行测试fakeAsync。这样,我们可以通过控制时间的流逝,轻松地以同步的方式测试异步代码。

如果你想了解更多关于在 Angular 中测试异步代码的信息,我强烈推荐你阅读Netanel Basel 的这篇文章。

使用我们之前创建的工厂创建测试组件后,我模拟了该fetch方法DataService。我告诉它返回一个可观察对象,该对象等待 100 毫秒以模拟 HTTP 请求,然后返回一个帖子数组。

有了这个模拟对象,我就可以调用spectator.detectChanges()它,这相当于ngOnInit()在组件上运行。

此时我想确认进度条存在,但我的帖子没有显示,并且用户下拉菜单显示“全部”,我可以使用 Spectator 的自定义匹配器轻松实现这一点toHaveSelectedOptions

然后我将计时器向前推进 100 毫秒,spectator.tick(100)此时该fetch方法将返回帖子列表。

最后,我可以断言进度条已不存在,并且这两篇文章确实存在。我还断言该fetch方法只被调用了一次。

关于这项测试,请注意以下几点:

  • 首先,我没有测试任何实现细节。在这个例子中,我的应用只包含一个组件PostsComponent和一个状态管理PostsService组件,但随着应用规模的扩大,我可能需要重构它,使用多个组件或不同的状态管理方法,例如引入状态管理库。这种方法的优点在于,我可以重构代码,只要我的解决方案仍然调用同一个组件DataService,我的测试就不需要更改! 😄

  • 其次,我使用了多个断言。这样做可以避免在不同测试之间共享状态,从而防止潜在的危险。此外,由于现代测试运行器能够准确地告诉我们测试的哪个部分失败了,因此无需为每个断言编写单独的测试。

  • 总的来说,我的测试与真实用户使用应用程序的方式,或者手动测试人员测试此功能的方式非常接近,因此我对我的代码充满信心。使用 Spectator 的 DOM 选择器(例如 `<div>` spectator.query(byLabel('Filter by user')))对此很有帮助,因为真实用户会使用这种方式查找元素,而不是通过元素的id属性。此外,它还隐式地测试了可访问性。

筛选帖子

我们还需要测试帖子过滤功能,所以让我们最后再看几个例子,看看 Spectator 如何让过滤变得如此简单。

我的应用在按用户筛选时使用下拉选择框。为此,我可以使用 Spectator 的selectOption匹配器来选择我的选项。

const select = spectator.query(
  byLabel('Filter by user')
) as HTMLSelectElement;

spectator.selectOption(
  select,
  spectator.query(byText('User 2')) as HTMLOptionElement
);
Enter fullscreen mode Exit fullscreen mode

请注意,它selectOption还会在之后运行detectChanges(),以进一步减少样板代码。👍

测试搜索词过滤器时,我需要模拟用户在输入框中输入内容。为此,我可以使用以下typeInElement辅助函数:

const input = spectator.query(byLabel('Filter by title'));

spectator.typeInElement('first', input);

spectator.tick(300);
Enter fullscreen mode Exit fullscreen mode

因为搜索输入更改时有 300 毫秒的防抖时间,所以我需要运行程序将计时器推进 300 毫秒(如前所述,spectator.tick(300)测试必须在指定区域内运行才能正常工作)。fakeAsync

结论

希望我已经向您展示了如何使用 Spectator 编写简单易读的集成测试,这些测试侧重于用户交互和结果,而不是实现细节,并让您确信您的代码能够按预期为最终用户工作。

完整代码请查看GitHubStackBlitz

资源

肯特·C·多兹——编写更少但更长的测试

Kent C. Dodds——编写测试。数量不多,主要是集成测试。

Netanel Basel -使用 FakeAsync 在 Angular 中测试异步代码

文章来源:https://dev.to/cjcoops/how-to-write-simple-angular-integration-tests-with-spectator-1i1b