Dojo test harness

当使用 @dojo/framework/testing 时,harness() 是最重要的 API,主要用于设置每一个测试并提供一个执行虚拟 DOM 断言和交互的上下文。目的在于当更新 propertieschildren,以及部件失效时,镜像部件的核心行为,并且不需要任何特殊或自定义逻辑。

Harness API

  1. interface HarnessOptions {
  2. customComparators?: CustomComparator[];
  3. middleware?: [MiddlewareResultFactory<any, any, any>, MiddlewareResultFactory<any, any, any>][];
  4. }
  5. harness(renderFunction: () => WNode, customComparators?: CustomComparator[]): Harness;
  6. harness(renderFunction: () => WNode, options?: HarnessOptions): Harness;
  • renderFunction: 返回被测部件 WNode 的函数
  • customComparators: 一组自定义的比较器描述符。每个描述符提供一个比较器函数,用于比较通过 selectorproperty 定位到的 properties
  • options: harness 的扩展选项,包括 customComparators 和一组 middleware/mocks 元组。

harness 函数返回一个 Harness 对象,该对象提供了几个与被测部件交互的 API:

Harness

  • expect: 对被测部件完整的渲染结果执行断言
  • expectPartial: 对被测部件部分渲染结果执行断言
  • trigger: 用于在被测部件的节点上触发函数
  • getRender: 根据提供的索引,从 harness 中返回对应的渲染器

使用 @dojo/framework/core 中的 w() 函数生成一个用于测试的部件是非常简单的:

tests/unit/widgets/MyWidget.tsx

  1. const { describe, it } = intern.getInterface('bdd');
  2. import { create, tsx } from '@dojo/framework/core/vdom';
  3. import harness from '@dojo/framework/testing/harness';
  4. const factory = create().properties<{ foo: string }>();
  5. const MyWidget = factory(function MyWidget({ properties, children }) {
  6. const { foo } = properties();
  7. return <div foo={foo}>{children}</div>;
  8. });
  9. const h = harness(() => <MyWidget foo="bar">child</MyWidget>);

renderFunction 是延迟执行的,所以可在断言之间包含额外的逻辑来操作部件的 propertieschildren

  1. describe('MyWidget', () => {
  2. it('renders with foo correctly', () => {
  3. let foo = 'bar';
  4. const h = harness(() => <MyWidget foo={foo}>child</MyWidget>);
  5. h.expect(/** 断言包含 bar **/);
  6. // 更新传入部件的属性值
  7. foo = 'foo';
  8. h.expect(/** 断言包含 foo **/);
  9. });
  10. });

Mocking 中间件

当初始化 harness 时,可将 mock 中间件指定为 HarnessOptions 值的一部分。Mock 中间件被定义为由原始的中间件和 mock 中间件的实现组成的元组。Mock 中间件的创建方式与其他中间件相同。

  1. import myMiddleware from './myMiddleware';
  2. import myMockMiddleware from './myMockMiddleware';
  3. import harness from '@dojo/framework/testing/harness';
  4. import MyWidget from './MyWidget';
  5. describe('MyWidget', () => {
  6. it('renders', () => {
  7. const h = harness(() => <MyWidget />, { middleware: [[myMiddleware, myMockMiddleware]] });
  8. h.expect(/** 断言执行的是 mock 的中间件而不是实际的中间件 **/);
  9. });
  10. });

Harness 会自动 mock 很多核心中间件,并注入到任何需要他们的中间件中:

  • invalidator
  • setProperty
  • destroy

Dojo mock 中间件

当测试使用了 Dojo 中间件的部件时,有很多 mock 中间件可以使用。Mock 会导出一个 factory,该 factory 会创建一个受限作用域的 mock 中间件,会在每个测试中使用。

Mock breakpoint 中间件

使用 @dojo/framework/testing/mocks/middlware/breakpoint 中的 createBreakpointMock 可手动控制 resize 事件来触发断点测试。

