dojo dragon main logo

Test Renderer

Dojo 提供了一个简单且类型安全的测试渲染器(test renderer),以便于浅断言部件期望的输出结构和行为。测试渲染器的 API 在设计之初就鼓励将单元测试作为最佳实践,以确保 Dojo 应用程序的高度可靠性。

断言和测试渲染器是结合断言结构中定义的包装的测试节点来使用的,确保在测试的整个生命周期类型安全。

使用 assertion 定义部件的期望结构,然后将其传给测试渲染器的 .expect() 函数,测试渲染器就可执行断言。

src/MyWidget.spec.tsx

import { tsx } from '@dojo/framework/core/vdom';
import renderer, { assertion } from '@dojo/framework/testing/renderer';

import MyWidget from './MyWidget';

const baseAssertion = assertion(() => (
    <div>
        <h1>Heading</h1>
        <h2>Sub Heading</h2>
        <div>Content</div>
    </div>
));

const r = renderer(() => <MyWidget />);

r.expect(baseAssertion);

包装的测试节点

为了让测试渲染器和断言能在期望和实际的节点结构中标识节点,需要使用指定的包装节点。在期望的断言结构中,可使用包装的节点代替实际节点,从而保持所有正确的属性和子类型。

使用 @dojo/framework/testing/renderer 中的 wrap 函数来创建一个包装的测试节点:

src/MyWidget.spec.tsx

import { wrap } from '@dojo/framework/testing/renderer';

import MyWidget from './MyWidget';

// Create a wrapped node for a widget
const WrappedMyWidget = wrap(MyWidget);

// Create a wrapped node for a vnode
const WrappedDiv = wrap('div');

测试渲染器使用测试节点在预期树结构中的位置,尝试对被测部件的实际输出上执行任意请求操作(r.property()r.child)。如果包装的测试节点与实际输出数结构上对应的节点不匹配,则不会执行任何操作,并且断言会报错。

注意: 包装的测试节点只能在断言中使用一次,如果在一次断言中多次检测到同一个测试节点,则会抛出错误,并导致测试失败。

断言

断言(assertion)用于构建预期的部件输出结构,以便在 renderer.expect() 中使用。断言公开了一系列 API,允许在不同的测试间调整期望的输出。

假定有一个部件,它根据属性值渲染不同的内容:

src/Profile.tsx

import { create, tsx } from '@dojo/framework/core/vdom';

import * as css from './Profile.m.css';

export interface ProfileProperties {
    username?: string;
}

const factory = create().properties<ProfileProperties>();

const Profile = factory(function Profile({ properties }) {
    const { username = 'Stranger' } = properties();
    return <h1 classes={[css.root]}>{`Welcome ${username}!`}</h1>;
});

export default Profile;

使用 @dojo/framework/testing/renderer#assertion 创建一个断言:

src/Profile.spec.tsx

const { describe, it } = intern.getInterface('bdd');
import { tsx } from '@dojo/framework/core/vdom';
import renderer, { assertion, wrap } from '@dojo/framework/testing/renderer';

import Profile from '../../../src/widgets/Profile';
import * as css from '../../../src/widgets/Profile.m.css';

// Create a wrapped node
const WrappedHeader = wrap('h1');

// Create an assertion using the `WrappedHeader` in place of the `h1`
const baseAssertion = assertion(() => <WrappedHeader classes={[css.root]}>Welcome Stranger!</WrappedHeader>);

describe('Profile', () => {
    it('Should render using the default username', () => {
        const r = renderer(() => <Profile />);

        // Test against the base assertion
        r.expect(baseAssertion);
    });
});

为了测试将 username 属性传给 Profile 部件,我们创建一个新的断言,将其更新为期望的用户名。但是,随着部件功能的增加,为每个场景重新创建整个断言将变得冗长且难以维护,因为对部件结构中的任何更改都需要调整所有断言。

为了避免维护开销并减少重复,断言提供了一套详尽的 API 来基于基础断言创建出变体。断言 API 使用包装的测试节点来标识要更新的期望结构中的节点。

src/Profile.spec.tsx

const { describe, it } = intern.getInterface('bdd');
import { tsx } from '@dojo/framework/core/vdom';
import renderer, { assertion, wrap } from '@dojo/framework/testing/renderer';

import Profile from '../../../src/widgets/Profile';
import * as css from '../../../src/widgets/Profile.m.css';

// Create a wrapped node
const WrappedHeader = wrap('h1');

// Create an assertion using the `WrappedHeader` in place of the `h1`
const baseAssertion = assertion(() => <WrappedHeader classes={[css.root]}>Welcome Stranger!</WrappedHeader>);

describe('Profile', () => {
    it('Should render using the default username', () => {
        const r = renderer(() => <Profile />);

        // Test against the base assertion
        r.expect(baseAssertion);
    });

    it('Should render using the passed username', () => {
        const r = renderer(() => <Profile username="Dojo" />);

        // Create a variation of the base assertion
        const usernameAssertion = baseAssertion.setChildren(WrappedHeader, () => ['Dojo']);

        // Test against the username assertion
        r.expect(usernameAssertion);
    });
});

