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
// Component.driver.jsx
class ComponentDriver {
given = { };
when = { };
get = { };
}
Specs should tell What needs to happen.
Drivers encapsulate How things are done (implementation).
Receives a saved name
Allows saving a new name
// 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.
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.
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.
Tests shouldn't care if we use React, Enzyme, etc...
Prefer exposing values instead of enzyme wrappers.
class NameComponentDriver {
savedName;
given = {
savedName: name => {
this.savedName = name;
return this;
},
};
when = {
created: () => {
this.component = mount(
<NameComponent savedName={this.savedName}/>
);
return this;
}
};
}
Fill props
// 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');
});
});
class NameComponentDriver {
constructor(component){
this.component = component;
}
}
A component test is already sort of an integration test
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();
});
});
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)
The component lives inside you app, so you need to test the integration as well.
The component is responsible to fetch and display the 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 {
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)
});
});
// 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
// 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
// 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"]')
}
}
// 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()
}
}
// 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()
"Wait for it..."
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:
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);
});
});
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);
});
});
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')
);
});
});
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');
});
});
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);
}
// 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;
}
}
}
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 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)
);
});
});
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;
}
};
}
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', {
id: '7ac7e961-253c-4256-a007-b7e434242c8e',
name: 'My site',
published: true,
isOwner: true,
permissions: []
});
Also prone to mistakes and could easily break
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
// 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();
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);
});
});
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);
//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
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
<Provider store={configureStore()}
<App/>
</Provider>,
// App.driver.jsx
class AppDriver {
get = {
savedNameValue: this.get._byDataHook('saved-name').text()
}
}
Red