考虑下面的部件,当激活 LG 断点时,它会显示附加 h2

src/Breakpoint.tsx

  1. import { tsx, create } from '@dojo/framework/core/vdom';
  2. import breakpoint from '@dojo/framework/core/middleware/breakpoint';
  3. const factory = create({ breakpoint });
  4. export default factory(function Breakpoint({ middleware: { breakpoint } }) {
  5. const bp = breakpoint.get('root');
  6. const isLarge = bp && bp.breakpoint === 'LG';
  7. return (
  8. <div key="root">
  9. <h1>Header</h1>
  10. {isLarge && <h2>Subtitle</h2>}
  11. <div>Longer description</div>
  12. </div>
  13. );
  14. });

使用 mock 的 breakpoint 中间件上的 mockBreakpoint(key: string, contentRect: Partial<DOMRectReadOnly>) 方法,测试中可以使用给定的值显式触发一个 resize 事件:

tests/unit/Breakpoint.tsx

  1. const { describe, it } = intern.getInterface('bdd');
  2. import { tsx } from '@dojo/framework/core/vdom';
  3. import harness from '@dojo/framework/testing/harness';
  4. import breakpoint from '@dojo/framework/core/middleware/breakpoint';
  5. import createBreakpointMock from '@dojo/framework/testing/mocks/middleware/breakpoint';
  6. import Breakpoint from '../../src/Breakpoint';
  7. describe('Breakpoint', () => {
  8. it('resizes correctly', () => {
  9. const mockBreakpoint = createBreakpointMock();
  10. const h = harness(() => <Breakpoint />, {
  11. middleware: [[breakpoint, mockBreakpoint]]
  12. });
  13. h.expect(() => (
  14. <div key="root">
  15. <h1>Header</h1>
  16. <div>Longer description</div>
  17. </div>
  18. ));
  19. mockBreakpoint('root', { breakpoint: 'LG', contentRect: { width: 800 } });
  20. h.expect(() => (
  21. <div key="root">
  22. <h1>Header</h1>
  23. <h2>Subtitle</h2>
  24. <div>Longer description</div>
  25. </div>
  26. ));
  27. });
  28. });

Mock iCache 中间件

使用 @dojo/framework/testing/mocks/middleware/icache 中的 createICacheMiddleware,能让测试代码直接访问缓存中的项,而此 mock 为被测的小部件提供了足够的 icache 功能。当使用 icache 异步获取数据时特别有用。直接访问缓存让测试可以 await 部件,就如 await promise 一样。

考虑以下部件,从一个 API 中获取数据:

src/MyWidget.tsx

  1. import { tsx, create } from '@dojo/framework/core/vdom';
  2. import { icache } from '@dojo/framework/core/middleware/icache';
  3. import fetch from '@dojo/framework/shim/fetch';
  4. const factory = create({ icache });
  5. export default factory(function MyWidget({ middleware: { icache } }) {
  6. const value = icache.getOrSet('users', async () => {
  7. const response = await fetch('url');
  8. return await response.json();
  9. });
  10. return value ? <div>{value}</div> : <div>Loading</div>;
  11. });

使用 mock 的 icache 中间件测试异步结果很简单:

tests/unit/MyWidget.tsx

  1. const { describe, it, afterEach } = intern.getInterface('bdd');
  2. import harness from '@dojo/framework/testing/harness';
  3. import { tsx } from '@dojo/framework/core/vdom';
  4. import * as sinon from 'sinon';
  5. import global from '@dojo/framework/shim/global';
  6. import icache from '@dojo/framework/core/middleware/icache';
  7. import createICacheMock from '@dojo/framework/testing/mocks/middleware/icache';
  8. import MyWidget from '../../src/MyWidget';
  9. describe('MyWidget', () => {
  10. afterEach(() => {
  11. sinon.restore();
  12. });
  13. it('test', async () => {
  14. // stub 一个 fetch 调用,让返回一个已知的值
  15. global.fetch = sinon.stub().returns(Promise.resolve({ json: () => Promise.resolve('api data') }));
  16. const mockICache = createICacheMock();
  17. const h = harness(() => <Home />, { middleware: [[icache, mockICache]] });
  18. h.expect(() => <div>Loading</div>);
  19. // 等待模拟缓存的异步方法
  20. await mockICache('users');
  21. h.expect(() => <pre>api data</pre>);
  22. });
  23. });

