REACTIVE MOVEMENT

SETUP

TESTING ENGINE

$ npm install --save-dev mocha
describe('Component', () => {
    beforeEach(() => {
        // setup env before running tests
    });

    it('should', () => {
        // setup test case expectation
    });

    afterEach(() => {
        // clean env before running tests
    });
});

ASSERTION LIBRARY

$ npm install --save-dev chai
import chai, { expect } from 'chai';

describe('Component', () => {
    beforeEach(() => {
        // setup env before running tests
    });

    it('should', () => {
        expect('racing').to.not.equal('post');
    });

    afterEach(() => {
        // clean env before running tests
    });
});

SPY ABILITY

$ npm install --save-dev sinon
$ npm install --save-dev sinon-chai
import chai, { expect } from 'chai';
import sinonChai from 'sinon-chai';

chai.use(sinonChai);

describe('Component', () => {
    it('should', () => {
        const = callback = sinon.spy();
        const proxy = sut(callback);    
        proxy();
        expect(callback).calledWith();
    });
});

ENZYME RENDERING

$ npm install --save-dev enzyme
$ npm install --save-dev chai-enzyme
import React from 'react';
import chai, { expect } from 'chai';
import chaiEnzyme from 'chai-enzyme';
import { shallow } from 'enzyme';
import Component from 'path/to/Component/';

chai.use(chaiEnzyme());

describe('Component', () => {
    it('should', () => {
        const wrapper = shallow(<Component />);
        expect(wrapper.find('.class')).be.present();
    });
});

RUN CONFIGURATION

$ npm install --save-dev babel-register
{
  ...
  "scripts": {
    ...
    "test": "mocha --compilers js:babel-register --recursive",
    "test:watch": "npm test -- --watch"
  },
  ...
}
package.json

CODE

ACTION CREATORS

import * as actions from './ActionTypes';

export function hide() {
    return {
        type: actions.HIDE_PROMO
    };
}
/* Promo actions */
export const HIDE_PROMO = 'HIDE_PROMO';
ActionTypes.js
PromoActions.js

ACTION CREATORS

PromoActions.spec.js
import { expect } from 'chai';
import * as actions from 'path/to/actions/PromoActions';
import * as types from 'path/to/actions/ActionTypes';

describe('Promo actions', () => {
    it('should create a hide action', () => {
        const expectedAction = {
            type: types.HIDE_PROMO
        };
        expect(actions.hide()).to.deep.equal(expectedAction);
    });
});

ASYNC CREATORS

import * as actions from './ActionTypes';

function receiveHorses(horses) {
    return {
        type: actions.RECEIVE_HORSES,
        horses
    };
}

export function fetchHorses(url) {
    return dispatch => (
        fetch(`/horse-tracker/data/${url}`, { credentials: 'include' })
            .then(response => (response.json()))
            .then(json => (dispatch(receiveHorses(json.data))))
    );
}
/* Tracked actions */
export const RECEIVE_HORSES = 'RECEIVE_HORSES';
ActionTypes.js
TrackedActions.js

ASYNC CREATORS

TrackedActions.spec.js
import chai, { expect } from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import fetchMock from 'fetch-mock';
import * as actions from 'path/to/actions/TrackedActions';
import * as types from 'path/to/actions/ActionTypes';

chai.use(sinonChai);

describe('Tracked actions', () => {
    let thunk;
    const url = 'testUrl';
    const fakeData = [
        { testKey: 'testValue' }
    ];

    beforeEach(() => {
        thunk = actions.fetchHorses(url);
    });

    it('should fetch horses data', (done) => {
        fetchMock.mock({
            name: 'success',
            matcher: `/horse-tracker/data/${url}`,
            response: {
                success: true,
                data: fakeData
            }
        });

        const dispatchSpy = sinon.spy();
        const expectedAction = {
            type: types.RECEIVE_HORSES,
            horses: fakeData
        };

        thunk(dispatchSpy)
            .then(() => {
                expect(dispatchSpy).calledWith(expectedAction);
            })
            .then(done)
            .catch(done);
    });
});