在基础断言之上创建新断言意味着,如果对默认的部件输出进行了更改,则只需修改基础断言,就可更新部件的所有测试。

断言 API

assertion.setChildren()

返回一个新断言,根据传入的 type 值将新的子节点放在已存子节点之前,或者之后,或者替换掉子节点。

.setChildren(
  wrapped: Wrapped,
  children: () => RenderResult,
  type: 'prepend' | 'replace' | 'append' = 'replace'
): AssertionResult;

assertion.append()

返回一个新断言,将新的子节点追加在已存在子节点之后。

.append(wrapped: Wrapped, children: () => RenderResult): AssertionResult;

assertion.prepend()

返回一个新断言,将新的子节点放在已存在子节点之前。

.prepend(wrapped: Wrapped, children: () => RenderResult): AssertionResult;

assertion.replaceChildren()

返回一个新断言,使用新的子节点替换掉已存在的子节点。

.append(wrapped: Wrapped, children: () => RenderResult): AssertionResult;

assertion.insertSiblings()

返回一个新节点,根据传入的 type 值,将传入的子节点插入到 beforeafter 位置。

.insertSiblings(
  wrapped: Wrapped,
  children: () => RenderResult,
  type: 'before' | 'after' = 'before'
): AssertionResult;

assertion.insertBefore()

返回一个新节点,将传入的子节点插入到已存在子节点之前。

.insertBefore(wrapped: Wrapped, children: () => RenderResult): AssertionResult;

assertion.insertAfter()

R 返回一个新节点,将传入的子节点插入到已存在子节点之后。

.insertAfter(wrapped: Wrapped, children: () => RenderResult): AssertionResult;

assertion.replace()

返回一个新断言,使用传入的节点替换掉已存在的节点。注意,如果你需要在断言或测试渲染器中与传入的新节点交互,则应传入包装的测试节点。

.replace(wrapped: Wrapped, node: DNode): AssertionResult;

assertion.remove()

返回一个新断言,其中完全删除指定的包装节点。

.remove(wrapped: Wrapped): AssertionResult;

assertion.setProperty()

返回一个新断言,为指定的包装节点设置新属性。

.setProperty<T, K extends keyof T['properties']>(
  wrapped: Wrapped<T>,
  property: K,
  value: T['properties'][K]
): AssertionResult;

assertion.setProperties()

返回一个新断言,为指定的节点设置多个新属性。

.setProperties<T>(
  wrapped: Wrapped<T>,
  value: T['properties'] | PropertiesComparatorFunction<T['properties']>
): AssertionResult;

可以设置一个函数来代替属性对象,以根据实际属性返回期望的属性。

触发属性

除了断言部件的输出结构外,还可以使用 renderer.property() 函数测试部件行为。property() 函数接收一个包装的测试节点和一个在下一次调用 expect() 之前会被调用的属性 key。

src/MyWidget.tsx

import { create, tsx } from '@dojo/framework/core/vdom';
import icache from '@dojo/framework/core/middleware/icache';
import { RenderResult } from '@dojo/framework/core/interfaces';

import MyWidgetWithChildren from './MyWidgetWithChildren';

const factory = create({ icache }).properties<{ onClick: () => void }>();

export const MyWidget = factory(function MyWidget({ properties, middleware: { icache } }) {
    const count = icache.getOrSet('count', 0);
    return (
        <div>
            <h1>Header</h1>
            <span>{`${count}`}</span>
            <button
                onclick={() => {
                    icache.set('count', icache.getOrSet('count', 0) + 1);
                    properties().onClick();
                }}
            >
                Increase Counter!
            </button>
        </div>
    );
});

src/MyWidget.spec.tsx

const { describe, it } = intern.getInterface('bdd');
import { tsx } from '@dojo/framework/core/vdom';
import renderer, { assertion, wrap } from '@dojo/framework/testing/renderer';
import * as sinon from 'sinon';

import MyWidget from './MyWidget';

// Create a wrapped node for the button
const WrappedButton = wrap('button');

const WrappedSpan = wrap('span');

const baseAssertion = assertion(() => (
    <div>
        <h1>Header</h1>
        <WrappedSpan>0</WrappedSpan>
        <WrappedButton
            onclick={() => {
                icache.set('count', icache.getOrSet('count', 0) + 1);
                properties().onClick();
            }}
        >
            Increase Counter!
        </WrappedButton>
    </div>
));

describe('MyWidget', () => {
    it('render', () => {
        const onClickStub = sinon.stub();
        const r = renderer(() => <MyWidget onClick={onClickStub} />);

        // assert against the base assertion
        r.expect(baseAssertion);

        // register a call to the button's onclick property
        r.property(WrappedButton, 'onclick');

        // create a new assertion with the updated count
        const counterAssertion = baseAssertion.setChildren(WrappedSpan, () => ['1']);

        // expect against the new assertion, the property will be called before the test render
        r.expect(counterAssertion);

        // once the assertion is complete, check that the stub property was called
        assert.isTrue(onClickStub.calledOnce);
    });
});