Mock intersection 中间件

使用 @dojo/framework/testing/mocks/middleware/intersection 中的 createIntersectionMock 可 mock 一个 intersection 中间件。要设置从 intersection mock 中返回的期望值,需要调用创建的 mock intersection 中间件,并传入 key 和期望的 intersection 详情。

考虑以下部件:

  1. import { create, tsx } from '@dojo/framework/core/vdom';
  2. import intersection from '@dojo/framework/core/middleware/intersection';
  3. const factory = create({ intersection });
  4. const App = factory(({ middleware: { intersection } }) => {
  5. const details = intersection.get('root');
  6. return <div key="root">{JSON.stringify(details)}</div>;
  7. });

使用 mock intersection 中间件:

  1. import { tsx } from '@dojo/framework/core/vdom';
  2. import createIntersectionMock from '@dojo/framework/testing/mocks/middleware/intersection';
  3. import intersection from '@dojo/framework/core/middleware/intersection';
  4. import harness from '@dojo/framework/testing/harness';
  5. import MyWidget from './MyWidget';
  6. describe('MyWidget', () => {
  7. it('test', () => {
  8. // 创建一个 mock intersection 的中间件
  9. const intersectionMock = createIntersectionMock();
  10. // 将 intersection mock 中间件传给 harness,
  11. // 这样 harness 就知道替换掉原来的中间件
  12. const h = harness(() => <App key="app" />, { middleware: [[intersection, intersectionMock]] });
  13. // 像平常一样调用 harness.expect 来断言默认的响应
  14. h.expect(() => <div key="root">{`{"intersectionRatio":0,"isIntersecting":false}`}</div>);
  15. // 使用 mock 的 intersection 中间件,通过指定 key 值,
  16. // 设置期望 intersection 中间件返回的结果
  17. intersectionMock('root', { isIntersecting: true });
  18. // 用更新后的期望值再断言一次
  19. h.expect(() => <div key="root">{`{"isIntersecting": true }`}</div>);
  20. });
  21. });

Mock node 中间件

使用 @dojo/framework/testing/mocks/middleware/node 中的 createNodeMock 可 mock 一个 node 中间件。要设置从 node mock 中返回的期望值,需要调用创建的 mock node 中间件,并传入 key 和期望的 DOM node。

  1. import createNodeMock from '@dojo/framework/testing/mocks/middleware/node';
  2. // 创建一个 mock node 的中间件
  3. const mockNode = createNodeMock();
  4. // mock 一个 DOM 节点
  5. const domNode = {};
  6. // 调用 mock 中间件,并传入 key 和将返回的 DOM
  7. mockNode('key', domNode);

Mock resize 中间件

使用 @dojo/framework/testing/mocks/middleware/resize 中的 createResizeMock 可 mock 一个 resize 中间件。要设置从 resize mock 中返回的期望值,需要调用创建的 mock resize 中间件,并传入 key 和期望的容纳内容的矩形区域。

  1. const mockResize = createResizeMock();
  2. mockResize('key', { width: 100 });

考虑以下部件:

  1. import { create, tsx } from '@dojo/framework/core/vdom'
  2. import resize from '@dojo/framework/core/middleware/resize'
  3. const factory = create({ resize });
  4. export const MyWidget = factory(function MyWidget({ middleware }) => {
  5. const { resize } = middleware;
  6. const contentRects = resize.get('root');
  7. return <div key="root">{JSON.stringify(contentRects)}</div>;
  8. });

