0%

Test async task with angular and jest

Test async task with Angular + Jest

在前端开发中,Unit Test是很重要的一个环节,而异步任务测试又是Unit Test不可避免的一个环节。本文将介绍如何使用Angular + Jest来测试异步任务。

待测试函数返回一个Promise

这是最简单的情况了,直接使用async/await即可。

待测函数

1
2
3
getPromise() {
return Promise.resolve(1);
}

测试代码

1
2
3
4
it('test getPromise', async () => {
const result = await service.getPromise();
expect(result).toBe(1);
});

在实际项目中不会有这么简单的情况,大部分都是一个async函数,里面await了其他异步操作,比如下面代码中的handleData,我们该如何测试它呢?

待测函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
getData() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(10);
}, 100);
});
}

async handleData() {
const data = await this.getData();
return {
data,
timestamp: new Date().getTime(),
};
}

首先来分析一下,handleData是一个async方法,而async方法一定返回一个Promise(如果函数实际返回值不是Promise,那么async方法会用Promise包裹该返回值),所以我们还是可以直接使用async/await来测试。

测试代码

1
2
3
4
it('test handle data', async () => {
const data = await component.handleData();
expect(data.data).toBe(10);
});

最新版的Angular推荐使用waitForAsync来测试异步任务,所以我们也可以使用waitForAsync来测试。(注意waitForAsync中不能使用await。)

测试代码

1
2
3
4
5
it('test handle data', waitForAsync(() => {
component.handleData().then((data) => {
expect(data.data).toBe(10);
});
}));

当然也可以使用fakeAsync来测试,这个情景使用fakeAsync来测试有点大材小用,仅作示例。

1
2
3
4
5
6
it('test handle data', fakeAsync(() => {
service.handleData().then((data) => {
expect(data.data).toBe(10);
});
flush();
}));

注意不要忘记flush()操作,否则会产生如下错误:Error: 1 timer(s) still in the queue.

待测试函数包含异步操作,但是没有返回Promise。

以上情况还是有些简单,实际应用中,经常是一个函数中间夹杂着某些异步操作用来获取数据,然后对数据进行处理,最后可能也不返回Promise,对于这种情况,我们应该使用fakeAsync来测试。
fakeAsync内部有三个方法可以控制异步操作

  • tick: 让时间快进
  • flush: 让所有异步操作立即执行
  • flushMicrotasks: 让所有微任务立即执行
name 作用 使用场景
tick 用于控制时间流逝 想要精细控制每个timer的执行时机,tick在执行前会清空micro task队列,如果代码中有promise,tick之后,promise都会执行完毕。
flush 执行所有异步操作,不论是macro task还是micro task 这个最常用,无脑操作,将所有异步操作执行完,比如setTimeout和promise等,flush之后就可以expect了
flushMicrotasks 这个只执行micro task,对于前端来说,就是promise了,不会影响timer 如果代码中没有用到timer,可以使用这个。

下面看几个列子,分别讲解一下如何使用这三个方法来进行测试。

待测试函数只包含Macro Task

待测函数

1
2
3
4
5
6
7
8
9
10
11
updateValue() {
this.num = 10;

setTimeout(() => {
this.num = 11;
}, 100);

setTimeout(() => {
this.num = 12;
}, 200);
}

测试代码

1
2
3
4
5
6
7
8
it('test updateValue', fakeAsync(() => {
component.updateValue();
expect(component.num).toBe(10);
tick(100); // tick the timer by 100ms
expect(component.num).toBe(11);
tick(100); // tick the timer by 100ms again.
expect(component.num).toBe(12);
}));

来分析一下以上测试代码,首先我们调用了updateValue方法,然后期望num的值为10,因为updateValue中有两个timer,所以我们需要调用两次tick,第一次调用tick,让时间快进100ms,这时候第一个timer会执行,num的值变为11,然后再调用一次tick,让时间再快进100ms,这时候第二个timer会执行,num的值变为12。

