Dojo test harness
当使用 @dojo/framework/testing
时,harness()
是最重要的 API,主要用于设置每一个测试并提供一个执行虚拟 DOM 断言和交互的上下文。目的在于当更新 properties
或 children
,以及部件失效时,镜像部件的核心行为,并且不需要任何特殊或自定义逻辑。
Harness API
interface HarnessOptions {
customComparators?: CustomComparator[];
middleware?: [MiddlewareResultFactory<any, any, any>, MiddlewareResultFactory<any, any, any>][];
}
harness(renderFunction: () => WNode, customComparators?: CustomComparator[]): Harness;
harness(renderFunction: () => WNode, options?: HarnessOptions): Harness;
renderFunction
: 返回被测部件 WNode 的函数customComparators
: 一组自定义的比较器描述符。每个描述符提供一个比较器函数,用于比较通过selector
和property
定位到的properties
options
: harness 的扩展选项,包括customComparators
和一组 middleware/mocks 元组。
harness 函数返回一个 Harness
对象,该对象提供了几个与被测部件交互的 API:
Harness
expect
: 对被测部件完整的渲染结果执行断言expectPartial
: 对被测部件部分渲染结果执行断言trigger
: 用于在被测部件的节点上触发函数getRender
: 根据提供的索引,从 harness 中返回对应的渲染器
使用 @dojo/framework/core
中的 w()
函数生成一个用于测试的部件是非常简单的:
tests/unit/widgets/MyWidget.tsx
const { describe, it } = intern.getInterface('bdd');
import { create, tsx } from '@dojo/framework/core/vdom';
import harness from '@dojo/framework/testing/harness';
const factory = create().properties<{ foo: string }>();
const MyWidget = factory(function MyWidget({ properties, children }) {
const { foo } = properties();
return <div foo={foo}>{children}</div>;
});
const h = harness(() => <MyWidget foo="bar">child</MyWidget>);
renderFunction
是延迟执行的,所以可在断言之间包含额外的逻辑来操作部件的 properties
和 children
。
describe('MyWidget', () => {
it('renders with foo correctly', () => {
let foo = 'bar';
const h = harness(() => <MyWidget foo={foo}>child</MyWidget>);
h.expect(/** 断言包含 bar **/);
// 更新传入部件的属性值
foo = 'foo';
h.expect(/** 断言包含 foo **/);
});
});
Mocking 中间件
当初始化 harness 时,可将 mock 中间件指定为 HarnessOptions
值的一部分。Mock 中间件被定义为由原始的中间件和 mock 中间件的实现组成的元组。Mock 中间件的创建方式与其他中间件相同。
import myMiddleware from './myMiddleware';
import myMockMiddleware from './myMockMiddleware';
import harness from '@dojo/framework/testing/harness';
import MyWidget from './MyWidget';
describe('MyWidget', () => {
it('renders', () => {
const h = harness(() => <MyWidget />, { middleware: [[myMiddleware, myMockMiddleware]] });
h.expect(/** 断言执行的是 mock 的中间件而不是实际的中间件 **/);
});
});
Harness 会自动 mock 很多核心中间件,并注入到任何需要他们的中间件中:
- invalidator
- setProperty
- destroy
Dojo mock 中间件
当测试使用了 Dojo 中间件的部件时,有很多 mock 中间件可以使用。Mock 会导出一个 factory,该 factory 会创建一个受限作用域的 mock 中间件,会在每个测试中使用。
breakpoint
中间件
Mock 使用 @dojo/framework/testing/mocks/middlware/breakpoint
中的 createBreakpointMock
可手动控制 resize 事件来触发断点测试。
考虑下面的部件,当激活 LG
断点时,它会显示附加 h2
:
src/Breakpoint.tsx
import { tsx, create } from '@dojo/framework/core/vdom';
import breakpoint from '@dojo/framework/core/middleware/breakpoint';
const factory = create({ breakpoint });
export default factory(function Breakpoint({ middleware: { breakpoint } }) {
const bp = breakpoint.get('root');
const isLarge = bp && bp.breakpoint === 'LG';
return (
<div key="root">
<h1>Header</h1>
{isLarge && <h2>Subtitle</h2>}
<div>Longer description</div>
</div>
);
});
使用 mock 的 breakpoint 中间件上的 mockBreakpoint(key: string, contentRect: Partial<DOMRectReadOnly>)
方法,测试中可以使用给定的值显式触发一个 resize 事件:
tests/unit/Breakpoint.tsx
const { describe, it } = intern.getInterface('bdd');
import { tsx } from '@dojo/framework/core/vdom';
import harness from '@dojo/framework/testing/harness';
import breakpoint from '@dojo/framework/core/middleware/breakpoint';
import createBreakpointMock from '@dojo/framework/testing/mocks/middleware/breakpoint';
import Breakpoint from '../../src/Breakpoint';
describe('Breakpoint', () => {
it('resizes correctly', () => {
const mockBreakpoint = createBreakpointMock();
const h = harness(() => <Breakpoint />, {
middleware: [[breakpoint, mockBreakpoint]]
});
h.expect(() => (
<div key="root">
<h1>Header</h1>
<div>Longer description</div>
</div>
));
mockBreakpoint('root', { breakpoint: 'LG', contentRect: { width: 800 } });
h.expect(() => (
<div key="root">
<h1>Header</h1>
<h2>Subtitle</h2>
<div>Longer description</div>
</div>
));
});
});
iCache
中间件
Mock 使用 @dojo/framework/testing/mocks/middleware/icache
中的 createICacheMiddleware
,能让测试代码直接访问缓存中的项,而此 mock 为被测的小部件提供了足够的 icache 功能。当使用 icache
异步获取数据时特别有用。直接访问缓存让测试可以 await
部件,就如 await
promise 一样。
考虑以下部件,从一个 API 中获取数据:
src/MyWidget.tsx
import { tsx, create } from '@dojo/framework/core/vdom';
import { icache } from '@dojo/framework/core/middleware/icache';
import fetch from '@dojo/framework/shim/fetch';
const factory = create({ icache });
export default factory(function MyWidget({ middleware: { icache } }) {
const value = icache.getOrSet('users', async () => {
const response = await fetch('url');
return await response.json();
});
return value ? <div>{value}</div> : <div>Loading</div>;
});
使用 mock 的 icache 中间件测试异步结果很简单:
tests/unit/MyWidget.tsx
const { describe, it, afterEach } = intern.getInterface('bdd');
import harness from '@dojo/framework/testing/harness';
import { tsx } from '@dojo/framework/core/vdom';
import * as sinon from 'sinon';
import global from '@dojo/framework/shim/global';
import icache from '@dojo/framework/core/middleware/icache';
import createICacheMock from '@dojo/framework/testing/mocks/middleware/icache';
import MyWidget from '../../src/MyWidget';
describe('MyWidget', () => {
afterEach(() => {
sinon.restore();
});
it('test', async () => {
// stub 一个 fetch 调用,让返回一个已知的值
global.fetch = sinon.stub().returns(Promise.resolve({ json: () => Promise.resolve('api data') }));
const mockICache = createICacheMock();
const h = harness(() => <Home />, { middleware: [[icache, mockICache]] });
h.expect(() => <div>Loading</div>);
// 等待模拟缓存的异步方法
await mockICache('users');
h.expect(() => <pre>api data</pre>);
});
});
intersection
中间件
Mock 使用 @dojo/framework/testing/mocks/middleware/intersection
中的 createIntersectionMock
可 mock 一个 intersection 中间件。要设置从 intersection mock 中返回的期望值,需要调用创建的 mock intersection 中间件,并传入 key
和期望的 intersection 详情。
考虑以下部件:
import { create, tsx } from '@dojo/framework/core/vdom';
import intersection from '@dojo/framework/core/middleware/intersection';
const factory = create({ intersection });
const App = factory(({ middleware: { intersection } }) => {
const details = intersection.get('root');
return <div key="root">{JSON.stringify(details)}</div>;
});
使用 mock intersection 中间件:
import { tsx } from '@dojo/framework/core/vdom';
import createIntersectionMock from '@dojo/framework/testing/mocks/middleware/intersection';
import intersection from '@dojo/framework/core/middleware/intersection';
import harness from '@dojo/framework/testing/harness';
import MyWidget from './MyWidget';
describe('MyWidget', () => {
it('test', () => {
// 创建一个 mock intersection 的中间件
const intersectionMock = createIntersectionMock();
// 将 intersection mock 中间件传给 harness,
// 这样 harness 就知道替换掉原来的中间件
const h = harness(() => <App key="app" />, { middleware: [[intersection, intersectionMock]] });
// 像平常一样调用 harness.expect 来断言默认的响应
h.expect(() => <div key="root">{`{"intersectionRatio":0,"isIntersecting":false}`}</div>);
// 使用 mock 的 intersection 中间件,通过指定 key 值,
// 设置期望 intersection 中间件返回的结果
intersectionMock('root', { isIntersecting: true });
// 用更新后的期望值再断言一次
h.expect(() => <div key="root">{`{"isIntersecting": true }`}</div>);
});
});
node
中间件
Mock 使用 @dojo/framework/testing/mocks/middleware/node
中的 createNodeMock
可 mock 一个 node 中间件。要设置从 node mock 中返回的期望值,需要调用创建的 mock node 中间件,并传入 key
和期望的 DOM node。
import createNodeMock from '@dojo/framework/testing/mocks/middleware/node';
// 创建一个 mock node 的中间件
const mockNode = createNodeMock();
// mock 一个 DOM 节点
const domNode = {};
// 调用 mock 中间件,并传入 key 和将返回的 DOM
mockNode('key', domNode);
resize
中间件
Mock 使用 @dojo/framework/testing/mocks/middleware/resize
中的 createResizeMock
可 mock 一个 resize 中间件。要设置从 resize mock 中返回的期望值,需要调用创建的 mock resize 中间件,并传入 key
和期望的容纳内容的矩形区域。
const mockResize = createResizeMock();
mockResize('key', { width: 100 });
考虑以下部件:
import { create, tsx } from '@dojo/framework/core/vdom'
import resize from '@dojo/framework/core/middleware/resize'
const factory = create({ resize });
export const MyWidget = factory(function MyWidget({ middleware }) => {
const { resize } = middleware;
const contentRects = resize.get('root');
return <div key="root">{JSON.stringify(contentRects)}</div>;
});
使用 mock resize 中间件:
import { tsx } from '@dojo/framework/core/vdom';
import createResizeMock from '@dojo/framework/testing/mocks/middleware/resize';
import resize from '@dojo/framework/core/middleware/resize';
import harness from '@dojo/framework/testing/harness';
import MyWidget from './MyWidget';
describe('MyWidget', () => {
it('test', () => {
// 创建一个 mock resize 的中间件
const resizeMock = createResizeMock();
// 将 resize mock 中间件传给 harness,
// 这样 harness 就知道替换掉原来的中间件
const h = harness(() => <App key="app" />, { middleware: [[resize, resizeMock]] });
// 像平常一样调用 harness.expect
h.expect(() => <div key="root">null</div>);
// 使用 mock 的 resize 中间件,通过指定 key 值,
// 设置期望 resize 中间件返回的结果
resizeMock('root', { width: 100 });
// 用更新后的期望值再断言一次
h.expect(() => <div key="root">{`{"width":100}`}</div>);
});
});
Store
中间件
Mock 使用 @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
函数,以创建指向需要修改的状态的指针。
mockStore((path) => [replace(path('details', { id: 'id' })]);
考虑以下部件:
src/MyWidget.tsx
import { create, tsx } from '@dojo/framework/core/vdom'
import { myProcess } from './processes';
import MyState from './interfaces';
// 应用程序的 store 中间件通过 state 接口来指定类型
// 示例:`const store = createStoreMiddleware<MyState>();`
import store from './store';
const factory = create({ store }).properties<{ id: string }>();
export default factory(function MyWidget({ properties, middleware: store }) {
const { id } = properties();
const { path, get, executor } = store;
const details = get(path('details');
let isLoading = get(path('isLoading'));
if ((!details || details.id !== id) && !isLoading) {
executor(myProcess)({ id });
isLoading = true;
}
if (isLoading) {
return <Loading />;
}
return <ShowDetails {...details} />;
});
使用 mock store 中间件:
tests/unit/MyWidget.tsx
import { tsx } from '@dojo/framework/core/vdom'
import createMockStoreMiddleware from '@dojo/framework/testing/mocks/middleware/store';
import harness from '@dojo/framework/testing/harness';
import { myProcess } from './processes';
import MyWidget from './MyWidget';
import MyState from './interfaces';
import store from './store';
// 导入 stub/mock 库,可以不是 sinon
import { stub } from 'sinon';
describe('MyWidget', () => {
it('test', () => {
const properties = {
id: 'id'
};
const myProcessStub = stub();
// 类型安全的 mock store 中间件
// 为 mock 的 process 传入一组 `[originalProcess, stub]` 元组
// 将忽略未传入 stub/mock 的 process
const mockStore = createMockStoreMiddleware<MyState>([[myProcess, myProcessStub]]);
const h = harness(() => <MyWidget {...properties} />, {
middleware: [[store, mockStore]]
});
h.expect(/* 断言 `Loading` 的断言模板 */);
// 重新断言 stubbed process
expect(myProcessStub.calledWith({ id: 'id' })).toBeTruthy();
mockStore((path) => [replace(path('isLoading', true)]);
h.expect(/* 断言 `Loading` 的断言模板 */);
expect(myProcessStub.calledOnce()).toBeTruthy();
// 使用 mock 的 store 来在 store 上应用操作
mockStore((path) => [replace(path('details', { id: 'id' })]);
mockStore((path) => [replace(path('isLoading', true)]);
h.expect(/* 断言 `ShowDetails` 的断言模板 */);
properties.id = 'other';
h.expect(/* 断言 `Loading` 的断言模板 */);
expect(myProcessStub.calledTwice()).toBeTruthy();
expect(myProcessStub.secondCall.calledWith({ id: 'other' })).toBeTruthy();
mockStore((path) => [replace(path('details', { id: 'other' })]);
h.expect(/* 断言 `ShowDetails` 的断言模板 */);
});
});
自定义模拟的中间件
已提供的模拟(mock)并未覆盖所有的测试场景。也可以创建自定义的模拟中间件。模拟中间件应该提供一个可重载的接口。无参的重载应该返回中间件的实现,它将被注入到被测的部件中。根据需要创建其他重载,以便为测试提供接口。
例如,考虑框架中的 icache
模拟。这个模拟提供了以下重载:
function mockCache(): MiddlewareResult<any, any, any>;
function mockCache(key: string): Promise<any>;
function mockCache(key?: string): Promise<any> | MiddlewareResult<any, any, any>;
接收 key
的重载让测试可以直接访问缓存中的项。这个简短的示例演示了模拟如何同时包含中间件实现和测试接口;这使得模拟(mock)可以在部件和测试之间的搭起桥梁。
export function createMockMiddleware() {
const sharedData = new Map<string, any>();
const mockFactory = factory(() => {
// 实际的中间件实现;使用 `sharedData` 来搭起桥梁
return {
get(id: string): any {},
set(id: string, value: any): void {}
};
});
function mockMiddleware(): MiddlewareResult<any, any, any>;
function mockMiddleware(id: string): any;
function mockMiddleware(id?: string): any | Middleware<any, any, any> {
if (id) {
// 直接访问 `shardData`
return sharedData.get(id);
} else {
// 向部件提供中间件的实现
return mockFactory();
}
}
}
在 framework/src/testing/mocks/middlware
中有很多完整的模拟示例可供参考。
自定义比较
在某些情况下,我们在测试期间无法得知属性的确切值,所以需要使用自定义比较描述符(custom compare descriptor)。
描述符中有一个用于定位要检查的虚拟节点的 selector
,一个应用自定义比较的属性名和一个接收实际值并返回一个 boolean 类型断言结果的比较器函数。
const compareId = {
selector: '*', // 所有节点
property: 'id',
comparator: (value: any) => typeof value === 'string' // 检查属性值是 string 类型
};
const h = harness(() => w(MyWidget, {}), [compareId]);
对于所有的断言,返回的 harness
API 将只对 id
属性使用 comparator
进行测试,而不是标准的相等测试。
Selectors
harness
API 支持 CSS style 选择器概念,来定位要断言和操作的虚拟 DOM 中的节点。查看支持的选择器的完整列表以了解更多信息。
除了标准 API 之外还提供:
- 支持将定位节点
key
属性简写为@
符号 - 当使用标准的
.
来定位样式类时,使用classes
属性而不是class
属性
harness.expect
测试中最常见的需求是断言部件的 render
函数的输出结构。expect
接收一个返回被测部件期望的渲染结果的函数作为参数。
expect(expectedRenderFunction: () => DNode | DNode[], actualRenderFunction?: () => DNode | DNode[]);
expectedRenderFunction
: 返回查询节点期望的DNode
结构的函数actualRenderFunction
: 一个可选函数,返回被断言的实际DNode
结构
h.expect(() =>
<div key="foo">
<Widget key="child-widget" />
text node
<span classes={[class]} />
</div>
);
expect
也可以接收第二个可选参数,返回要断言的渲染结果的函数。
h.expect(() => <div key="foo" />, () => <div key="foo" />);
如果实际的渲染输出和期望的渲染输出不同,就会抛出一个异常,并使用结构化的可视方法,用 (A)
(实际值)和 (E)
(期望值)指出所有不同点。
出错后的断言输出示例:
v('div', {
'classes': [
'root',
(A) 'other'
(E) 'another'
],
'onclick': 'function'
}, [
v('span', {
'classes': 'span',
'id': 'random-id',
'key': 'label',
'onclick': 'function',
'style': 'width: 100px'
}, [
'hello 0'
])
w(ChildWidget, {
'id': 'random-id',
'key': 'widget'
})
w('registry-item', {
'id': true,
'key': 'registry'
})
])
harness.trigger
harness.trigger()
在 selector
定位的节点上调用 name
指定的函数。
interface FunctionalSelector {
(node: VNode | WNode): undefined | Function;
}
trigger(selector: string, functionSelector: string | FunctionalSelector, ...args: any[]): any;
selector
: 用于查找目标节点的选择器functionSelector
: 要么是从节点的属性中找到的被调用的函数名,或者是从节点的属性中返回一个函数的函数选择器args
: 为定位到的函数传入的参数
如果有返回结果,则返回的是被触发函数的结果。
用法示例:
// 在第一个 key 值为 `foo` 的节点上调用 `onclick` 函数
h.trigger('@foo', 'onclick');
// 在第一个 key 值为 `bar` 的节点上调用 `customFunction` 函数,并为其传入值为 `100` 的参数
// 然后接收被触发函数返回的结果
const result = h.trigger('@bar', 'customFunction', 100);
functionalSelector
返回部件属性中的函数。函数也会被触发,与使用普通字符串 functionSelector
的方式相同。
Trigger 示例
假定有如下 VDOM 树结构:
v(Toolbar, {
key: 'toolbar',
buttons: [
{
icon: 'save',
onClick: () => this._onSave()
},
{
icon: 'cancel',
onClick: () => this._onCancel()
}
]
});
通过以下代码触发 save 按钮的 onClick
函数:
h.trigger('@buttons', (renderResult: DNode<Toolbar>) => {
return renderResult.properties.buttons[0].onClick;
});
注意: 如果没能找到指定的选择器,则 trigger
会抛出一个错误。
harness.getRender
harness.getRender()
返回索引指定的渲染器,如果没有提供索引则返回最后一个渲染器。
getRender(index?: number);
index
: 要返回的渲染器的索引
用法示例:
// 返回最后一个渲染器的结果
const render = h.getRender();
// 返回传入的索引对应渲染器的结果
h.getRender(1);