How does unit testing work for front-end React-based applications in 2017?
Testing React Components
Components can be tested with snapshot testing. Tools like Jest (often combined with Enzyme) that offer this functionality take a ‘snapshot’ of what your component renders--everything from divs, attributes, classNames, props, and state--and saves it to a file, kind of like this:
On subsequent test runs, Jest takes a new snapshot to compare it with the old and breaks if the two don’t match, preventing any unintended changes to the rendering of your component. It’s not perfect, since you can’t really do true test-driven-development where you make a failing test first then fix it, so you just have to trust that your initial snapshot is correct, but it’s really quick and easy:
import React from 'react';
import Link from '../Link.react';
import renderer from 'react-test-renderer';
it('renders correctly', () => {
const tree = renderer.create(
<Link page="http://www.facebook.com">Facebook</Link>
).toJSON();
expect(tree).toMatchSnapshot();
});
https://gist.github.com/KTruong008/f194011faa19195a46b49e58ae4add0d
Testing Redux Actions and Reducers
Actions and Reducers are (or should be) pure functions. Stuff goes in, stuff goes out. No side effects. Test them like normal JavaScript functions, something like this:
// reducer.js
export const initialState = {
authenticated: false,
authenticating: false,
};
export const loginReducer = (
state = initialState,
action
) => {
switch (action.type) {
case AUTHENTICATION.AUTHENTICATED:
return { ...state, authenticating: false, authenticated: action.payload.authenticated };
default:
return state;
}
};
// action.js
export const switchAuthenticatedFlag = status => {
return {
type: 'LOGIN/AUTHENTICATED',
payload: {
authenticated: status,
},
};
};
// reducer.test.js
describe('Login Reducers', () => {
it('properly captures a dispatch to change authenticated state', () => {
expect(loginReducer(initialState, switchAuthenticatedFlag(true)))
.toEqual({
authenticated: true,
authenticating: false,
});
});
})
https://gist.github.com/KTruong008/91a932624e8463ab31bafaacbd75e63e
Testing Reselect Selectors
Selectors are pretty handy stuff. They untangle the mess that Redux state often ends up turning into and they allow you to abstract gnarly logic out from reducers so they stay clean and simple. Using the common createSelector function, it takes in some arguments and spits out some other function that takes in state and returns what you want selected from said state:
Selectors.js
import { createSelector } from 'reselect'
const shopItemsSelector = state => state.shop.items
const subtotalSelector = createSelector(
shopItemsSelector,
items => items.reduce((acc, item) => acc + item.value, 0)
)
https://gist.github.com/KTruong008/fb5a44f0db369046f70ae86ec558f693
I guess I could mock the whole state, pass it through and then check that it returns what I want it to, but that’s a lot of work.
All I really want to do is check the last function parameter, the function that actually performs the custom logic, and it just so happens that the object createSelector returns has a property, resultFunc, that returns the last passed-in function. Using this, we can test selectors like this:
// selector.js
export const selectLogin = (state: AppStateT) => state.login;
export const selectAuthenticated = createSelector(
selectLogin,
(loginState: LoginStateT) => loginState.authenticated
);
// selectors.test.js
import { type } from 'ramda';
import {
selectAuthenticated,
} from '../login.selectors';
describe('Login Selectors', () => {
describe('selectAuthenticated', () => {
it('should return login.authenticated as boolean', () => {
mockParameters = {
login: {
authenticated: false,
authenticating: false,
},
};
const selected = selectAuthenticated.resultFunc(mockParameters.login);
expect(type(selected)).toEqual('Boolean');
});
});
});
https://gist.github.com/KTruong008/9514d1900a1ef4eb743fcbf7fd73c25a
Epics are the core primitive of redux-observables, a RxJS-based middleware for Redux. I highly recommend giving observables and reactive programming a try because it makes it a breeze to deal with asynchronous and/or event-based problems like user interactions, animations and timers, websockets, and AJAX calls.
Epics are functions that receive and output a stream of actions. The simplest way to test epics, in my experience, has been to just test the bare minimum of what an epic is supposed to do--actions in, actions out. Invoke your epic with an action, then test that the output action is what you expected.
Let’s say you got an epic that handles the flow for successful logins. Upon a successful login, you want to dispatch multiple actions: one to change routes, one to change authenticated state, and one to indicate the form submission has been completed:
epic.js
export const loginSuccessEpic = (
action$,
store
) => action$
.ofType(LOGIN_TYPES.SUCCESS)
.mergeMap(() => {
return Observable.from([
location.createRouteChangeAction(
LOCATION.USER_DASHBOARD
),
switchAuthenticatedFlag(true),
stopSubmit(FORM_NAMES.LOGIN)
]);
});
https://gist.github.com/KTruong008/14a90d70e3120fcaa54487ac7e2c7740
In our testing, what we want to do is feed in an action of type LOGIN_TYPES.SUCCESS, and test that the output returns the three dispatched actions:
epic.test.js
describe('loginSuccessEpic', () => {
it('dispatches actions to change location, authenticated, and stopSubmit', () => {
const action = ActionsObservable.of({
type: LOGIN_TYPES.SUCCESS,
payload: [{}, {}]
});
loginSuccessEpic(action, {})
.toArray()
.subscribe((outputActions) => {
expect(outputActions).toEqual([
location.createRouteChangeAction(
LOCATION.USER_DASHBOARD
),
switchAuthenticatedFlag(true),
stopSubmit(FORM_NAMES.LOGIN)
]);
});
});
});
https://gist.github.com/KTruong008/642536fd3bba01d76664c2014d288bea
If you want to learn more about this method, here’s a good article from @KevinSalter about testing epics:
https://medium.com/kevin-salters-blog/writing-epic-unit-tests-bd85f05685b
Conclusion
Testing doesn’t have to be a drag. It’s 2017 and we have the technology. Go write some tests and have fun.
If you happen to have questions, feedback, feature requests or anything at all, please don’t hesitate to reach out.