Testing

Even though this part was left as the last one, it is one of the most important parts of a software project and this will only be one way of doing tests.

Initialize

For our testing framework we are going to use Jest, with Enzyme for BDD testing, enzyme-to-json for snapshot testing, ts-jest so Jest plays nice with TypeScript and react-test-renderer for rendering React-components. Because of the new adapters in Enzyme we also need to add the appropriate adapter (in our case for React version 16) and finally we also add a little tool called concurrently to allow the simultaneous running of multiple NPM or Yarn scripts at the same time

    yarn add -D jest ts-jest enzyme enzyme-to-json react-test-renderer enzyme-adapter-react-16 concurrently

setup.js

As Enzyme requires the adapter for React and on top of that error messages related to HTTP-calls can sometimes be incomprehensible, we need to create a small setup file to fix these things, so create a file called setup.js in src/jest-folder and fill it as follows:

// Import adapter for enzyme
var enzyme = require('enzyme');
var Adapter = require('enzyme-adapter-react-16');
enzyme.configure({ adapter: new Adapter() })

// Log all jsDomErrors when using jsdom testEnvironment
window._virtualConsole && window._virtualConsole.on('jsdomError', function (error) {
  console.error('jsDomError', error.stack, error.detail);
});

After that we need to register it for Jest so open up your package.json and add the following:

    "jest": {
        // Other content in the "jest"-object
        "setupTestFrameworkScriptFile": "<rootDir>/src/jest/setup.js"
    }

Linting

Let's start with the easiest part, linting our codebase. For the TypeScript code we already have our linting script setup under lint:ts.

For our Sass files it will be simply

    "scripts": {
        "lint:sass": "sass-lint src/**/*.scss -v --max-warnings 1",
    }

by running sass-lint on the files mathing our glob pattern, adding the v-flag for verbose output and max-warnings to only allow for one warning (you can omit it if you dare).

TS-Jest and enzyme-to-json

We will also need to make some setting for Jest in our package.json to use ts-jest and enzyme-to-json with our code

    "jest": {
        "snapshotSerializers": ["<rootDir>/node_modules/enzyme-to-json/serializer"],
        "transform": {
            ".(ts|tsx)": "<rootDir>/node_modules/ts-jest/preprocessor.js"
        },
        "testRegex": "(/__specs__/.*|\\.(spec))\\.(ts|tsx)$",
        "moduleFileExtensions": ["ts", "tsx", "js"]
    }

where the first setting snapshotSerializers sets Jest to use enzyme-to-json for serializing, testRegex makes it so that Jest looks for tests in folders named __specs__ with the extension spec.ts or spec.tsx and the rest are there so that it uses TS-Jest to build the code.

Button

We will start with one of the simple components, in this case the Button. With all our tests we will follow the convention of having the tests in a folder next to the testable code called __specs__ named [TestableCode].spec.ts or *.tsx if it includes JSX like this

src/
-- components/
-- -- __specs__/
-- -- -- Button.spec.tsx
-- -- Button.tsx

Now the actual content of Button.spec.tsx will look like this

import * as React from 'react';
import { shallow } from 'enzyme';
import Button from '../Button';

describe('Button', () => {
    const click = jest.fn();
    const wrapper = shallow(<Button click={click} text="" />);

    it('should render correctly', () => (
        expect(wrapper).toMatchSnapshot()
    ));

    it('should call the correct function on click', () => {
        const testTimes = 5;
        for (let i = 0; i < testTimes; i++) {
            wrapper.find('.btn').simulate('click');
        }
        expect(click).toHaveBeenCalledTimes(testTimes); 
    });
});

Going through it line by line, the first line of importance is creating a describe-block

describe('Button', () => {});

which Jest uses to group tests together (in this case all tests related to 'Button').


After that we create a mocked function that will allow us to test how many times the function has been called, what values it was called with etc.

    const click = jest.fn();

Next we create a shallow render of the Button

import * as React from 'react';
import { shallow } from 'enzyme';
import Button from '../Button';
// ...
    const button = shallow(<Button click={click} text="" />);

which will allow us to do snapshot testing and to simulate user interaction.


First we test our Button's snapshot (to ensure we don't accidentally change the way it looks like)

    it('should render correctly', () => (
        expect(wrapper).toMatchSnapshot()
    ));

which will create a snapshot-file inside a folder __snapshots__ which you should include in your version control, as it will compare the render to the file if it exists.

it-clauses are Jest's tests and expect defines something to test


