dojo dragon main logo

Dojo test harness

harness() is the primary API when working with @dojo/framework/testing, essentially setting up each test and providing a context to perform virtual DOM assertions and interactions. The harness is designed to mirror the core behavior for widgets when updating properties or children and widget invalidation, with no special or custom logic required.

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: A function that returns a WNode for the widget under test
  • customComparators: Array of custom comparator descriptors. Each provides a comparator function to be used during the comparison for properties located using a selector and property name
  • options: Expanded options for the harness which includes customComparators and an array of middleware/mocks tuples.

The harness returns a Harness object that provides a small API for interacting with the widget under test:

Harness

  • expect: Performs an assertion against the full render output from the widget under test.
  • expectPartial: Performs an assertion against a section of the render output from the widget under test.
  • trigger: Used to trigger a function from a node on the widget under test's API
  • getRender: Returns a render from the harness based on the index provided

Setting up a widget for testing is simple and familiar using the w() function from @dojo/framework/core:

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

The renderFunction is lazily executed so it can include additional logic to manipulate the widget's properties and children between assertions.

describe('MyWidget', () => {
    it('renders with foo correctly', () => {
        let foo = 'bar';

        const h = harness(() => <MyWidget foo={foo}>child</MyWidget>);

        h.expect(/** assertion that includes bar **/);
        // update the property that is passed to the widget
        foo = 'foo';
        h.expect(/** assertion that includes foo **/);
    });
});

Mocking middleware

When initializing the harness, mock middleware can be specified as part of the HarnessOptions. The mock middleware is defined as a tuple of the original middleware and the mock middleware implementation. Mock middleware is created in the same way as any other middleware.

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(/** assertion that executes the mock middleware instead of the normal middleware **/);
    });
});

The harness automatically mocks a number of core middlewares that will be injected into any middleware that requires them:

  • invalidator
  • setProperty
  • destroy

Dojo mock middleware

There are a number of mock middleware available to support testing widgets that use the corresponding Dojo middleware. The mocks export a factory used to create the scoped mock middleware to be used in each test.

Mock breakpoint middleware

Using createBreakpointMock from @dojo/framework/testing/mocks/middlware/breakpoint offers tests manual control over resizing events to trigger breakpoint tests.

Consider the following widget which displays an additonal h2 when the LG breakpoint is activated:

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

By using the mockBreakpoint(key: string, contentRect: Partial<DOMRectReadOnly>) method on the breakpoint middleware mock, the test can explicitly trigger a given 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 middleware

Using createICacheMiddleware from @dojo/framework/testing/mocks/middleware/icache allows tests to access cache items directly while the mock provides a sufficient icache experience for the widget under test. This is particularly useful when icache is used to asynchronously retrieve data. Direct cache access enables the test to await the same promise as the widget.

Consider the following widget which retrieves data from an 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>;
});

Testing the asynchrounous result using the mock icache middleware is simple:

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 the fetch call to return a known value
        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 the async method passed to the mock cache
        await mockICache('users');
        h.expect(() => <pre>api data</pre>);
    });
});

Mock intersection middleware

Using createIntersectionMock from @dojo/framework/testing/mocks/middleware/intersection creates a mock intersection middleware. To set the expected return from the intersection mock, call the created mock intersection middleware with a key and expected intersection details.

Consider the following widget:

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

Using the mock intersection middleware:

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', () => {
        // create the intersection mock
        const intersectionMock = createIntersectionMock();
        // pass the intersection mock to the harness so it knows to
        // replace the original middleware
        const h = harness(() => <App key="app" />, { middleware: [[intersection, intersectionMock]] });

        // call harness.expect as usual, asserting the default response
        h.expect(() => <div key="root">{`{"intersectionRatio":0,"isIntersecting":false}`}</div>);

        // 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
        h.expect(() => <div key="root">{`{"isIntersecting": true }`}</div>);
    });
});

Mock node middleware

Using createNodeMock from @dojo/framework/testing/mocks/middleware/node creates a mock for the node middleware. To set the expected return from the node mock, call the created mock node middleware with a key and expected DOM node.

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

Mock resize middleware

Using createResizeMock from @dojo/framework/testing/mocks/middleware/resize creates a mock resize middleware. To set the expected return from the resize mock, call the created mock resize middleware with a key and expected content rects.

const mockResize = createResizeMock();
mockResize('key', { width: 100 });

Consider the following widget:

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

Using the mock resize middleware:

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

        // call harness.expect as usual
        h.expect(() => <div key="root">null</div>);

        // 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
        h.expect(() => <div key="root">{`{"width":100}`}</div>);
    });
});

Mock Store middleware

Using createMockStoreMiddleware from @dojo/framework/testing/mocks/middleware/store creates a typed mock store middleware, which optionally supports mocking processes. To mock a store process pass a tuple of the original store process and the stub process. The middleware will swap out the call to the original process for the passed stub. If no stubs are passed, the middleware will simply no-op all process calls.

