dojo dragon main logo

模拟

一种常见的测试类型是验证小部件的用户界面按预期渲染,而不必关心小部件的底层业务逻辑。这些测试可能希望断言诸如按钮点击调用小部件属性方法之类的场景,而不关心属性方法的实现是什么,只关心界面是否按预期调用。在这种情况下,可以使用像 Sinon 这样的模拟库来提供帮助。

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 方法的模拟。然后针对 @button 键来触发按钮的 onClick,之后验证 fetchItems 模拟只被调用了一次。

有关模拟的更多详细信息,请参阅 Sinon 文档。

提供的中间件模拟

有许多模拟中间件可用于支持测试使用相应 Dojo 中间件的小部件。模拟导出一个工厂,用于创建要在每个测试中使用的作用域模拟中间件。

模拟 breakpoint 中间件

使用 @dojo/framework/testing/mocks/middleware/breakpoint 中的 createBreakpointMock,测试可以手动控制调整大小事件以触发断点测试。

考虑以下小部件,当 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>
	);
});

通过在 breakpoint 中间件模拟上使用 mockBreakpoint(key: string, contentRect: Partial<DOMRectReadOnly>) 方法,测试可以显式触发给定的调整大小。

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 中间件

使用 @dojo/framework/testing/middleware/focus 中的 createFocusMock,测试可以手动控制 focus 中间件何时报告具有指定键的节点获得焦点。

考虑以下小部件

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 中间件

使用 @dojo/framework/testing/mocks/middleware/icache 中的 createICacheMiddleware 允许测试直接访问缓存项,而模拟为被测试的小部件提供了足够的 icache 体验。当 icache 用于异步检索数据时,这特别有用。直接访问缓存使测试能够 await 与小部件相同的承诺。

考虑以下小部件,它从 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>;
});

使用模拟 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 中间件

使用 @dojo/framework/testing/mocks/middleware/intersection 中的 createIntersectionMock 创建一个模拟交叉点中间件。要设置交叉点模拟的预期返回值,请使用 key 和预期交叉点详细信息调用创建的模拟交叉点中间件。

考虑以下小部件

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>;
});

使用模拟 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 中间件

使用 @dojo/framework/testing/mocks/middleware/node 中的 createNodeMock 为节点中间件创建一个模拟。要设置节点模拟的预期返回值,请使用 key 和预期 DOM 节点调用创建的模拟节点中间件。

import createNodeMock from '@dojo/framework/testing/mocks/middleware/node';

// create the mock node middleware
const mockNode = createNodeMock();

// create a mock DOM node
const domNode = {};

// call the mock middleware with a key and the DOM
// to return.
mockNode('key', domNode);

模拟 resize 中间件

使用 @dojo/framework/testing/mocks/middleware/resize 中的 createResizeMock 创建一个模拟调整大小中间件。要设置调整大小模拟的预期返回值,请使用 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>;
});

使用模拟 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', () => {
		// create the resize mock
		const resizeMock = createResizeMock();
		// pass the resize mock to the test renderer so it knows to replace the original
		// middleware
		const r = renderer(() => <App key="app" />, { middleware: [[resize, resizeMock]] });

		const WrappedRoot = wrap('div');
		const baseAssertion = assertion(() => <div key="root">null</div>);

		// call renderer.expect as usual
		r.expect(baseAssertion);

		// use the resize mock to set the expected return of the resize middleware
		// by key
		resizeMock('root', { width: 100 });

		// assert again with the updated expectation
		r.expect(baseAssertion.setChildren(WrappedRoot, () [`{"width":100}`]);)
	});
});

模拟 store 中间件

使用 @dojo/framework/testing/mocks/middleware/store 中的 createMockStoreMiddleware 创建一个类型化的模拟存储中间件,它可以选择支持模拟进程。要模拟存储进程,请传递原始存储进程和存根进程的元组。中间件将用传递的存根替换对原始进程的调用。如果没有传递存根,中间件将简单地对所有进程调用执行无操作。

要对模拟存储进行更改,请使用返回存储操作数组的函数调用 mockStore。这与存储的 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';
// application store middleware typed with the state interface
// Example: `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} />;
});

使用模拟 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';

// import a stub/mock lib, doesn't have to be sinon
import { stub } from 'sinon';

describe('MyWidget', () => {
     it('test', () => {
          const properties = {
               id: 'id'
          };
         const myProcessStub = stub();
         // type safe mock store middleware
         // pass through an array of tuples `[originalProcess, stub]` for mocked processes
         // calls to processes not stubbed/mocked get ignored
         const mockStore = createMockStoreMiddleware<MyState>([[myProcess, myProcessStub]]);
         const r = renderer(() => <MyWidget {...properties} />, {
             middleware: [[store, mockStore]]
         });
         r.expect(/* assertion for `Loading`*/);

         // assert again the stubbed process
         expect(myProcessStub.calledWith({ id: 'id' })).toBeTruthy();

         mockStore((path) => [replace(path('isLoading', true)]);
         r.expect(/* assertion for `Loading`*/);
         expect(myProcessStub.calledOnce()).toBeTruthy();

         // use the mock store to apply operations to the store
         mockStore((path) => [replace(path('details', { id: 'id' })]);
         mockStore((path) => [replace(path('isLoading', true)]);

         r.expect(/* assertion for `ShowDetails`*/);

         properties.id = 'other';
         r.expect(/* assertion for `Loading`*/);
         expect(myProcessStub.calledTwice()).toBeTruthy();
         expect(myProcessStub.secondCall.calledWith({ id: 'other' })).toBeTruthy();
         mockStore((path) => [replace(path('details', { id: 'other' })]);
         r.expect(/* assertion for `ShowDetails`*/);
     });
});

模拟 validity 中间件

使用 @dojo/framework/testing/mocks/middleware/validity 中的 createValidityMock 创建一个模拟有效性中间件,其中 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 模拟的 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);
	});
});

自定义中间件模拟

并非所有测试场景都将被提供的模拟覆盖。也可以创建自定义中间件模拟。中间件模拟应该提供一个重载接口。无参数重载应该返回中间件实现;这是将被注入到被测试的小部件中的内容。根据需要创建其他重载以提供测试接口。

例如,考虑框架的 icache 模拟。模拟提供了这些重载

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

接受 key 的重载为测试提供了对缓存项的直接访问。这个简化的示例演示了模拟如何包含中间件实现和测试接口;这使模拟能够弥合小部件和测试之间的差距。

export function createMockMiddleware() {
	const sharedData = new Map<string, any>();

	const mockFactory = factory(() => {
		// actual middleware implementation; uses `sharedData` to bridge the gap
		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) {
			// expose access to `sharedData` directly to
			return sharedData.get(id);
		} else {
			// provides the middleware implementation to the widget
			return mockFactory();
		}
	}
}

framework/src/testing/mocks/middleware 中有很多完整的模拟示例,可以作为参考。