可在函数名之后传入函数的参数,如 r.property(WrappedButton, 'onclick', { target: { value: 'value' }})。当函数有多个参数时,逐个传入即可 r.property(WrappedButton, 'onclick', 'first-arg', 'second-arg', 'third-arg')

断言函数型的子节点

要断言函数型子节点的输出内容,测试渲染器需要理解如何解析子节点的渲染函数。包括传入任意期望的注入值。

测试渲染器的 renderer.child() 函数能够解析子节点,以便将解析结果包含到断言中。使用 .child() 函数时,需要包装使用了函数型子节点的部件,以在断言中使用,然后将包装的节点传给 .child 函数。

src/MyWidget.tsx

import { create, tsx } from '@dojo/framework/core/vdom';
import { RenderResult } from '@dojo/framework/core/interfaces';

import MyWidgetWithChildren from './MyWidgetWithChildren';

const factory = create().children<(value: string) => RenderResult>();

export const MyWidget = factory(function MyWidget() {
    return (
        <div>
            <h1>Header</h1>
            <MyWidgetWithChildren>{(value) => <div>{value}</div>}</MyWidgetWithChildren>
        </div>
    );
});

src/MyWidget.spec.tsx

const { describe, it } = intern.getInterface('bdd');
import { tsx } from '@dojo/framework/core/vdom';
import renderer, { assertion, wrap } from '@dojo/framework/testing/renderer';

import MyWidgetWithChildren from './MyWidgetWithChildren';
import MyWidget from './MyWidget';

// Create a wrapped node for the widget with functional children
const WrappedMyWidgetWithChildren = wrap(MyWidgetWithChildren);

const baseAssertion = assertion(() => (
    <div>
      <h1>Header</h1>
      <WrappedMyWidgetWithChildren>{() => <div>Hello!</div>}</MyWidgetWithChildren>
    </div>
));

describe('MyWidget', () => {
    it('render', () => {
        const r = renderer(() => <MyWidget />);

        // instruct the test renderer to resolve the children
        // with the provided params
        r.child(WrappedMyWidgetWithChildren, ['Hello!']);

        r.expect(baseAssertion);
    });
});

自定义属性比较器

在某些情况下,测试期间无法得知属性的确切值,所以需要使用自定义比较器。自定义比较器用于包装的部件,结合 @dojo/framework/testing/renderer#compare 函数可替换部件或节点属性。

compare(comparator: (actual) => boolean)
import { assertion, wrap, compare } from '@dojo/framework/testing/renderer';

// create a wrapped node the `h1`
const WrappedHeader = wrap('h1');

const baseAssertion = assertion(() => (
    <div>
        <WrappedHeader id={compare((actual) => typeof actual === 'string')}>Header!</WrappedHeader>
    </div>
));

断言时忽略节点

当处理渲染多个项的部件时,例如一个列表,可能需要让测试渲染器忽略输出中的一些内容。比如只断言第一个和最后一项是有效的,然后忽略这两项中间的所有项的详细信息,只是简单断言期望的类型。要让测试渲染器做到这一点,需使用 ignore 函数让测试渲染器忽略节点,只检查节点类型是否相同即可,即匹配标签名或部件的工厂或构造器。

import { create, tsx } from '@dojo/framework/core/vdom';
import renderer, { assertion, ignore } from '@dojo/framework/testing/renderer';

const factory = create().properties<{ items: string[] }>();

const ListWidget = create(function ListWidget({ properties }) {
    const { items } = properties();
    return (
        <div>
            <ul>{items.map((item) => <li>{item}</li>)}</ul>
        </div>
    );
});

const r = renderer(() => <ListWidget items={['a', 'b', 'c', 'd']} />);
const IgnoredItem = ignore('li');
const listAssertion = assertion(() => (
    <div>
        <ul>
            <li>a</li>
            <IgnoredItem />
            <IgnoredItem />
            <li>d</li>
        </ul>
    </div>
));
r.expect(listAssertion);

Mocking 中间件

当初始化测试渲染器时,可将 mock 中间件指定为 RendererOptions 值的一部分。Mock 中间件被定义为由原始的中间件和 mock 中间件实现组成的元组。Mock 中间件的创建方式与其他中间件相同。

import myMiddleware from './myMiddleware';
import myMockMiddleware from './myMockMiddleware';
import renderer from '@dojo/framework/testing/renderer';

import MyWidget from './MyWidget';

describe('MyWidget', () => {
    it('renders', () => {
        const r = renderer(() => <MyWidget />, { middleware: [[myMiddleware, myMockMiddleware]] });

        h
            .expect
            /** 断言执行的是 mock 的中间件而不是实际的中间件 **/
            ();
    });
});

测试渲染器会自动 mock 很多核心中间件,并注入到任何需要他们的中间件中:

  • invalidator
  • setProperty
  • destroy

此外,当测试使用了 Dojo 中间件的部件时,还可以使用很多对应的 mock 中间件。有关已提供的 mock 中间件的更多信息,请查看 mocking