REDUCERS

import * as actions from 'path/to/actions/ActionTypes';

export default function promoReducer(state = {}, action) {
    switch (action.type) {
        case actions.HIDE_PROMO:
            return {
                ...state,
                isHidden: true
            };
        default:
            return state;
    }
}
/* Promo actions */
export const HIDE_PROMO = 'HIDE_PROMO';
ActionTypes.js
PromoReducer.js

REDUCERS

PromoReducer.spec.js
import { expect } from 'chai';
import reducer from 'path/to/reducers/PromoReducer';
import * as types from 'path/to/actions/ActionTypes';

describe('Promo reducer', () => {
    it('should return the initial state by default', () => {
        expect(
            reducer(undefined, {})
        ).to.deep.equal(
            {}
        );
    });

    it('should handle HIDE_PROMO action type', () => {
        expect(
            reducer({}, {
                type: types.HIDE_PROMO
            })
        ).to.deep.equal({
            isHidden: true
        });
    });
});

COMPONENTS

import './styles.scss';

import React, { PropTypes } from 'react';
import Svg from 'svg';

const Promo = ({ promo, onHide }) => (
    promo.isHidden ? null : (
        <div className="ht-promo cf">
            <img
                src="img/promo.png"
                alt="Horse Tracker Promo Message"
                className="ht-promo__image"
            />
            <div className="ht-promo__title">Never miss a trick</div>
            <span className="ht-promo__close" onClick={onHide}>
                <Svg
                    iconName="crossFull"
                    size="Large"
                />
            </span>
        </div>
    )
);

Promo.propTypes = {
    promo: PropTypes.object.isRequired,
    onHide: PropTypes.func.isRequired
};

export default Promo;
PromoComponent.js

CONTAINERS

import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';

import * as actions from 'path/to/actions/TrackedActions';
import Tracked from 'path/to/components/Tracked/';

export class TrackedContainer extends Component {
    componentDidMount() {
        const { fetchHorses } = this.props;
        fetchHorses('tracked');
    }

    render() {
        const { tracked } = this.props;
        return (
            <Tracked tracked={tracked} />
        );
    }
}

TrackedContainer.propTypes = {
    tracked: PropTypes.object.isRequired,
    fetchHorses: PropTypes.func.isRequired
};

const mapStateToProps = ({ tracked }) => ({ tracked });

export default connect(
    mapStateToProps,
    { fetchHorses: actions.fetchHorses }
)(TrackedContainer);
TrackedContainer.js

CONTAINERS

import React from 'react';
import chai, { expect } from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import proxyquire from 'proxyquire';

chai.use(sinonChai);

describe('Tracked container', () => {
    let TrackedContainer;
    let reactRedux;
    let wrapWithConnect;

    beforeEach(() => {
        wrapWithConnect = sinon.stub().returns({});

        reactRedux = {
            connect: sinon.stub().returns(wrapWithConnect)
        };

        const TrackedActions = {
            fetchHorses: sinon.spy()
        };

        const Tracked = {
            default: Symbol()
        };

        TrackedContainer = proxyquire('path/to/containers/Tracked', {
            'react-redux': reactRedux,
            'path/to/actions/TrackedActions': TrackedActions,
            'path/to/components/Tracked/': Tracked,
            react: React
        }).TrackedContainer;
    });

    it('should fetch horses on component mounted', () => {
        const fetchHorses = sinon.spy();
        const sut = new TrackedContainer();

        sut.props = {
            fetchHorses
        };
        sut.componentDidMount();
        expect(fetchHorses).calledWith('tracked');
    });
});
TrackedContainer.spec.js

KEEP GOING

Reactive Movement

By Roman Stremedlovskyi

Reactive Movement

Test-Driven Development in Reactive projects

  • 1,232