Finally we simulate user interaction and ensure it has the correct result

    it('should call the correct function on click', () => {
        const testTimes = 5;
        for (let i = 0; i < testTimes; i++) {
            wrapper.find('.btn').simulate('click');
        }
        expect(click).toHaveBeenCalledTimes(testTimes); 
    });

where we first define the number of times we want to simulate a click, then using find (which takes a CSS-selector to find the correct HTML-element) we simulate a click-event and test that the mock function was called the correct amount of times.

Loader

Next up we create a simple snapshot test for our Loader-component

import * as React from 'react';
import { shallow } from 'enzyme';
import Loader from '../Loader';

describe('Loader', () => (
    it('should render correctly', () => (
        expect(shallow(<Loader />)).toMatchSnapshot()
    ))
));

where everything is very similar to the Button-tests.

TodoComponent

Then for our last component TodoComponent we do, once again, similar tests

import * as React from 'react';
import { shallow } from 'enzyme';
import TodoComponent from '../TodoComponent';
import Todo from '../../common/Todo';

describe('TodoComponent', () => {
    const testTodo1 = new Todo(1, 'Title');
    const testTodo2 = new Todo(0, 'Testing', true);
    const setDone = jest.fn();
    const wrapper1 = shallow(<TodoComponent todo={testTodo1} setDone={setDone} />);
    const wrapper2 = shallow(<TodoComponent todo={testTodo2} setDone={setDone} />);

    it('should render correctly', () => {
        expect(wrapper1).toMatchSnapshot();
        expect(wrapper2).toMatchSnapshot();
    });

    it('should call setDone correctly', () => {
        wrapper1.find('.todo__checkbox').simulate('change');
        expect(setDone).toBeCalledWith(testTodo1.id);
        expect(setDone).toHaveBeenCalledTimes(1);
        wrapper2.find('.todo__checkbox').simulate('change');
        expect(setDone).toBeCalledWith(testTodo2.id);
        expect(setDone).toHaveBeenCalledTimes(1);
    });
});

where the only difference is that we create two renders and as the setDone function shouldn't be called when the Todo is already done we ensure that it behaves as such.

PageNotFound

For PageNotFound we only need to make a simple snapshot-test

import * as React from 'react';
import { shallow } from 'enzyme';
import PageNotFound from '../PageNotFound';

describe('PageNotFound', () => (
    it('should render correctly', () => (
        expect(shallow(<PageNotFound />)).toMatchSnapshot()
    ))
));

IndexView

Next up we create tests for our IndexView

import * as React from 'react';
import { shallow } from 'enzyme';
import IndexView from '../IndexView';
import Todo from '../../../common/Todo';

describe('IndexView', () => {
    const testTodo1 = new Todo(0, 'title');
    const testTodo2 = new Todo(1, 'testing', true);
    const testTitle = 'A title';
    const testSetTitle = jest.fn();
    const testSaveTodo = jest.fn();
    const testSetDone = jest.fn();
    const wrapperMinimalProps = shallow((
        <IndexView
            title=""
            todos={[]}
            loading={false}
            setTitle={testSetTitle}
            saveTodo={testSaveTodo}
            setDone={testSetDone}
            match={{ params: undefined, isExact: true, path: '', url: '' }}
            location={{ pathname: '', search: '', state: {}, hash: '', key: '' }}
            history={createHistory()}
        />
    ));
    const wrapperMaximumProps = shallow((
        <IndexView
            title={testTitle}
            todos={[testTodo1, testTodo2]}
            loading
            setTitle={testSetTitle}
            saveTodo={testSaveTodo}
            setDone={testSetDone}
            match={{ params: undefined, isExact: true, path: '', url: '' }}
            location={{ pathname: '', search: '', state: {}, hash: '', key: '' }}
            history={createHistory()}
        />
    ));

    it('should render with correct props', () => {
        expect(wrapperMinimalProps).toMatchSnapshot();
        expect(wrapperMaximumProps).toMatchSnapshot();
    });

    it('should call the correct functions when typing to input field', () => {
        const testValue = 'A_TEST_VALUE';
        wrapperMinimalProps.find('[type="text"]').simulate('change', { target: { value: testValue }});
        expect(testSetTitle).toBeCalledWith(testValue);
    });
});

which is a bit more complex, but in essence built on the same things our previous tests were.


The biggest difference here is that for the simulation function

    it('should call the correct functions when typing to input field', () => {
        const testValue = 'A_TEST_VALUE';
        wrapperMinimalProps.find('[type="text"]').simulate('change', { target: { value: testValue }});
        expect(testSetTitle).toBeCalledWith(testValue);
    });