当然了,如果你不想测试中间过程,而只想测试最终的结果,也可以使用Jest提供的useFakeTimer方法。

测试代码

1
2
3
4
5
6
it('test updateValue', () => {
jest.useFakeTimers();
component.updateValue();
jest.runAllTimers();
expect(component.num).toBe(12);
});

useFakeTimers会将所有的timer替换成一个fake的timer,然后调用runAllTimers,会让所有的timer立即执行,这样就可以直接测试最终结果了。

待测试函数只包含Micro Task

待测函数

1
2
3
4
5
6
7
8
fetchData() {
return Promise.resolve(10);
}
updateValue() {
this.fetchData().then((value) => {
this.num = value;
});
}

测试代码

1
2
3
4
5
6
it('test updateValue', fakeAsync(() => {
component.updateValue();
expect(component.num).toBe(0);
flushMicrotasks(); // 这里用tick()或者flush()也可以。但是flushMicrotasks更加精确。
expect(component.num).toBe(10);
}));

上述代码中,首先调用了updateValue方法,然后期望num的值为0,因为updateValue中有一个promise,所以我们需要调用flushMicrotasks,让所有的micro task立即执行,这时候num的值变为10。

当然上例中的flushMicrotasks也可以替换成flush,因为flush会让所有的异步操作立即执行,包括macro task和micro task。也可以使用tick,因为tick在执行之前也会先把微任务队列清空(把队列中的微任务都执行完)。

待测试函数同时包含Macro Task和Micro Task

待测函数

1
2
3
4
5
6
7
8
9
10
11
12
fetchData() {
return new Promise<number>((resolve) => {
setTimeout(() => {
resolve(10);
}, 100);
});
}
updateValue() {
this.fetchData().then((value) => {
this.num = value;
});
}

测试代码

1
2
3
4
5
6
it('test updateValue', fakeAsync(() => {
component.updateValue();
expect(component.num).toBe(0);
tick(100); //或者flush()
expect(component.num).toBe(10);
}));

上述代码中,fetchData()中同时包含Macro task和Micro task,所以我们可以使用tick或者flush来测试。但是使用flushMicrotasks就不行了,因为flushMicrotasks只会让micro task立即执行,而setTimeout是macro task,不会被执行。

Limitation: The fakeAsync() function won’t work if the test body makes an XMLHttpRequest (XHR) call. XHR calls within a test are rare, but if you need to call XHR, see the waitForAsync() section.

需要更新UI的异步测试

上面的测试用例都不涉及UI更新,如果需要测试UI更新,那么需要使用fixture.detectChanges()来触发Angular的变更检测。

fixture.WhenStable()

这个方法也是用来处理异步任务的,可以稍后总结一下。fixture.WhenStable()通常和waitForAsync一起配合使用。

1
2
3
4
5
6
7
8
9
10
11
it('should show quote after getQuote (waitForAsync)', waitForAsync(() => {
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');

fixture.whenStable().then(() => {
// wait for async getQuote
fixture.detectChanges(); // update view with quote
expect(quoteEl.textContent).toBe(testQuote);
expect(errorMessage()).withContext('should not show error').toBeNull();
});
}));

总结

  • 如果函数返回一个Promise,那么直接使用async/await测试即可。新版的Angular推荐使用waitForAsync来测试。
  • 如果函数中间夹杂着异步操作,但是没有返回Promise,那么分为以下三种情况
    • 待测试函数只包含微任务 - 使用fakeAsync配合flushMicrotasks来控制异步操作。
    • 待测试函数只包含宏任务 - 使用fakeAsync配合tick或者flush来控制异步操作。
    • 待测试函数同时包含微任务与宏任务 - 使用fakeAsync配合tick或者flush来控制异步操作。
  • 能用async/await或者waitForAsync测试的一定能用fakeAsync测试,反之不成立。

最终结论: 在Angular + Jest为基础的项目中,使用fakeAsync + tick/flush能搞定所有的异步测试。