今天我们来聊聊如何撰写测试程序来确保写出来的 RxJS 如我们所想的一般运作,也就是撰写测试程序码!撰写测试程序是软件开发中非常重要的一环,虽然不是所有程序码都一定要有对应的测试程序,但良好的测试程序却可以帮助我们撰写住更加稳固的程序码。
至於到底该怎麽测试 RxJS?撰写 RxJS 测试程序时又有什麽需要注意的呢?就让我们继续看下去吧!
这边假设大家对於撰写前端测程序已经有一定理解和经验了,就不多说明基本的撰写测试方法了;接下来我们撰写的测试程序都是运行在 Karma + Jasmine 上,原始档如下:
https://github.com/wellwind/rxjs-marble-testing-demo
当然,想要用其他测试框架也是完全 ok 的喔!
只要专案下载下来後执行 npm install
安装相关套件,接着执行 npm run test
即可看到所有测试程序执行的结果。
范例专案内多数都是些基本的设定如使用套件、TypeScript 设定等等,另外有两个重要的目录:
src
:所有要测试的目标程序码所在的位置test
:所有针对测试目标所撰写的测试程序码在 RxJS 中,基本上有两件事情要测试,分别是
在范例专案中,我们设计了几种 Observable,有些很好测试,有些则相对没那麽简单:
export const emitOne$ = of(1);
export const emitOneToFour$ = of(1, 2, 3, 4);
export const emitOntToFourPerSecond$ = timer(0, 1000).pipe(
take(4)
);
另外我们也设计了一个 operator:
export const plusOne = ()
=> (source$: Observable<number>)
=> source$.pipe(map(value => value + 1));
这个 operator 内容很简单,将输入的资料加一而已,主要是拿来测试整个资料流是否能依照我们设计的 operator 流动。
另外,我们在前几天实战介绍「自动完成」功能时,有透过组合好几个 operators 来让资料查询不要那麽频繁,我们也将它抽成一个比较复杂一点的 operator 来测试看看:
export const debounceInput = ()
=> (source$: Observable<string>)
=> source$.pipe(
debounceTime(300),
distinctUntilChanged(),
filter(data => data.length >= 3)
);
接着就让我们来看看两种不同的测试手法,一种很简单,但比较难测试各种情境;另外一种比较复杂,但可以应付几乎所有情境的测试。
第一种方法很简单,这种手法在「同步 Observable」或是「非同步 Observable 但有明确结束时机点」两种状况时适合使用,也不用想太多,直接在 subscribe
的 callback 方法内测试结果即可。
测试程序码请参考范例专案内的 test/subscribe-test.spec.ts
。
针对 of(1)
这种只有一个事件值的测试程序可以很容易的直接在 subscribe
内进行验证:
// const emitOne$ = of(1);
it('测试单一个事件的 Observable', () => {
emitOne$.subscribe(data => {
expect(data).toEqual(1);
});
});
非常简单吧!
那麽如果来源 Observable 有多个事件值呢?expect(data)
後面该怎麽写?这时我们可以在 subscribe
时将订阅值存起来,再测试结果:
// const emitOneToFour$ = of(1, 2, 3, 4);
it('测试多个事件的 Observable', () => {
const actual: number[] = [];
emitOneToFour$.subscribe(data => {
actual.push(data);
});
expect(actual).toEqual([1, 2, 3, 4]);
});
上述程序第 4 行将每次事件资料存到 actual
阵列中,接着在第 7 行比较结果。由於整个 Observable 是「同步执行」的,因此可以确定所有事件都发送完毕,直到 Observable 结束後才进行比较。
如果是「非同步执行」的 Observable 如 timer
处理呢?那就先要看测试框架是否支援非同步处理了,以 Jasmine 来说可以在测试 function 内传入一个 done
的 function,并在非同步程序中测试完毕後呼叫它代表非同步程序结束:
// const emitOntToFourPerSecond$ = timer(0, 1000).pipe(
// take(4)
// );
it('测试非同步处理的 Observable', (done) => {
const actual: number[] = [];
emitOntToFourPerSecond$.subscribe({
next: data => {
actual.push(data);
},
complete: () => {
expect(actual).toEqual([0, 1, 2, 3]);
done();
}
});
});
上述程序第 8 行在 subscribe
中的 next()
将来源资料存入阵列,直到 Observable 结束後,在 complete()
内验证测试结果,并呼叫 done()
告知测试框架「非同步处理的程序码测试完毕」。
这种方式看起来合理,但上面程序码实际上有一些缺点
take()
operator 或想办法加上其他条件让它结束,但那就变成在写程序而不是写测试了,因此不是个推荐的解法这些问题,在之後介绍「弹珠图测试」时会解决。
如果要测试某个自订的 operator 是否如我们预期处理资料流,只需要准备一个来源 Observable 并搭配 pipe
将资料传入我们自订的 operator 即可:
// const plusOne = ()
// => (source$: Observable<number>)
// => source$.pipe(map(value => value + 1));
it('使用 pipe 测试 operator', () => {
of(1).pipe(
plusOne()
).subscribe(data => {
expect(data).toEqual(2);
});
});
it('单独测试一个 operator', () => {
const source$ = of(1);
plusOne()(source$).subscribe(data => {
expect(data).toEqual(2);
});
});
有两种测试方式,其实是一样的概念,第一种是把它真的当作 operator,以操作 operator 的方式处理,因此第 5 行使用 of(1).pipe(...)
的方式,来确认资料流向及处理。
而第二种测试是把它当作一个 function,因为 operator 其实就是一个 function 而已,所以第 14 行我们呼叫这个 plusOne
function 後,会得到一个参数和回传值都是 Observable 的 function,再把我们的来源 Observable 当参数传入,然後订阅测试结果。
上一段落我们单纯的使用测试框架提供的功能来进行测试,已经足以应付不少情境了,但还是有些情境不是那麽适合,因此 RxJS 提供了一个 TestScheduler 测试工具,来协助我们以更直觉、图像的方式处理各种同步、非同步的 RxJS 程序码!
Scheduler 是用来「安排」事件发生时机点的工具,而 TestScheduler 就是其中一种,这种 Scheduler 可以帮助我们处理测试中遇到的各种问题。
关於 Scheduler,可以参考之前的文章「[RxJS] 认识 Scheduler」
TestScheduler 是 RxJS 开发出来协助我们撰写测试程序的工具,它可以帮助我们:
不过 TestScheduler 使用上也有些条件:「必须使用跟 timer 相关的 Observable 当作测试来源,如 timer
、interval
、delay
等等」,这类型的测试来源都是使用 asyncScheduler
;而使用 Promise 或是其他非同步的 Scheduler 如 asapScheduler
和 animationFrameScheduler
则会变得比较不可靠,这是需要注意的一点。
当然,遇到这种状况时,还是可以走回原来
subsctibe
callback 的测试方式
要建立 TestScheduler 很简单,因为它只是个类别,直接 new 它就好,而在建立时需要传入一个 callback function 用来决定如何比较两个物件是否相同。
const testScheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
上述程序我们产生了一个新的 TestScheduler,并在建立时指定比较两个物件的方法,以 Jasmine 的例子来说,我们使用 toEqual
来进行深层比较 (deep equality comparison),以确保物件内所有属性都完全相等。如果使用其他测试框架,则须要看该测试框架使用那种方式比较两个物件。
建立好 TestScheduler 後,我们需要呼叫该物件的 run()
方法,run()
方法内也是一个 callback function,在此 function 内会得到一个 helper
物件,这个物件可以帮助我们「以同步的方式测试非同步 Observable」,这个 helper 包含几个方法:
hot
:依照指定的弹珠图建立一个 Hot Observablecold
:依照指定的蛋ˋ猪屠建立一个 Cold ObservableexpectObservable(...).toBe(...)
:用来判断两条 Observable 是否结果相同expectSubscription(...).toBe(...)
:用来判断「订阅」和「结束订阅」的结果是否符合预期flush
:用来立即完成一个 Observable,通常用不到,只要在很细的控制测试需要我们之前已经介绍过了「文字版弹珠图」,里面的符号在一般沟通时已经足够使用,但在测试时,需要更精准的知道事件发生的时间,因此让我们回顾一下基本的符号、在测试时的意义,以及之前没有用到的符号。
-
:如同之前所介绍,它是一个时间的最小单位,在测试中我们称为一个 frame
,通时它也虚拟地代表了 1 毫秒。
:空白符号,一样只是用来对齐使用,不会发生任何事情,也不代表时间[0-9]+[ms|s|m]
:代表经过了多少时间,毕竟一个 -
代表 1 毫秒,那一秒钟要画 1000 个 -
,太辛苦了[a-z0-9]
:代表事件发生了,一个符号代表一个事件值发生了,也就是 next()
被呼叫了( )
:用来群组同时间发生的资料,例如 (12)
并不是事件「十二」发生,而是同一个时间点 (frame) 发生了 1
和 2
两个事件|
:Observable 完成#
:Observable 发生错误^
:代表订阅开始的时间点,专门用来测试订阅何时开始的!
:代表订阅结束的时间点,专门用来测试订阅何时结束的上述符号在测试时,^
和 !
在测试 Observable 资料流程时不可使用;而在测试订阅时机时,则可以使用 ^
、!
、和时间相关的符号 1
和 [0-9]+[ms|s|m]
,稍後看到实际程序会更加清楚。
实际画几个图来说明一下:
-
)----------
(10ms)
// 上下两组发生时间一样长
--- 1s ---
// 实际上用掉了 1006 个 frames
--- ---
// 实际只用掉了 6 个 frames
a 1s b
=> a 事件发生後,再等待 1000 个 frames 後发生 b 事件
--- 1 ---
---(abc)---
=> 用掉了 7 个 frame,事件 abc 都在同一个 frame 发生
---1---2---3---4---5|
^-----------|
=> 实际上订阅的时间点
接着就让我们直接举例,用弹珠图来进行测试看看吧!我们先把 Observable 都写在测试程序里面,以便理解各种弹珠图测试方式。
测试程序码请参考范例专案内的 test/marble-test-basic.spec.ts
。
首先第一步,在每个要使用 TestScheduler 的测试之前,都需要建立一次 TestScheduler,因此我们用 beforeEach
来处理:
describe('使用 TestScheduler 测试', () => {
let testScheduler: TestScheduler;
beforeEach(() => {
testScheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
});
});
接着只要在测试案例的 it
内,呼叫 testScheduler.run()
并执行测试即可,以下以 take
operator 进行示范:
it('测试 take operator', () => {
testScheduler.run(helpers => {
const { cold, expectObservable, expectSubscriptions } = helpers;
const sourceMarbleDiagram = '---a---b---c---d---e---|';
const expectedSubscription = '^----------!';
const expectedResult = '---a---b---(c|)';
const sourceObservable = cold(sourceMarbleDiagram);
const source$ = sourceObservable.pipe(take(3));
expectObservable(source$)
.toBe(expectedResult);
expectSubscriptions(sourceObservable.subscriptions)
.toBe(expectedSubscription);
});
});
testScheduler.run
开始进行弹珠图测试helpers
中取出take(3)
)expectObservable
测试 source$
订阅後产生的资料流弹珠图是否符合预期expectSubscriptions
测试 source$
的订阅时机点 (sourceObservable.subscription
)是否符合预期这样就完成第一个弹珠图测试啦!之後要进行弹珠图测试时,可以用同样的模式,把要被测试的目标换掉 (以这边的例子是 source$
),以及设定正确的弹珠图,就可以看到结果罗!
上述例子我们用 a
、b
和 c
当作事件值,但实际上我们的来源 Observable 不会只有这麽单纯的文字资料而已,很多时候都是物件的处理,这时候我们可以在使用 cold
和 hot
建立 Observable 时给予一个对应事件名称当作属性的物件,当作来源资料;同时在 expectObservable
时用一样的方式给予物件,当作结果资料。先看个简单的例子:
it('测试 map operator (带入 value)', () => {
testScheduler.run(helpers => {
const { cold, expectObservable } = helpers;
const sourceMarbleDiagram = '--a--b--c--d--|';
const expectedResult = '--w--x--y--z--|';
const sourceObservable = cold(sourceMarbleDiagram, { a: 1, b: 2, c: 3, d: 4 });
const source$ = sourceObservable.pipe(map(value => value + 1));
expectObservable(source$).toBe(expectedResult, { w: 2, x: 3, y: 4, z: 5 });
});
});
cold
建立 Cold Observable 时,除了给予弹珠图文字外,也传入一个物件,因此弹珠图上的事件 a
实际上发出的事件值会是物件内的数字 1
,以此类推toBe
传入预期的结果物件,因此弹珠图上的事件 w
实际上会发出的事件值会是物件内的数字 2
,以此类推看完简单的例子後,改成带入更复杂的物件看看,原理一样,就不多说明罗:
it('测试 map operator (带入更复杂的 value)', () => {
testScheduler.run(helpers => {
const { cold, expectObservable } = helpers;
const input = {
a: { name: 'Student A', score: 25 },
b: { name: 'Student B', score: 49 },
c: { name: 'Student C', score: 100 },
d: { name: 'Student D', score: 0 }
};
const expected = {
w: { name: 'Student A', score: 50 },
x: { name: 'Student B', score: 70 },
y: { name: 'Student C', score: 100 },
z: { name: 'Student D', score: 0 }
};
const sourceMarbleDiagram = '--a--b--c--d--|';
const expectedResult = '--w--x--y--z--|';
const sourceObservable = cold(sourceMarbleDiagram, input);
const source$ = sourceObservable.pipe(
map(student => ({ ...student, score: Math.sqrt(student.score) * 10 }))
);
expectObservable(source$).toBe(expectedResult, expected);
});
});
使用 testScheuler
除了可以画图用视觉画理解测试外,另一个最大的方便就是一切都变成同步执行的,尽管 Observable 是每秒发生一次,在 testScheduler
内也只是虚拟时间,不会等待那麽长的时间,而弹珠图中也可以直接用时间单位表达发生多久,如下:
it('测试时间 time frame', () => {
testScheduler.run(helpers => {
const { cold, expectObservable } = helpers;
const sourceMarbleDiagram = '(123|)';
const expectedResult = '--- 7ms 1 9ms 2 9ms (3|)';
const sourceObservable = cold(sourceMarbleDiagram);
const source$ = sourceObservable.pipe(
concatMap(value => of(value).pipe(delay(10)))
);
expectObservable(source$).toBe(expectedResult);
});
});
这里重点测试是 source$
内的 delay(10)
,透过 concatMap
让原始 Observable 的每个值都延迟 10ms 发生,然後串在一起。
在 expectedResult
里面 --- 7ms
实际上就是 10 毫秒,直接写成 10 ms
也可以,意义完全一样;;而整体 Observable 弹珠图则是 1 9ms 2 9ms (3|)
,值得注意的是每次事件发生的那个当下也代表了一个 frame
,因此是 1 9ms
而不是 1 10ms
,而最後事件值 3
发生後会直接结束,因此使用 ( )
包起来, (3|)
代表两个事件 (3
和 |
结束) 是在同一个 frame 发生的。
就算是一个不会结束的 Observable,弹珠图画出来也只是没有 |
符号而已罗。
Cold Observable 在每次订阅时会重新跑完整个资料流,而 Hot Observable 则是一个资料流,在不同时间点订阅可能会得到不同的结果,因此 Hot Observable 测试时还有一个重点,就是不同时间订阅的结果,听起来很麻烦,但一画成弹珠图就简单多了!
it('测试 Hot Observable', () => {
testScheduler.run((helpers) => {
const { hot, expectObservable } = helpers;
const sourceMarbleDiagram = '--1--2--3--4--5--6--7--8';
const subscription1 = '-------^-------!';
const subscription2 = '-----------^-----!';
const expectedResult1 = '--------3--4--5-';
const expectedResult2 = '-----------4--5---';
const sourceObservable = hot(sourceMarbleDiagram);
expectObservable(
sourceObservable,
subscription1
)
.toBe(expectedResult1);
expectObservable(
sourceObservable,
subscription2
)
.toBe(expectedResult2);
});
});
上面程序我们只订了两个订阅时机 (subscription1
和 subscription2
),从弹珠图就可以看到时机点的不同,以及画出两种预期得到资料的结果,在处理 Hot Observable 时,可以在 expectObservable
中给予订阅的时机,藉此来比较该订阅时机点下,得到的弹珠图是否与预期相同。
有了这些观念後,就让我们实际测试看看一开始写的那些程序吧!
测试程序码可以参考范例专案内的 src/marble-test-for-observables.spec.ts
以及 src/marble-test-debounce-input.spec.ts
两个档案,里面有各种写法,有了前面的观念後要写出类似的测试程序应该不困难,这里只挑出一些重点。
与之前一样,只是来源 Observable 是我们真正写好的程序码:
it('使用弹珠图测试单一个事件的 Observable', () => {
testScheduler.run((helpers) => {
const { expectObservable } = helpers;
// 1 会被当事件字串,因此不能这样写
// const expectedResult = (1|);
const expected = '(a|)';
expectObservable(emitOne$).toBe(expected, { a: 1 });
});
});
这里有一个值得注意的小地方,由於弹珠图内的事件预设都是字串,因此 of(1)
虽然一般可以想成弹珠图 (1|)
,但在测试时 1
会被当作字串,而跟结果比较不符合,所以正确的写法是给一个字母如 a
当作事件点,然後把物件传入来指定该是件时间点的事件值。
可以画弹珠图之後,一切就变得简单啦!
it('使用弹珠图测试多个事件的 Observable', () => {
testScheduler.run(helpers => {
const { expectObservable } = helpers;
const expected = '(abcd|)';
expectObservable(emitOneToFour$)
.toBe(expected, { a: 1, b: 2, c: 3, d: 4 });
});
});
一样要注意的是资料型态问题。
要测试非同步 Observable 也不困难,在弹珠图上标记经过时间即可:
it('使用弹珠图测试非同步处理的 Observable', () => {
testScheduler.run((helpers) => {
const { expectObservable } = helpers;
// 因为事件本身占一个 frame,所以用 999ms
const expected = 'a 999ms b 999ms c 999ms (d|)';
expectObservable(emitOntToFourPerSecond$)
.toBe(expected, { a: 0, b: 1, c: 2, d: 3 });
});
});
跟之前提过的一样,事件发生点本身就占一个 frame,这是需要多注意的地方。
最後要来测试前面提到旦没测试过的 debounceInput()
啦!这个 debounceInput()
是三个 operators 组合起来的,所以至少根据每个 operator 特性会撰写一组测试案例才对,同时这些测试案例的结果也必须符合组合起来的结果。
首先是最简单的「文字长度大於等於 3」也就是 filter(data => data.length >= 3)
的条件:
it('文字长度大於等於 3 才允许事件发生', () => {
testScheduler.run((helpers) => {
const { cold, expectObservable } = helpers;
const input = {
a: 'rxjs',
b: 'rx',
};
const expectedOutput = {
x: 'rxjs',
};
// b 事件的内容不到 3 个字,因此没有事件发生
const sourceMarbleDiagram = 'a 300ms 100ms b';
const expectedResult = ' 300ms x 100ms ';
const sourceObservable = cold(sourceMarbleDiagram, input);
const source$ = sourceObservable.pipe(debounceInput());
expectObservable(source$)
.toBe(expectedResult, expectedOutput);
});
});
input
代表每次事件发生时输入的内容,可以搭配 sourceMarbleDiagram
弹珠图一起看,而 expectedOutput
则搭配 expectedResult
弹珠图忆起看,在事件 a
时间点时,输入内容超过 3 个字,因此预期的 Observable 会得到这个资料,而事件 b
时间点指输入 2 个字,因此在新的 Observable 上没有事件发生。同时考量到整个 operator 有一个 debounceTime(300)
,因此事件 a
发生後等待 300ms
没有新事件才会在新的 Observable 上发生事件 x
。
这里的 x
实际上就是 a
事件,因此我们也可以把 expectedResult
的 x
直接取代成 a
,比较时可以都传入 input
会更好理解这个弹珠图,这里只是示范带入不同物件的方法。
接着测试 debounceTime(300)
的行为:
it('300ms 内没有的输入才允许事件发生', () => {
testScheduler.run((helpers) => {
const { cold, expectObservable } = helpers;
const input = {
a: 'rxjs-demo',
b: 'rxjs-test',
c: 'rxjs',
};
// a--b 後等待 100ms 继续输入文字 (事件 c),因为没超过 300ms 所以没有新事件
// 之後 300ms 没有新的输入,将最後资料当作事件发送
const sourceMarbleDiagram = 'a--b 100ms c';
const expectedResult = '---- 100ms 300ms c';
const sourceObservable = cold(sourceMarbleDiagram, input);
const source$ = sourceObservable.pipe(debounceInput());
expectObservable(source$).toBe(expectedResult, input);
});
});
考量到 filter(data => data.length >= 3)
的条件,我们将事件内容都设定超过 3 个字,而事件 a
、b
和 c
之间的间隔都没有超过设定的 300ms
因此过程中不会有新的事件,直到事件 c
发生後 300ms
没有新事件,才在新的 Observable 发生来源 Observable 的最後一个事件值 c
。
最後测试 distinceUntilChanged
的行为:
it('事件值跟上次相同时,不允许本次事件发生', () => {
testScheduler.run((helpers) => {
const { cold, expectObservable } = helpers;
const input = {
a: 'rxj',
b: 'rxjs',
c: 'rxjs',
d: 'rxj',
};
// 由於 b 事件跟 c 事件的内容一样,因此事件不会发生
// 由於 c 事件跟 d 事件的内容不同,因此事件继续发生
// 若使用 distinct 则会因为 a 和 d 事件一样而不发生新事件
const sourceMarbleDiagram = 'a 300ms b 300ms c 300ms d';
const expectedResult = ' 300ms a 300ms b - 300ms 300ms d';
const sourceObservable = cold(sourceMarbleDiagram, input);
const source$ = sourceObservable.pipe(debounceInput());
expectObservable(source$).toBe(expectedResult, input);
});
});
一样的需要考量到 debounce(300)
和 filter(data => data.length >= 3)
的问题;事件 a
发生後 300ms
发生在新的 Observable 上,事件 b
也是;而事件 c
和事件 b
内容相同,因此不会发生在新的 Observable 上。
今天我们学习到两种测试 RxJS 的方法
subscribe
callback 内验证:直觉简单,但比较不能应付复杂情境,非同步程序的测试可能会拉长整体测试时间如果 Observable 真的很简单,可以考虑使用 subscribe
callback 的方法就好,如果遇到比较复杂的状况,RxJS 都帮我们想好了,使用 TestScheduler 可以帮助我们用很好理解的方式撰写测试程序罗!
终於!把我觉得 RxJS 想介绍的内容都写完了!!每次写铁人赛都有一种很深的感觉:「自己才是学到最多的那个人」,这种感觉很棒!!当然,也希望这 35 篇内容可以帮助大家由浅入深的理解 RxJS,同时体会到它的强大、便利以及可靠。虽然 RxJS 真的是一个比较抽象的东西,但只要花点时间多多练习这些内容,相信一定可以帮助大家打通 RxJS 任督二脉,摇身一变成高手啦!
RxJS 还有很多 operators 没机会介绍到,之後我会再花些时间整理这些 operators,以及补上更多实战练习,这部分就敬请期待罗。
<<: 强型闯入DenoLand[33] - Web API 正式完成!
前言 串连行情的部分OK了,我们再回到盘後资讯,这是要使用的是Scanners。 参考网站:Stoc...
上次练习完了自订函式的基础 今天就要来练习相关题目罗~ 题目: 1.计算:键盘输入任一整数a,计算2...
终於快结束30天的挑战了,专案开发的知识点除了环境安装、技术学习以外,还有一个重点知识,那就是专案的...
前言 这篇蛮适合给刚要成为interview的人,帮助你思考要怎麽同理与善待面试者。 演讲总结 整...
使用 Livewire 之後,在 Layout 上会改用 component 的 $slot 方式来...