如何使用 Spectator 编写简单的 Angular 集成测试
集成测试是前端代码测试中至关重要的工具。单元测试确保每个组件或服务在独立运行的情况下都能按预期工作,而集成测试则确保所有这些独立部分能够协同工作。Kent C. Dodds 的这篇精彩文章指出,集成测试的投资回报率最高,因此应该占测试总量的绝大部分。
Spectator是一个库,它封装了内置的 Angular 测试框架,提供了一个简单但强大的 API,从而减少了样板代码,使测试更清晰、更易读。
在本文中,我将向您展示 Spectator 如何帮助您轻松编写集成测试。
如果您想直接查看代码,可以在我的GitHub和StackBlitz上找到。
方法
编写集成测试时,我倾向于编写更贴近最终用户实际使用应用方式的测试。具体来说,这意味着:
- 无需测试实现细节——只需测试用户交互和输出
- 减少模拟对象(包括非浅渲染组件)
- 更长的测试,包含多个断言,更接近于手动测试工作流程。
示例申请
为了演示这种方法,我测试了一个简单的应用程序,该应用程序执行以下操作:
- 页面加载时,它会从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)));
}
}
我不会在这里深入探讨细节(我会向你展示,实现细节对于我们的测试实际上并不重要),但它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$;
}
}
测试设置
首先,我们需要在项目中安装 Spectator。
npm install @ngneat/spectator --save-dev
无论你使用 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
});
...
});
编写集成测试时,我们需要决定哪些组件和服务需要测试,哪些需要模拟。在本例中,我希望测试覆盖 `<Component>` 和 `<Service>` 。我不想测试 `<Component>`PostsComponent和`<Service>`,因此我将其添加到模拟数组中。这样,Spectator 会自动模拟 `<Component>` ,并将其每个函数转换为 Jasmine 间谍对象。PostsServiceDataServiceDataServicejasmine.createSpy()
我不想ngOnInit立即运行,因为我首先需要告诉它DataService如何模拟fetch响应,所以我将其设置detectChanges为false。
编写第一个测试
一切准备就绪,我们就可以编写第一个测试了!它将测试以下内容:
- 最初只显示进度条,没有任何帖子。
- 初始状态下,用户下拉菜单设置为“全部”。
- 一旦我们从 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);
}));
好吧,这里发生的事情很多。
首先,我在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
);
请注意,它selectOption还会在之后运行detectChanges(),以进一步减少样板代码。👍
测试搜索词过滤器时,我需要模拟用户在输入框中输入内容。为此,我可以使用以下typeInElement辅助函数:
const input = spectator.query(byLabel('Filter by title'));
spectator.typeInElement('first', input);
spectator.tick(300);
因为搜索输入更改时有 300 毫秒的防抖时间,所以我需要运行程序将计时器推进 300 毫秒(如前所述,spectator.tick(300)测试必须在指定区域内运行才能正常工作)。fakeAsync
结论
希望我已经向您展示了如何使用 Spectator 编写简单易读的集成测试,这些测试侧重于用户交互和结果,而不是实现细节,并让您确信您的代码能够按预期为最终用户工作。
完整代码请查看GitHub或StackBlitz。
资源
肯特·C·多兹——编写更少但更长的测试
Kent C. Dodds——编写测试。数量不多,主要是集成测试。
Netanel Basel -使用 FakeAsync 在 Angular 中测试异步代码
文章来源:https://dev.to/cjcoops/how-to-write-simple-angular-integration-tests-with-spectator-1i1b