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 testcustomComparators
: Array of custom comparator descriptors. Each provides a comparator function to be used during the comparison forproperties
located using aselector
andproperty
nameoptions
: Expanded options for the harness which includescustomComparators
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 APIgetRender
: 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.
breakpoint
middleware
Mock 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>
));
});
});
iCache
middleware
Mock 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>);
});
});
intersection
middleware
Mock 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>);
});
});
node
middleware
Mock 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);
resize
middleware
Mock 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>);
});
});
Store
middleware
Mock 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'skey
property - The
classes
property is used instead ofclass
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 expectedDNode
structure of the queried nodeactualRenderFunction
: An optional function that returns the actualDNode
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 targetfunctionSelector
: 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);