where we give the change function a value to send with the event and test that it goes to the correct function.

IndexReducer

For the IndexReducer we are not going to use enzyme, but rather normal Jest functions

import { ActionsObservable } from 'redux-observable';
import Todo from '../../../common/Todo';
import IndexReducer, {
    SET_TITLE,
    setTitle,
    SAVE_TODO,
    saveTodo,
    saveTodoEpic,
    SAVE_TODO_SUCCESS,
    saveTodoSuccess,
    SET_DONE,
    setDone,
    setDoneEpic,
    SET_DONE_SUCCESS,
    setDoneSuccess,
    IndexState,
} from '../IndexReducer';

describe('IndexReducer', () => {
    it('should set the correct title as payload on setTitle', () => {
        const payload = 'THIS_IS_A_TEST_TITLE';
        const setTitleAction = setTitle(payload);
        expect(setTitleAction).toEqual({ type: SET_TITLE, payload });
        const newState: IndexState = IndexReducer(undefined, setTitleAction);
        expect(newState.title).toEqual(payload);
    });

    it('should set the correct values on saveTodo', () => {
        const saveTodoAction = saveTodo();
        expect(saveTodoAction).toEqual({ type: SAVE_TODO });
        const newState: IndexState = IndexReducer(undefined, saveTodoAction);
        expect(newState.loading).toBeTruthy();
    });

    it('should trigger the correct action on saveTodoEpic', async () =>
        await saveTodoEpic(ActionsObservable.of(saveTodo()), undefined, undefined).forEach(actionReceived =>
            expect(actionReceived).toEqual({ type: SAVE_TODO_SUCCESS }),
        ));

    it('should set the correct values on saveTodoSuccess', () => {
        /* tslint:disable:no-magic-numbers */
        const testT = new Todo(1, 'Doing', true);
        const initialState: IndexState = { title: 'TEST', todos: [testT], loading: true };
        const saveTodoSuccessAction = saveTodoSuccess();
        expect(saveTodoSuccessAction).toEqual({ type: SAVE_TODO_SUCCESS });
        const newState: IndexState = IndexReducer(initialState, saveTodoSuccessAction);
        expect(newState.title).toEqual('');
        expect(newState.todos.length).toEqual(2);
        expect(newState.todos[1].done).toBeFalsy();
        expect(newState.todos[1].id).toEqual(2);
        expect(newState.todos[1].title).toEqual(initialState.title);
        expect(newState.loading).toBeFalsy();
        /* tslint:enable:no-magic-numbers */
    });

    it('should set the correct values on setDone', () => {
        const setDoneAction = setDone(0);
        expect(setDoneAction).toEqual({ type: SET_DONE, payload: 0 });
        const newState: IndexState = IndexReducer(undefined, setDoneAction);
        expect(newState.loading).toBeTruthy();
    });

    it('should trigger the correct action on setDoneEpic', async () =>
        await setDoneEpic(ActionsObservable.of(setDone(0)), undefined, undefined).forEach(actionReceived =>
            expect(actionReceived).toEqual({ type: SET_DONE_SUCCESS, payload: 0 }),
        ));

    it('should set the correct values on setDoneSuccess', () => {
        const initialState: IndexState = { title: '', todos: [new Todo(0, '')], loading: true };
        const setDoneSuccessAction = setDoneSuccess(0);
        expect(setDoneSuccessAction).toEqual({ type: SET_DONE_SUCCESS, payload: 0 });
        const newState: IndexState = IndexReducer(initialState, setDoneSuccessAction);
        expect(newState.loading).toBeFalsy();
        expect(newState.todos[0].done).toBeTruthy();
        expect(newState.todos[0].id).toEqual(initialState.todos[0].id);
        expect(newState.todos[0].title).toEqual(initialState.todos[0].title);
    });
});

where we test each of our action creator-functions and epics (we validate our reducer itself via the action creators).

I also highly recommend always explicitly setting the type of values in your tests, as accidentally changing the values in your code will result in a compiler error then.


For the first action creator setTitle we do it as follows

import IndexReducer, { SET_TITLE, setTitle } from '../IndexReducer';
// ...
    it('should set the correct title as payload on setTitle', () => {
        const payload = 'THIS_IS_A_TEST_TITLE';
        const setTitleAction = setTitle(payload);
        expect(setTitleAction).toEqual({ type: SET_TITLE, payload });
        const newState = IndexReducer(undefined, setTitleAction);
        expect(newState.title).toEqual(payload);
    });

