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
Make tests more readable and maintainable.
While tests tells What needs to happen, drivers encapsulate How things are done (implementation).
// Component.driver.jsx
export class ComponentDriver {
constructor() { }
given = { };
when = { };
get = { };
}
Receives a saved name
Requests saving a new name
// NameComponent.driver.jsx
import {mount} from 'enzyme';
import {NameComponent} from './NameComponent';
class NameComponentDriver {
when = {
created: () => {
this.component = mount(<NameComponent/>);
return this;
}
};
}
The most basic usage - mount the component to the DOM.
class NameComponentDriver {
when = {
saveName: () => {
this.component
.find('button')
.simulate('click');
return this;
},
fillName: name => {
this.component
.find('input')
.simulate('change', {target: {event: name}});
return this;
}
};
}
Interaction with the component
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();
}
};
}
Querying the component
Tests shouldn't care if we use React, Enzyme, etc...
Prefer exposing values instead of enzyme wrappers.
class NameComponentDriver {
given = {
savedName: name => {
this.savedName = name;
return this;
},
};
when = {
created: () => {
this.component = mount(
<NameComponent savedName={this.savedName}/>
);
return this;
}
};
}
Setting props for the component
class NameComponentDriver {
constructor(component){
this.component = component;
}
}
Reuse the driver inside other drivers, by receiving a mounted component
// 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 fill the saved name input', () => {
driver
.when.created()
.when.fillName('Alexey')
expect(driver.get.filledNameValue()).toEqual('Alexey');
});
});
Â
A component test is already an integration test
describe('NameComponent', () => {
it('should save the new name', () => {
const onSave = jest.fn();
driver
.given.onSaveHandler(onSave)
.when.created()
.when.saveName();
expect(onSave).toHaveBeenCalled();
});
});
class NameComponentDriver {
given = {
onSaveHandler: fn => {
this.onSave = fn;
return this;
}
}
}
describe('NameComponent', () => {
it('should have the correct save button styling', () => {
driver
.when.created();
expect(driver.get.saveButtonHasClass('saveStyle')).toBeTruthy();
});
});
class NameComponentDriver {
get = {
saveButtonHasClass: className =>
this.get._saveButton().hasClass(className)
}
}
Same technique will work when using css modules (behind the scenes we use proxy for .scss files)
The component lives inside you app, so you need to test the integration as well.
The component is responsible to fetch and display a saved name from the server and update it.
// 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 {
given = {};
when = {created: () => this.component = mount(<App/>); return this;};
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)
});
});
// 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 = {
_nameComponent: () =>
this.component.find('NameComponent'),
savedNameValue: () =>
this.get._nameComponent().find('[data-hook="saved-name"]').text()
}
}
//App.driver.jsx
import {NameComponentDriver} from '../NameComponent/NameComponent.driver';
class AppDriver {
get = {
_nameComponent: () =>
this.component.find('NameComponent'),
_nameComponentDriver: () =>
new NameComponentDriver(this.get._nameComponent()),
savedNameValue: () =>
this.get._nameComponentDriver().get.savedNameValue()
}
}
// App.jsx
import React from 'react';
import {NameComponent} from './NameComponent/NameComponent';
export const App = () => (
<div>
<NameComponent name="Shlomi"/>
</div>
);
"Wait for it..."
describe('App', () => {
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"
});
});
Assume that all functions are implemented, but saveName is an asynchronous action. This test will fail:
How can we fix the test?
describe('App', () => {
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);
});
});
describe('App', () => {
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) {return;}
}, 50);
});
});
import eventually from 'wix-eventually';
describe('App', () => {
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')
);
});
});
import eventually from 'wix-eventually';
describe('App', () => {
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');
});
});
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);
}
Let's make an HTTP request!
// 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')
.get('/savedName')
.reply(200, res);
return this;
}
}
}
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>
);
}
}
describe('App', () => {
it('should update the saved name', async() => {
const savedName = 'Shlomi';
const newName = 'Alexey';
driver
.given.fetchSavedName({savedName})
.given.updateSavedName({newName})
.given.fetchSavedName({savedName: newName})
.when.created()
.when.fillName(newName)
.when.saveName();
await eventually(() =>
expect(driver.get.savedNameValue()).toEqual(newName)
);
});
});
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;
},
saveName: () => {
this.get._nameComponentDriver().when.saveName();
return this;
}
};
}
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();
});
nock('......')
.get('/site')
.reply(200, {
id: '7ac7e961-253c-4256-a007-b7e434242c8e',
name: 'My cool site!',
published: true,
isOwner: true,
permissions: []
});
Also prone to mistakes and could easily break
Help is needed!
export interface ISiteDTO {
id: string;
name: string;
published: boolean;
isOwner: boolean;
permissions: string[];
}
Give it a try, you might even like it 👿 😇
Definitions are helpful in your code, but also in test.
// test/builders/SiteDTOBuilder.ts
import { ISiteDTO } from '../../src/types/siteDTO';
import { Chance } from 'chance';
const chance = Chance();
export class SiteDTOBuilder {
private id: string = chance.guid();
private name: string = chance.word();
private published: boolean = chance.bool();
private isOwner: boolean = chance.bool();
private permissions: string[] = [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();
import { aSiteDTOBuilder } from '../../test/builders/SiteDTOBuilder';
describe('Some Component', () => {
it('should display a site', () => {
const site = aSiteDTOBuilder()
.withName('My cool site!')
.build();
driver
.given.fetchSite(site)
.when.created()
expect(driver.get.siteName()).toEqual(site.name);
});
});
(test, definition, builder, 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 {fetchSite} from '../actions/site';
import {App as AppComponent} from './App';
function mapStateToProps(state) {
return {siteName: state.site.name};
}
export const App = connect(mapStateToProps, {fetchSite})(AppComponent);
//client.tsx
import * as React from 'react';
import {render} from 'react-dom';
import {Provider} from 'react-redux';
import {App} from './App/App.container';
import {configureStore} from './stores/configureStores';
render(
<Provider store={configureStore()}
<App/>
</Provider>,
document.querySelector('#root')
);
How do we test <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)
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
// App.driver.jsx
class AppDriver {
created: () => {
this.component = mount(
<Provider store={configureStore()}
<App/>
</Provider>
);
return this;
}
}
Wrap with Redux Provider and use the exact same store!
Actual actions and reducers means you test the same behavior and not a mocked behvaior.
render(
<Provider store={store}>
<I18nextProvider i18n={translations}>
<Router history={history} onUpdate={onUpdate}>
<App/>
</Router>
</I18nextProvider>
</Provider>,
document.querySelector('#root')
);
It becomes ugly wrapping each component when testing
https://github.com/wix-private/wix-js-framework/tree/master/react-test-wrappers
//testWrappers.ts
import configureStore from './configureStore';
import {ComponentWrapper} from 'react-test-wrappers/dist/src/componentWrapper';
import {reduxWrapper} from 'react-test-wrappers/dist/src/wrappers/redux';
import {i18nextWrapper} from 'react-test-wrappers/dist/src/wrappers/i18next';
import {routerWrapper} from 'react-test-wrappers/dist/src/wrappers/router';
export function testWrapper(initialState = {}) {
return new ComponentWrapper()
.wrapWith(reduxWrapper(configureStore(initialState)))
.wrapWith(i18nextWrapper())
.wrapWith(routerWrapper());
}
//App.spec.tsx
import {testWrapper} from './testWrappers'
import {App} from './App';
import {mount} from 'enzyme';
mount(testWrapper().build(<App/>));
🔥🔥🔥🔥🔥🔥
🔥🔥🔥🔥🔥🔥
A simple counter
https://github.com/wix-private/mobx-counter-container-example
Even for a small project like the above counter, is a big refactor.
only one change was needed in tests.
Where?
to-mobx-container (From stores to flux) to-redux (From flux to redux)
// test/mocha-setup.js
import 'jsdom-global/register';
// App.driver.jsx
export class AppDriver {
when = {
created: {
this.component = mount(
(<App/>),
{attachTo: document.createElement('div')}
);
return this;
}
};
cleanup() {
this.component.detach();
};
}
// App.spec.js
import {AppDriver} from './App.driver';
describe('App', () => {
afterEach(() => {
driver.cleanup();
});
});