dojo dragon main logo

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 中间件,会在每个测试中使用。

Mock breakpoint 中间件

使用 @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>]);
	});
});

Mock focus 中间件

使用 @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]));
	});
});

Mock iCache 中间件

使用 @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']));
	});
});

Mock intersection 中间件

使用 @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 }`]));
	});
});

Mock node 中间件

使用 @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);

Mock resize 中间件

使用 @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}`]);)
	});
});

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 函数,以创建指向需要修改的状态的指针。

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`*/);
     });
});

Mock validity 中间件

使用 @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 中有很多完整的模拟示例可供参考。