where we first check that it has the correct return values and types and then test that running it through our IndexReducer has the desired effects. The same is done for saveTodo, saveTodoSuccess, setDone and setDoneSuccess.


Possibly the most important part of our testing is testing our epics, for example in the case of our saveTodoEpic

import { ActionsObservable } from 'redux-observable';
import { SAVE_TODO_SUCCESS, saveTodo } from '../IndexReducer';
// ...
    it('should trigger the correct action on saveTodoEpic', async () => (
        await saveTodoEpic(ActionsObservable.of(saveTodo()), undefined, undefined)
            .forEach(actionReceived => expect(actionReceived).toEqual({ type: SAVE_TODO_SUCCESS }));
    ));

where we have to use an async function, as Observables are not synchronous. We use ActionsObservable.of to create an Observable out of our action creator and give our epic an undefined as the second argument (which, if you remember is defined as a type of undefined in IndexReducer) and as the third one, as we don't use that feature. After that we subscribe to our new Observable returned by saveTodoEpic and check that the action received matches what we expect.


In our case we don't have any AJAX calls, but most likely you will require them, so to test them you would do something like this

import * as nock from 'nock';
import { ActionsObservable } from 'redux-observable';
import { State } '../../../redux/reducer';
import IndexReducer, {
    fetchTodo,
    fetchTodoSuccess,
    fetchTodoFail,
    fetchTodoEpic,
} from '../IndexReducer';

describe('fetchTodoEpic', () => {
    afterEach(() => {
        nock.cleanAll();
    });
    it('should return the correct effects', async () => {
        const payload = 'Todo';
        nock('path/to/endpoint')
            .get('/lastparam')
            .reply(200, payload, { 'Content-Type': 'application/json' });
        return await fetchTagsEpic(ActionsObservable.of(fetchTodo()), { getState: () => new State(), dispatch: () => {} }, undefined)
            .forEach(actionReceived => expect(actionReceived).toEqual(fetchTodoSuccess(payload)));
    });
    it('should fail correctly', async () => {
        const payload = 'ERROR';
        nock('path/to/endpoint')
            .get('/lastparam')
            .replyWithError(payload);
        return await fetchTagsEpic(ActionsObservable.of(fetchTodo()), { getState: () => new State(), dispatch: () => {} }, undefined)
            .forEach(actionReceived => expect(actionReceived).toEqual(fetchTodoFail(payload)));
    });
});

where we use nock to mock the API responses.

AppView

We also add simple snapshot tests for our AppView

import * as React from 'react';
import { shallow } from 'enzyme';
import createHistory from 'history/createBrowserHistory';
import AppView from '../AppView';

describe('AppView', () => {
    const index = (
        <AppView
            match={{ params: undefined, isExact: true, path: '', url: '' }}
            location={{ pathname: '', search: '', state: {}, hash: '', key: '' }}
            history={createHistory()}
        />
    );
    const notFound = (
        <AppView
            match={{ params: undefined, isExact: false, path: '', url: '' }}
            location={{ pathname: '', search: '', state: {}, hash: '', key: '' }}
            history={createHistory()}
        />
    );

    it('should render correctly', () => {
        expect(shallow(index)).toMatchSnapshot();
        expect(shallow(notFound)).toMatchSnapshot();
    });
});

Scripts

Finally we also want to run our tests! So back into our package.json

    "scripts": {
        "test": "concurrently --kill-others-on-fail -p \"{name}\" -n \"SASS-LINT,TS-LINT,JEST\" -c \"bgBlue,bgMagenta,bgCyan\" \"yarn lint:sass\" \"yarn lint:ts\" \"jest\"",
        "test:watch": "jest --watch",
        "test:ci": "yarn run lint:sass && yarn run lint:ts && jest --runInBand --forceExit",
    }

where the first command test will run our lint-scripts and Jest with its default configuration (--kill-others-on-fail will kill all three running processes if one test process fails). test:watch will run Jest in watch mode, so when you're working on your tests, it will only run the ones your changes affect, saving time. The last one test:ci adds a couple of flags to the Jest command, --runInBand which will run all tests in a single process (easier to spot errors) and --forceExit to ensure Jest will shut down after tests (Travis can freeze without this sometimes).

Alternatives

  • Possibly the best known alternative to Jest is Mocha
    • Jasmine is one of the tools often used to extend Mocha
    • Chai is another one of them
    • And so is Sinon

results matching ""

    No results matching ""