Testing React Components
Drivers, Common use cases, Best practices, TDD
Agenda
- About me
- Infra team
- Test Drivers
- Component Unit testing
- Component Integration testing
- Testing Async actions
- Testing Network requests
- Redux, MobX & friends
Testing Components with state management
About me
Shlomi Toussia-Cohen
29 years old, Married to Dana, Lives in Tel-Aviv.
Hobbies: Motorcycles, Gardening.
~4 years FED, 11 months in Wix.
FED Guild Master in the Infra Team.
Current Team: Wix-Stores (Cart & Checkout)
Previous Team: CX-OS (Business-Manager)
Original Team: FED-Infra
wix-js-stack
Your first stop for new stack projects
#wix-js-stack
wix-js-stack
- Generators:
- client / server / fullstack / universal
- component library / node library
- Example projects:
- Experiments
- Typescript
- HMR
- ...
- How to work with the stack
- FAQ and how to (test, deploy, debug...)
yoshi
A tool for common tasks in Javascript projects
Get inspired by a project
- Fullstack Redux based project
- Client and Server Experiments
- Translated components
- Server RPC calls
- wix-style-react components
- Fully tested
Test Drivers
Component Driver Pattern
// Component.driver.jsx
class ComponentDriver {
given = { };
when = { };
get = { };
}
Specs should tell What needs to happen.
Drivers encapsulate How things are done (implementation).
Example: NameComponent
Receives a saved name
Allows saving a new name
Driver: when (1)
// NameComponent.driver.jsx
import {mount} from 'enzyme';
import {NameComponent} from './NameComponent';
class NameComponentDriver {
component;
when = {
created: () => {
this.component = mount(<NameComponent/>);
return this;
}
};
}
The most basic usage - mount the component to the DOM.
Driver: when (2)
class NameComponentDriver {
when = {
submitName: () => {
this.component.find('button').simulate('click');
return this;
},
fillName: name => {
this.component.find('input')
.simulate('change', {target: {event: name}});
return this;
}
};
}
Interact with the component.
Driver: get (1)
class NameComponentDriver {
get = {
_byDataHook: hook => {
return this.component.find(`[data-hook="${hook}"]`);
},
_savedName: () => {
return this.get._byDataHook('saved-name');
}
savedNameValue: () => {
return this.get._savedName().text();
}
};
}
Query the component.
Tip #1
Hide technology from your test
Tests shouldn't care if we use React, Enzyme, etc...
Prefer exposing values instead of enzyme wrappers.
Driver: given
class NameComponentDriver {
savedName;
given = {
savedName: name => {
this.savedName = name;
return this;
},
};
when = {
created: () => {
this.component = mount(
<NameComponent savedName={this.savedName}/>
);
return this;
}
};
}
Fill props
Driver: usage in spec
// NameComponent.spec.js
import {NameComponentDriver} from './NameComponentDriver.driver';
describe('NameComponent', () => {
let driver;
beforeEach(() => {
driver = new NameComponentDriver();
});
it('should display the saved name', () => {
driver
.given.savedName('Shlomi')
.when.created();
expect(driver.get.savedNameValue()).toEqual('Shlomi');
});
it('should change the saved name', () => {
driver
.given.savedName('Shlomi')
.when.created()
.when.fillName('Alexey')
.when.submitName();
expect(driver.get.savedNameValue()).toEqual('Alexey');
});
});
Driver: Reuse
class NameComponentDriver {
constructor(component){
this.component = component;
}
}
Component Unit testing
Not exactly like unit testing a function
- Test the output based on the component logic.
- Test edge cases and small details.
- The interactions and some scenarios.
A component test is already sort of an integration test
Unit: Testing callback functions
class NameComponentDriver {
onSave;
given = {
onSaveHandler: fn => {
this.onSave = fn;
return this;
}
}
}
describe('NameComponent', () => {
it('should update the saved name', () => {
const onSave = jest.fn();
driver
.given.onSaveHandler(onSubmit)
.when.created()
.when.saveName();
expect(onSave).toHaveBeenCalled();
});
});
Unit: Testing CSS classes
class NameComponentDriver {
get = {
saveButtonHasClass: className =>
this.get._saveButton().hasClass(className)
}
}
describe('NameComponent', () => {
it('should have the correct save button styling', () => {
driver
.when.created();
expect(driver.get.saveButtonHasClass('submitStyle')).toBeTruthy();
});
});
Same technique will work when using css modules (behind the scenes we use proxy for .scss files)
Unit testing is not always enough
The component lives inside you app, so you need to test the integration as well.
Components Integration testing
TDD - fun time
Let's TDD
<App/>
The component is responsible to fetch and display the saved name from the server and update it.
Step 0 - Boilerplate
// App.jsx
import React from 'react';
export const App = () => <div>App</div>;
// App.driver.jsx
import React from 'react';
import {App} from './App';
export class AppDriver {
component;
when = {created: this.component = mount(<App/>)};
get = {_byDataHook: hook => this.component.find(`[data-hook="${hook}]"`)}
}
// App.spec.js
import {AppDriver} from './App.driver';
describe('App', () => {
let driver;
beforeEach(() => {
driver = new AppDriver();
});
it('should pass', () => {
driver.when.created();
expect(true).toEqual(true)
});
});
Step 1: Expect some name
// App.spec.js
describe('App', () => {
it('should show the saved name', () => {
driver.create();
expect(driver.get.savedNameValue()).toEqual('Shlomi');
});
});
// App.driver.jsx
class AppDriver {
get = {
_savedName: this.get._byDataHook('app-saved-name'),
savedNameValue: this.get._savedName().text()
}
}
Red
Step 2: Display hardcoded name
// App.jsx
import React from 'react';
export const App = () => (
<div>
<div data-hook="app-saved-name">
Shlomi
</div>
</div>
);
Green
Refactor is not needed at the moment
Step 3: Use NameComponent
(Suggestion #1 - refactor component)
// App.jsx
import React from 'react';
import {NameComponent} from './NameComponent/NameComponent';
export const App = () => (
<div>
<NameComponent name="Shlomi"/>
</div>
);
// App.spec.js
describe('App', () => {
it('should show the saved name using NameComponent', () => {
driver.create();
expect(driver.get.savedNameValueRefactor()).toEqual('Shlomi');
});
});
// App.driver.jsx
class AppDriver {
get = {
_nameComponent: () => this.component.find('NameComponent'),
savedNameValueRefactor: () =>
this.get._nameComponent().find('[data-hook="saved-name"]')
}
}
Step 3: Use NameComponent
(Suggestion #2 - new test)
// App.spec.js
describe('App', () => {
it('should show the saved name using NameComponent', () => {
driver.create();
expect(driver.get.savedNameRefactor()).toEqual('Shlomi');
});
});
//App.driver.jsx
import {NameComponentDriver} from '../NameComponent/NameComponent.driver';
class AppDriver {
get = {
_nameComponent: () =>
this.component.find('NameComponent'),
_nameComponentDriver: () =>
new NameComponentDriver(this.get._nameComponent())
savedNameValueRefactor: () =>
this.get._nameComponentDriver().get.savedNameValue()
}
}
Step 3: Use NameComponent
(Suggestion #3 - new test, reuse driver)
Step 4: Change the component
// App.jsx
import React from 'react';
import {NameComponent} from './NameComponent/NameComponent';
export const App = () => (
<div>
<NameComponent name="Shlomi"/>
</div>
);
Fix:
Remove failing test
Refactor:
delete: get._savedName(), get.savedNameValue()
rename: get.savedNameValueRefactor() => get.savedNameValue()
Questions so far?
How to: Asynchronous testing
"Wait for it..."
Async: save name
describe('NameComponent', () => {
it('should change the saved name', () => {
driver
.given.savedName('Shlomi')
.when.created()
.when.fillName('Alexey')
.when.saveName();
expect(driver.get.savedNameValue()).toEqual('Alexey');
// Fail: Expected "Shlomi" to equal "Alexey"
});
});
Only for now, assume that saving name is an asynchronous action.
This test will now fail:
Async: naively - explicit timeout
describe('NameComponent', () => {
it('should change the saved name', (done) => {
driver
.given.savedName('Shlomi')
.when.created()
.when.fillName('Alexey')
.when.saveName();
setTimeout(() => {
expect(driver.get.savedNameValue()).toEqual('Alexey');
done();
}, 1000);
});
});
Async: better - polling and retry
describe('NameComponent', () => {
it('should change the saved name', (done) => {
driver
.given.savedName('Shlomi')
.when.created()
.when.fillName('Alexey')
.when.saveName();
const timeout = setTimeout(() => done('error'), 1000);
const interval = setInterval(() => {
try {
expect(driver.get.savedNameValue()).toEqual('Alexey');
clearTimeout(timeout);
clearInterval(interval);
done();
} catch (e) {continue;}
}, 50);
});
});
Async: perfect - wix-eventually
import eventually from 'wix-eventually';
describe('NameComponent', () => {
it('should change the saved name', () => {
driver
.given.savedName('Shlomi')
.when.created()
.when.fillName('Alexey')
.when.saveName();
return eventually(() =>
expect(driver.get.savedNameValue()).toEqual('Alexey')
);
});
});
Async: async-await
import eventually from 'wix-eventually';
describe('NameComponent', () => {
it('should change the saved name', async() => {
driver
.given.savedName('Shlomi')
.when.created()
.when.fillName('Alexey')
.when.saveName();
await eventually(() =>
expect(driver.get.savedNameValue()).toEqual('Alexey')
);
console.log('and do more things after fulfilled');
});
});
Tip #2
Create a preconfigured eventually
import wixEventually from 'wix-eventually';
const defaultTimeouts = {timeout: 500, interval: 10};
async function eventually(condition, timeouts = defaultTimeouts) {
const timeouts = {...defaultTimeouts, ...timeouts};
return await wixEventually.with(timeouts)(condition);
}
Back to the <App/>
Recap
- Created the component and driver
- Displayed some hard coded name
- Used the NameComponent to display the hard coded name
Step 5: expect for server results
// App.spec.js
import eventually from 'wix-eventually';
describe('App', () => {
it('should show the saved name', async () => {
driver
.given.fetchSavedName({savedName: 'Shlomi'})
.create();
await eventually(() =>
expect(driver.get.savedNameValue()).toEqual('Shlomi')
);
});
});
// App.driver.jsx
import nock from 'nock';
class AppDriver {
given = {
fetchSavedName: res => {
nock('http://www.my-server.com') // HARD-CODED for now
.get('/savedName')
.reply(200, res);
return this;
}
}
}
Step 6: component fetches data
Red
// App.jsx
import React from 'react';
import {NameComponent} from './NameComponent/NameComponent';
import axios from 'axios';
export class App extends React.Component {
constructor() {
this.state = {savedName: ''};
}
componentDidMount() {
axios.get('/savedName')
.then(data => this.setState({savedName: data.savedName}));
}
render() {
return (
<div>
<NameComponent savedName={this.state.savedName}/>
</div>
);
}
}
How would you test the update name flow? (test + driver)
Possible implementation: Test
describe('App', () => {
it('should update the saved name', async() => {
const currentName = 'Shlomi';
const newName = 'Alexey';
driver
.given.fetchSavedName({savedName: currentName})
.given.updateSavedName({savedName: newName})
.given.fetchSavedName({savedName: newName})
.when.created()
.when.fillName(newName)
.when.submitName();
await eventually(() =>
expect(driver.get.savedNameValue()).toEqual(newName)
);
});
});
Possible implementation: Driver
class AppDriver {
given = {
updateSavedName: (params) => {
nock('http://www.my-server.com')
.post('/savedName', params)
.reply(200);
return this;
}
};
when = {
fillName: name => {
this.get._nameComponentDriver().when.fillName(name);
return this;
},
submitName: () => {
this.get._nameComponentDriver().when.submitName();
return this;
}
};
}
Tip #3 http mocks
Verify all requests are fulfilled
Avoid unused mocks from leaking to next tests
export function validateAllMocksCalled() {
if (!nock.isDone()) {
const errorMessage = `pending mocks: ${nock.pendingMocks()}`;
this.clearMocks();
throw new Error(errorMessage);
}
}
export function clearMocks() {
nock.cleanAll();
nock.emitter.removeAllListeners('no match');
}
afterEach(() => {
validateAllMocksCalled();
clearMocks();
});
Mocking data is exhausting
nock('......')
.get('/site', {
id: '7ac7e961-253c-4256-a007-b7e434242c8e',
name: 'My site',
published: true,
isOwner: true,
permissions: []
});
Also prone to mistakes and could easily break
Use typescript :)
export interface ISiteDTO {
id: string;
name: string;
published: boolean;
isOwner: boolean;
permissions: string[];
}
Even if you don't like it 👿 😇
This is helpful in your code, but also in test
Create Test Builders
// test/builders/SiteDTOBuilder.ts
import { ISiteDTO } from '../../src/common/types/siteDTO';
import { Chance } from 'chance';
const chance = Chance();
export class SiteDTOBuilder {
private id = chance.guid();
private name = chance.word();
private published = chance.bool();
private isOwner = chance.bool();
private permissions = [chance.word(), chance.word()];
withId(id: string) {
this.id = id;
return this;
}
public build(): ISiteDTO {
return {
id: this.id,
name: this.name,
published: this.published,
isOwner: this.isOwner,
permissions: this.permissions
};
}
}
export const aSiteDTOBuilder = () => new SiteDTOBuilder();
And now your test is great!
import { aSiteDTOBuilder } from '../../test/builders/SiteDTOBuilder';
describe('Some Component', () => {
it('should display a site', () => {
const site = aSiteDTOBuilder()
.withName('Alexey')
.build();
driver
.given.fetchSite(site)
.when.created()
expect(driver.get.siteName()).toEqual(site.name);
});
});
Time to take a deep breath
Redux, MobX & friends
What should I test?
In Redux
- Actions
- Reducers
- Selectors
- Components
In MobX
- Observers and Observables
- Computed Values
- Actions
- Stores
- Components
Test the bheavior of the connected / injected components
Connected Component
Container pattern
//App.tsx
import * as React from 'react';
export class App <{siteName: string; fetchSite: Function}, void> {
componentWillMount() {
this.props.fetchSite();
}
render() {
return (<div data-hook="site-name">{this.props.siteName}</div>);
}
}
//App.container.ts
import {connect} from 'react-redux';
import {IState} from '../types/state';
import {fetchSite} from '../actions/site';
import {App as AppComponent} from './App';
function mapStateToProps(state: IState) {
return {siteName: state.site.name};
}
export const App = connect(mapStateToProps, {fetchSite})(AppComponent);
A typical redux app
//client.tsx
import * as React from 'react';
import {render} from 'react-dom';
import {Provider} from 'react-redux';
import {App} from './App/App.container';
render(
<Provider store={configureStore()}
<App/>
</Provider>,
document.querySelector('#root')
);
Wrap with Provider and pass redux store
Render the connected app to the DOM
A typical redux app
import axios from 'axios';
import { ISiteDTO } from '../../common/types/siteDTO';
import * as types from '../actionTypes/siteActionTypes';
export async function fetchSite(siteId: string) {
return dispatch => {
const res = await axios.get(`/site?siteId=${siteId}`);
dispatch(fetchSiteSuccess(res.data))
};
}
function fetchSiteSuccess(site: ISiteDTO) {
return {
type: types.FETCH_SITE_SUCCESS,
site
};
}
fetchSite async action (using thunks)
A typical redux app
import { ISiteDTO } from '../../common/types/site';
import * as types from '../actionTypes/siteActionTypes';
const initialState: ISiteDTO = {
id: '',
name: '',
published: false,
isOwner: true,
permissions: null
};
export function site(state = initialState, action): ISiteDTO {
switch (action.type) {
case types.FETCH_SITE_SUCCESS:
return Object.assign({}, state, action.site);
default:
return state;
}
}
The site reducer
Title Text
<Provider store={configureStore()}
<App/>
</Provider>,
// App.driver.jsx
class AppDriver {
get = {
savedNameValue: this.get._byDataHook('saved-name').text()
}
}
Red
More things to write about
- Testing components in state management libraries
-
- don't test actions, reducers, stores, component solely.
- test the logic through the components.
- test wrappers, initial state
- switching library
- jsdom performance issues and optimization
- import jsdom before react-dom jsdom-global/register
- resetting dom between tests => slow.
- mount => on the body. react doesn't kill the component.
- mounting on a specific node = > better
- dependency injection in react
Testing environment
- Tests runs in NodeJS (super fast)
- Prefer using Jest or Mocha
(Karma+Jasmine are also supported).
Simulating browser
- Use JSDOM to fake DOM and window.
- Jest uses JSDOM under the hood (in an efficient way)
React Testing Utilities
- Enzyme
makes it easier to assert, manipulate, and traverse your React Components' output.
Copy of Testing React Components
By Shlomi Toussia Cohen
Copy of Testing React Components
- 431