Mocking
一种常见的测试类型是验证部件的用户界面是否按预期渲染,而不必关心部件的底层业务逻辑。但这些测试可能希望断言一些场景,如单击按钮以调用部件的属性方法,并不关心属性方法的实现逻辑,只是希望按预期调用了接口。在这种情况下,可借助类似 Sinon 的 mock 库。
src/widgets/Action.tsx
import { create, tsx } from '@dojo/framework/core/vdom';
import Button from '@dojo/widgets/button';
import * as css from './Action.m.css';
const factory = create().properties<{ fetchItems: () => void }>();
const Action = factory(function Action({ properties }) {
return (
<div classes={[css.root]}>
<Button key="button" onClick={() => properties().fetchItems()}>
Fetch
</Button>
</div>
);
});
export default Action;
测试当单击按钮后,会调用 properties().fetchItems
方法。
tests/unit/widgets/Action.tsx
const { describe, it } = intern.getInterface('bdd');
import { tsx } from '@dojo/framework/core/vdom';
import renderer, { assertion, wrap } from '@dojo/framework/testing/renderer';
import Action from '../../../src/widgets/Action';
import * as css from '../../../src/widgets/Action.m.css';
import Button from '@dojo/widgets/button';
import { stub } from 'sinon';
import { assert } from 'chai';
describe('Action', () => {
const fetchItems = stub();
it('can fetch data on button click', () => {
const WrappedButton = wrap(Button);
const baseAssertion = assertion(() => (
<div classes={[css.root]}>
<WrappedButton key="button" onClick={() => {}}>
Fetch
</WrappedButton>
</div>
));
const r = renderer(() => <Action fetchItems={fetchItems} />);
r.expect(baseAssertion);
r.property(WrappedButton, 'onClick');
r.expect(baseAssertion);
assert.isTrue(fetchItems.calledOnce);
});
});
在这种情况下,为 Action 部件的 fetchItems
方法提供一个 mock 实现,该方法用于获取数据项。然后使用 WrappedButton
定位到按钮,并触发按钮的 onClick
事件,然后校验 fetchItems
方法仅被调用过一次。
要了解更多 mocking 信息,请阅读 Sinon 文档。
内置的 mock 中间件
有很多 mock 的中间件支持测试使用了相关 Dojo 中间件的部件。Mock 会导出一个 factory,该 factory 创建一个受限作用域的 mock 中间件,会在每个测试中使用。
breakpoint
中间件
Mock 使用 @dojo/framework/testing/mocks/middleware/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 renderer, { assertion, wrap } from '@dojo/framework/testing/renderer';
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 WrappedHeader = wrap('h1');
const mockBreakpoint = createBreakpointMock();
const baseAssertion = assertion(() => (
<div key="root">
<WrappedHeader>Header</WrappedHeader>
<div>Longer description</div>
</div>
));
const r = renderer(() => <Breakpoint />, {
middleware: [[breakpoint, mockBreakpoint]]
});
r.expect(baseAssertion);
mockBreakpoint('root', { breakpoint: 'LG', contentRect: { width: 800 } });
r.expect(baseAssertion.insertAfter(WrappedHeader, () => [<h2>Subtitle</h2>]);
});
});
focus
中间件
Mock 使用 @dojo/framework/testing/middleware/focus
中的 createFocusMock
可手动控制 focus
中间件何时报告指定 key 的节点获取了焦点。
考虑下面的部件:
src/FormWidget.tsx
import { tsx, create } from '@dojo/framework/core/vdom';
import focus, { FocusProperties } from '@dojo/framework/core/middleware/focus';
import * as css from './FormWidget.m.css';
export interface FormWidgetProperties extends FocusProperties {}
const factory = create({ focus }).properties<FormWidgetProperties>();
export const FormWidget = factory(function FormWidget({ middleware: { focus } }) {
return (
<div key="wrapper" classes={[css.root, focus.isFocused('text') ? css.focused : null]}>
<input type="text" key="text" value="focus me" />
</div>
);
});
通过调用 focusMock(key: string | number, value: boolean)
,就可以在测试时控制 focus
中间件中 isFocused
方法的返回值。
tests/unit/FormWidget.tsx
const { describe, it } = intern.getInterface('bdd');
import { tsx } from '@dojo/framework/core/vdom';
import renderer, { assertion, wrap } from '@dojo/framework/testing/renderer';
import focus from '@dojo/framework/core/middleware/focus';
import createFocusMock from '@dojo/framework/testing/mocks/middleware/focus';
import * as css from './FormWidget.m.css';
describe('Focus', () => {
it('adds a "focused" class to the wrapper when the input is focused', () => {
const focusMock = createFocusMock();
const WrappedRoot = wrap('div');
const baseAssertion = assertion(() => (
<WrappedRoot key="wrapper" classes={[css.root, null]}>
<input type="text" key="text" value="focus me" />
</WrappedRoot>
));
const r = renderer(() => <FormWidget />, {
middleware: [[focus, focusMock]]
});
r.expect(baseAssertion);
focusMock('text', true);
r.expect(baseAssertion.setProperty(WrappedRoot, 'classes', [css.root, css.focused]));
});
});
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 renderer, { assertion, wrap } from '@dojo/framework/testing/renderer';
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 the fetch call to return a known value
global.fetch = sinon.stub().returns(Promise.resolve({ json: () => Promise.resolve('api data') }));
const WrappedRoot = wrap('div');
const baseAssertion = assertion(() => <WrappedRoot>Loading</WrappedRoot>);
const mockICache = createICacheMock();
const r = renderer(() => <Home />, { middleware: [[icache, mockICache]] });
r.expect(baseAssertion);
// await the async method passed to the mock cache
await mockICache('users');
r.expect(baseAssertion.setChildren(WrappedRoot, () => ['api data']));
});
});
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 renderer, { assertion, wrap } from '@dojo/framework/testing/renderer';
import MyWidget from './MyWidget';
describe('MyWidget', () => {
it('test', () => {
// create the intersection mock
const intersectionMock = createIntersectionMock();
// pass the intersection mock to the renderer so it knows to
// replace the original middleware
const r = renderer(() => <App key="app" />, { middleware: [[intersection, intersectionMock]] });
const WrappedRoot = wrap('div');
const assertion = assertion(() => (
<WrappedRoot key="root">{`{"intersectionRatio":0,"isIntersecting":false}`}</WrappedRoot>
));
// call renderer.expect as usual, asserting the default response
r.expect(assertion);
// use the intersection mock to set the expected return
// of the intersection middleware by key
intersectionMock('root', { isIntersecting: true });
// assert again with the updated expectation
r.expect(assertion.setChildren(WrappedRoot, () => [`{"isIntersecting": true }`]));
});
});
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 renderer, { assertion, wrap } from '@dojo/framework/testing/renderer';
import MyWidget from './MyWidget';
describe('MyWidget', () => {
it('test', () => {
// 创建一个 mock resize 的中间件
const resizeMock = createResizeMock();
// 将 resize mock 中间件传给测试渲染器,
// 这样测试渲染器就知道要替换掉原来的中间件
const r = renderer(() => <App key="app" />, { middleware: [[resize, resizeMock]] });
const WrappedRoot = wrap('div');
const baseAssertion = assertion(() => <div key="root">null</div>);
// 像平常一样调用 renderer.expect
r.expect(baseAssertion);
// 使用 mock 的 resize 中间件,通过指定 key 值,
// 设置期望 resize 中间件返回的结果
resizeMock('root', { width: 100 });
// 用更新后的期望值再断言一次
r.expect(baseAssertion.setChildren(WrappedRoot, () [`{"width":100}`]);)
});
});
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 renderer from '@dojo/framework/testing/renderer';
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 r = renderer(() => <MyWidget {...properties} />, {
middleware: [[store, mockStore]]
});
r.expect(/* 断言 `Loading`*/);
// 重新断言 stubbed process
expect(myProcessStub.calledWith({ id: 'id' })).toBeTruthy();
mockStore((path) => [replace(path('isLoading', true)]);
r.expect(/* 断言 `Loading`*/);
expect(myProcessStub.calledOnce()).toBeTruthy();
// 使用 mock 的 store 来在 store 上应用操作
mockStore((path) => [replace(path('details', { id: 'id' })]);
mockStore((path) => [replace(path('isLoading', true)]);
r.expect(/* 断言 `ShowDetails` */);
properties.id = 'other';
r.expect(/* 断言 `Loading`*/);
expect(myProcessStub.calledTwice()).toBeTruthy();
expect(myProcessStub.secondCall.calledWith({ id: 'other' })).toBeTruthy();
mockStore((path) => [replace(path('details', { id: 'other' })]);
r.expect(/* 断言 `ShowDetails`*/);
});
});
validity
中间件
Mock 使用 @dojo/framework/testing/mocks/middleware/validity
中的 createValidityMock
可 mock 一个 validity 中间件,可以在测试用控制 get
方法的返回值。
考虑以下示例:
src/FormWidget.tsx
import { tsx, create } from '@dojo/framework/core/vdom';
import validity from '@dojo/framework/core/middleware/validity';
import icache from '@dojo/framework/core/middleware/icache';
import * as css from './FormWidget.m.css';
const factory = create({ validity, icache });
export const FormWidget = factory(function FormWidget({ middleware: { validity, icache } }) {
const value = icache.getOrSet('value', '');
const { valid, message } = validity.get('input', value);
return (
<div key="root" classes={[css.root, valid === false ? css.invalid : null]}>
<input type="email" key="input" value={value} onchange={(value) => icache.set('value', value)} />
{message ? <p key="validityMessage">{message}</p> : null}
</div>
);
});
使用 validityMock(key: string, value: { valid?: boolean, message?: string; })
,可以在测试中控制 validity
mock 中 get
方法的返回值。
tests/unit/FormWidget.tsx
const { describe, it } = intern.getInterface('bdd');
import { tsx } from '@dojo/framework/core/vdom';
import renderer, { assertion } from '@dojo/framework/testing/renderer';
import validity from '@dojo/framework/core/middleware/validity';
import createValidityMock from '@dojo/framework/testing/mocks/middleware/validity';
import * as css from './FormWidget.m.css';
describe('Validity', () => {
it('adds the "invalid" class to the wrapper when the input is invalid and displays a message', () => {
const validityMock = createValidityMock();
const r = renderer(() => <FormWidget />, {
middleware: [[validity, validityMock]]
});
const WrappedRoot = wrap('div');
const baseAssertion = assertion(() => (
<WrappedRoot key="root" classes={[css.root, null]}>
<input type="email" key="input" value="" onchange={() => {}} />
</WrappedRoot>
));
r.expect(baseAssertion);
validityMock('input', { valid: false, message: 'invalid message' });
const invalidAssertion = baseAssertion
.append(WrappedRoot, () => [<p key="validityMessage">invalid message</p>])
.setProperty(WrappedRoot, 'classes', [css.root, css.invalid]);
r.expect(invalidAssertion);
});
});
自定义 Mock 中间件
已提供的 mock 中间件并未覆盖所有的测试场景。也可以创建自定义的 mock 中间件。模拟中间件应该提供一个可重载的接口。无参的重载应该返回中间件的实现,它将被注入到被测的部件中。根据需要创建其他重载,以便为测试提供接口。
例如,参考框架中的 icache
mock。这个 mock 提供了以下重载:
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) {
// 直接访问 `sharedData`
return sharedData.get(id);
} else {
// 向部件提供中间件的实现
return mockFactory();
}
}
}
在 framework/src/testing/mocks/middleware
中有很多完整的模拟示例可供参考。