使用 mock resize 中间件:

  1. import { tsx } from '@dojo/framework/core/vdom';
  2. import createResizeMock from '@dojo/framework/testing/mocks/middleware/resize';
  3. import resize from '@dojo/framework/core/middleware/resize';
  4. import harness from '@dojo/framework/testing/harness';
  5. import MyWidget from './MyWidget';
  6. describe('MyWidget', () => {
  7. it('test', () => {
  8. // 创建一个 mock resize 的中间件
  9. const resizeMock = createResizeMock();
  10. // 将 resize mock 中间件传给 harness,
  11. // 这样 harness 就知道替换掉原来的中间件
  12. const h = harness(() => <App key="app" />, { middleware: [[resize, resizeMock]] });
  13. // 像平常一样调用 harness.expect
  14. h.expect(() => <div key="root">null</div>);
  15. // 使用 mock 的 resize 中间件,通过指定 key 值,
  16. // 设置期望 resize 中间件返回的结果
  17. resizeMock('root', { width: 100 });
  18. // 用更新后的期望值再断言一次
  19. h.expect(() => <div key="root">{`{"width":100}`}</div>);
  20. });
  21. });

Mock Store 中间件

使用 @dojo/framework/testing/mocks/middleware/store 中的 createMockStoreMiddleware 可 mock 一个强类型的 store 中间件,也支持 mock process。为了 mock 一个 store 的 process,可传入一个由原始 store process 和 stub process 组成的元组。中间件会改为调用 stub,而不是调用原始的 process。如果没有传入 stub,中间件将停止调用所有的 process。