To make changes to the mock store, call the mockStore with a function that returns an array of store operations. This is injected with the stores path function to create the pointer to the state that needs changing.

mockStore((path) => [replace(path('details', { id: 'id' })]);

Consider the following widget:

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

Using the mock store middleware:

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

// 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 h = harness(() => <MyWidget {...properties} />, {
             middleware: [[store, mockStore]]
         });
         h.expect(/* assertion template for `Loading`*/);

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

         mockStore((path) => [replace(path('isLoading', true)]);
         h.expect(/* assertion template 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)]);

         h.expect(/* assertion template for `ShowDetails`*/);

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

Custom middleware mocks

Not all testing scenarios will be covered by the provided mocks. Custom middleware mocks can also be created. A middleware mock should provide an overloaded interface. The parameterless overload should return the middleware implementation; this is what will be injected into the widget under test. Other overloads are created as needed to provide an interface for the tests.

As an example, consider the framework's icache mock. The mock provides these overloads:

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

The overload which accepts a key provides the test direct access to cache items. This abbreviated example demonstrates how the mock contains both the middleware implementation and the test interface; this enabled the mock to bridge the gap between the widget and the test.

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

    const mockFactory = factory(() => {
        // actual middlware 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();
        }
    }
}

There are plenty of full mock examples in framework/src/testing/mocks/middlware which can be used for reference.

Custom comparators

There are circumstances where the exact value of a property is unknown during testing, so will require the use of a custom compare descriptor.

The descriptors have a selector to locate the virtual nodes to check, a property name for the custom compare and a comparator function that receives the actual value and returns a boolean result for the assertion.

const compareId = {
    selector: '*', // all nodes
    property: 'id',
    comparator: (value: any) => typeof value === 'string' // checks the property value is a string
};

const h = harness(() => w(MyWidget, {}), [compareId]);

For all assertions, using the returned harness API will now only test identified id properties using the comparator instead of the standard equality.

Selectors

The harness APIs commonly support a concept of CSS style selectors to target nodes within the virtual DOM for assertions and operations. Review the full list of supported selectors for more information.

In addition to the standard API:

  • The @ symbol is supported as shorthand for targeting a node's key property
  • The classes property is used instead of class when using the standard shorthand . for targeting classes

harness.expect

The most common requirement for testing is to assert the structural output from a widget's render function. expect accepts a render function that returns the expected render output from the widget under test.

expect(expectedRenderFunction: () => DNode | DNode[], actualRenderFunction?: () => DNode | DNode[]);
  • expectedRenderFunction: A function that returns the expected DNode structure of the queried node
  • actualRenderFunction: An optional function that returns the actual DNode structure to be asserted
h.expect(() =>
    <div key="foo">
        <Widget key="child-widget" />
        text node
        <span classes={[class]} />
    </div>
);

Optionally expect can accept a second parameter of a function that returns a render result to assert against.

h.expect(() => <div key="foo" />, () => <div key="foo" />);

If the actual render output and expected render output are different, an exception is thrown with a structured visualization indicating all differences with (A) (the actual value) and (E) (the expected value).

Example assertion failure output:

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() calls a function with the name on the node targeted by the selector.

interface FunctionalSelector {
    (node: VNode | WNode): undefined | Function;
}

trigger(selector: string, functionSelector: string | FunctionalSelector, ...args: any[]): any;
  • selector: The selector query to find the node to target
  • functionSelector: Either the name of the function to call from found node's properties or a functional selector that returns a function from a nodes properties.
  • args: The arguments to call the located function with

Returns the result of the function triggered if one is returned.

Example Usage(s):

// calls the `onclick` function on the first node with a key of `foo`
h.trigger('@foo', 'onclick');
// calls the `customFunction` function on the first node with a key of `bar` with an argument of `100`
// and receives the result of the triggered function
const result = h.trigger('@bar', 'customFunction', 100);

A functionalSelector can be used return a function that is nested in a widget's properties. The function will be triggered, in the same way that using a plain string functionSelector.

Trigger example

Given the following VDOM structure:

v(Toolbar, {
    key: 'toolbar',
    buttons: [
        {
            icon: 'save',
            onClick: () => this._onSave()
        },
        {
            icon: 'cancel',
            onClick: () => this._onCancel()
        }
    ]
});

The save toolbar button's onClick function can be triggered by:

h.trigger('@buttons', (renderResult: DNode<Toolbar>) => {
    return renderResult.properties.buttons[0].onClick;
});

Note: If the specified selector cannot be found, trigger will throw an error.

harness.getRender

harness.getRender() returns the render with the index provided, when no index is provided it returns the last render.

getRender(index?: number);
  • index: The index of the render result to return

Example Usage(s):

// Returns the result of the last render
const render = h.getRender();
// Returns the result of the render for the index provided
h.getRender(1);