dojo dragon main logo

Dojo test harness

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

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: 一组自定义的比较器描述符。每个描述符提供一个比较器函数,用于比较通过 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

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 是延迟执行的,所以可在断言之间包含额外的逻辑来操作部件的 propertieschildren

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

Mock breakpoint 中间件

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

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

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

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

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