要修改 mock store 中的值,需要调用 mockStore,并传入一个返回一组 store 操作的函数。这将注入 store 的 path 函数,以创建指向需要修改的状态的指针。

  1. mockStore((path) => [replace(path('details', { id: 'id' })]);

考虑以下部件:

src/MyWidget.tsx

  1. import { create, tsx } from '@dojo/framework/core/vdom'
  2. import { myProcess } from './processes';
  3. import MyState from './interfaces';
  4. // 应用程序的 store 中间件通过 state 接口来指定类型
  5. // 示例:`const store = createStoreMiddleware<MyState>();`
  6. import store from './store';
  7. const factory = create({ store }).properties<{ id: string }>();
  8. export default factory(function MyWidget({ properties, middleware: store }) {
  9. const { id } = properties();
  10. const { path, get, executor } = store;
  11. const details = get(path('details');
  12. let isLoading = get(path('isLoading'));
  13. if ((!details || details.id !== id) && !isLoading) {
  14. executor(myProcess)({ id });
  15. isLoading = true;
  16. }
  17. if (isLoading) {
  18. return <Loading />;
  19. }
  20. return <ShowDetails {...details} />;
  21. });

使用 mock store 中间件:

tests/unit/MyWidget.tsx

  1. import { tsx } from '@dojo/framework/core/vdom'
  2. import createMockStoreMiddleware from '@dojo/framework/testing/mocks/middleware/store';
  3. import harness from '@dojo/framework/testing/harness';
  4. import { myProcess } from './processes';
  5. import MyWidget from './MyWidget';
  6. import MyState from './interfaces';
  7. import store from './store';
  8. // 导入 stub/mock 库,可以不是 sinon
  9. import { stub } from 'sinon';
  10. describe('MyWidget', () => {
  11. it('test', () => {
  12. const properties = {
  13. id: 'id'
  14. };
  15. const myProcessStub = stub();
  16. // 类型安全的 mock store 中间件
  17. // 为 mock 的 process 传入一组 `[originalProcess, stub]` 元组
  18. // 将忽略未传入 stub/mock 的 process
  19. const mockStore = createMockStoreMiddleware<MyState>([[myProcess, myProcessStub]]);
  20. const h = harness(() => <MyWidget {...properties} />, {
  21. middleware: [[store, mockStore]]
  22. });
  23. h.expect(/* 断言 `Loading` 的断言模板 */);
  24. // 重新断言 stubbed process
  25. expect(myProcessStub.calledWith({ id: 'id' })).toBeTruthy();
  26. mockStore((path) => [replace(path('isLoading', true)]);
  27. h.expect(/* 断言 `Loading` 的断言模板 */);
  28. expect(myProcessStub.calledOnce()).toBeTruthy();
  29. // 使用 mock 的 store 来在 store 上应用操作
  30. mockStore((path) => [replace(path('details', { id: 'id' })]);
  31. mockStore((path) => [replace(path('isLoading', true)]);
  32. h.expect(/* 断言 `ShowDetails` 的断言模板 */);
  33. properties.id = 'other';
  34. h.expect(/* 断言 `Loading` 的断言模板 */);
  35. expect(myProcessStub.calledTwice()).toBeTruthy();
  36. expect(myProcessStub.secondCall.calledWith({ id: 'other' })).toBeTruthy();
  37. mockStore((path) => [replace(path('details', { id: 'other' })]);
  38. h.expect(/* 断言 `ShowDetails` 的断言模板 */);
  39. });
  40. });

自定义模拟的中间件

已提供的模拟(mock)并未覆盖所有的测试场景。也可以创建自定义的模拟中间件。模拟中间件应该提供一个可重载的接口。无参的重载应该返回中间件的实现,它将被注入到被测的部件中。根据需要创建其他重载,以便为测试提供接口。

例如,考虑框架中的 icache 模拟。这个模拟提供了以下重载:

  1. function mockCache(): MiddlewareResult<any, any, any>;
  2. function mockCache(key: string): Promise<any>;
  3. function mockCache(key?: string): Promise<any> | MiddlewareResult<any, any, any>;

接收 key 的重载让测试可以直接访问缓存中的项。这个简短的示例演示了模拟如何同时包含中间件实现和测试接口;这使得模拟(mock)可以在部件和测试之间的搭起桥梁。

  1. export function createMockMiddleware() {
  2. const sharedData = new Map<string, any>();
  3. const mockFactory = factory(() => {
  4. // 实际的中间件实现;使用 `sharedData` 来搭起桥梁
  5. return {
  6. get(id: string): any {},
  7. set(id: string, value: any): void {}
  8. };
  9. });
  10. function mockMiddleware(): MiddlewareResult<any, any, any>;
  11. function mockMiddleware(id: string): any;
  12. function mockMiddleware(id?: string): any | Middleware<any, any, any> {
  13. if (id) {
  14. // 直接访问 `shardData`
  15. return sharedData.get(id);
  16. } else {
  17. // 向部件提供中间件的实现
  18. return mockFactory();
  19. }
  20. }
  21. }

framework/src/testing/mocks/middlware 中有很多完整的模拟示例可供参考。

自定义比较

在某些情况下,我们在测试期间无法得知属性的确切值,所以需要使用自定义比较描述符(custom compare descriptor)。

描述符中有一个用于定位要检查的虚拟节点的 selector,一个应用自定义比较的属性名和一个接收实际值并返回一个 boolean 类型断言结果的比较器函数。

  1. const compareId = {
  2. selector: '*', // 所有节点
  3. property: 'id',
  4. comparator: (value: any) => typeof value === 'string' // 检查属性值是 string 类型
  5. };
  6. const h = harness(() => w(MyWidget, {}), [compareId]);

对于所有的断言,返回的 harness API 将只对 id 属性使用 comparator 进行测试,而不是标准的相等测试。

Selectors

harness API 支持 CSS style 选择器概念,来定位要断言和操作的虚拟 DOM 中的节点。查看支持的选择器的完整列表以了解更多信息。

除了标准 API 之外还提供:

  • 支持将定位节点 key 属性简写为 @ 符号
  • 当使用标准的 . 来定位样式类时,使用 classes 属性而不是 class 属性

harness.expect

测试中最常见的需求是断言部件的 render 函数的输出结构。expect 接收一个返回被测部件期望的渲染结果的函数作为参数。

  1. expect(expectedRenderFunction: () => DNode | DNode[], actualRenderFunction?: () => DNode | DNode[]);
  • expectedRenderFunction: 返回查询节点期望的 DNode 结构的函数
  • actualRenderFunction: 一个可选函数,返回被断言的实际 DNode 结构
  1. h.expect(() =>
  2. <div key="foo">
  3. <Widget key="child-widget" />
  4. text node
  5. <span classes={[class]} />
  6. </div>
  7. );

expect 也可以接收第二个可选参数,返回要断言的渲染结果的函数。

  1. h.expect(() => <div key="foo" />, () => <div key="foo" />);

如果实际的渲染输出和期望的渲染输出不同,就会抛出一个异常,并使用结构化的可视方法,用 (A) (实际值)和 (E) (期望值)指出所有不同点。

出错后的断言输出示例:

  1. v('div', {
  2. 'classes': [
  3. 'root',
  4. (A) 'other'
  5. (E) 'another'
  6. ],
  7. 'onclick': 'function'
  8. }, [
  9. v('span', {
  10. 'classes': 'span',
  11. 'id': 'random-id',
  12. 'key': 'label',
  13. 'onclick': 'function',
  14. 'style': 'width: 100px'
  15. }, [
  16. 'hello 0'
  17. ])
  18. w(ChildWidget, {
  19. 'id': 'random-id',
  20. 'key': 'widget'
  21. })
  22. w('registry-item', {
  23. 'id': true,
  24. 'key': 'registry'
  25. })
  26. ])

harness.trigger

harness.trigger()selector 定位的节点上调用 name 指定的函数。

  1. interface FunctionalSelector {
  2. (node: VNode | WNode): undefined | Function;
  3. }
  4. trigger(selector: string, functionSelector: string | FunctionalSelector, ...args: any[]): any;
  • selector: 用于查找目标节点的选择器
  • functionSelector: 要么是从节点的属性中找到的被调用的函数名,或者是从节点的属性中返回一个函数的函数选择器
  • args: 为定位到的函数传入的参数

如果有返回结果,则返回的是被触发函数的结果。

用法示例:

  1. // 在第一个 key 值为 `foo` 的节点上调用 `onclick` 函数
  2. h.trigger('@foo', 'onclick');
  1. // 在第一个 key 值为 `bar` 的节点上调用 `customFunction` 函数,并为其传入值为 `100` 的参数
  2. // 然后接收被触发函数返回的结果
  3. const result = h.trigger('@bar', 'customFunction', 100);

functionalSelector 返回部件属性中的函数。函数也会被触发,与使用普通字符串 functionSelector 的方式相同。

Trigger 示例

假定有如下 VDOM 树结构:

  1. v(Toolbar, {
  2. key: 'toolbar',
  3. buttons: [
  4. {
  5. icon: 'save',
  6. onClick: () => this._onSave()
  7. },
  8. {
  9. icon: 'cancel',
  10. onClick: () => this._onCancel()
  11. }
  12. ]
  13. });

通过以下代码触发 save 按钮的 onClick 函数:

  1. h.trigger('@buttons', (renderResult: DNode<Toolbar>) => {
  2. return renderResult.properties.buttons[0].onClick;
  3. });

注意: 如果没能找到指定的选择器,则 trigger 会抛出一个错误。

harness.getRender

harness.getRender() 返回索引指定的渲染器,如果没有提供索引则返回最后一个渲染器。

  1. getRender(index?: number);
  • index: 要返回的渲染器的索引

用法示例:

  1. // 返回最后一个渲染器的结果
  2. const render = h.getRender();
  1. // 返回传入的索引对应渲染器的结果
  2. h.getRender(1);