Тестирование React-Redux приложения

в 15:12, , рубрики: ECMAScript, enzyme, javascript, jest, ReactJS, redux, tutorial

image

Время чтения: 13 минут

Много ли вы видели react разработчиков, которые покрывают свой код тестами? А вы-то тестируете свои? Действительно, зачем, если мы можем предсказать состояние компонента и стора? Ответ довольно прост: чтобы избежать ошибок при изменениях в проекте.

Всех, кого заинтересовало, приглашаю под кат.

Клонируйте репозиторий проект в котором будути написаны тесты или попробуйте написать тесты для своего проекта.

Это будет очень простой проект.

Приложение выглядит так:

image

И умеет только складывать и вычитать числа, но с помощью react-redux связки.

Почему я и не только выбрают jest

А кто эти не только? Вот, что пишут в блоге Jest.

We feel incredibly humbled that 100+ companies have adopted Jest in the last six months. Companies like Twitter, Pinterest, Paypal, nytimes, IBM (Watson), Spotify, eBay, SoundCloud, Intuit, FormidableLabs, Automattic, Trivago and Microsoft have either fully or partially switched to Jest for their JavaScript testing needs.

Большим компаниям нравится простота Jest. Вот почему его любят:

  • Вам потребуется минимальное время на установку и настройку Jest
  • Возможность запускать тесты параллельно. (В этом примере это не так принципиально, но в больших приложениях скорость тестов играет большую роль)
  • snapshot тестирование — это действительно замечательная возможность, благодаря которой можно сократить написание некоторых тестов, поскольку создается snapshot, и если что-то изменится в компоненте, будет выведена ошибку, когда snapshot будет сгенерирован в следующий раз.
  • Jest использует Jasmine для assertion(сопоставления)
  • Возможность посмотреть покрытие тестами из коробки

Когда Jest только появился он работал не очень быстро и был спроектирован не очень хорошо, но в 2016 году Facebook сделал огромную работу по улучшению Jest и, я думаю, в ближайшее время он станет довольно популярным.

Настройка проекта

Давайте разберемся, какие нам нужны зависимости для запуска тестов.

  • jest — думаю понятно зачем
  • babel-jest — для транспиляции современного JS в более старый.
  • enzyme — для более удобного управления и рендера наших компонентов. (библиотека разработана airbnb )
  • react-addons-test-utils — как зависимость для enzyme
  • react-test-renderer  — позволяет делать рендерить компонент в разметку для snapshot
  • redux-mock-store — для того чтобы создавать mock для store

И это все, что вам нужно.

Запуск тестов

Добавим в package.json в scripts ”test”: “jest”. И теперь мы можем запускать тесты с помощью команды yarn test или npm test.

Jest проанализирует папку __test__ и выполнит все файлы в названии которых есть .test.js or .spec.js

После того, как мы все напишем, вот что получится.

image

  • Home.spec.js — У этого компонента есть дочерние компоненты и он подключен к redux.
  • calculatorActions.spec.js — Это юнит тест для action
  • calculatorReducers.spec.js — Это юнит тест для reducer.

Теперь давайте напишем пару тестов.

Понимание наших тестов и их создание

Существует много нежелательных тестовых примеров, которые просто проверяют наличие элемента DOM. Это на самом деле не нужно, и я просто оставил их чтобы вы знали как это делать, если есть такая задача, чтобы вы справились с этим тестом. Но я бы не советовал писать такие тесты.

Я остановлюсь только на важных тестах для каждого раздела, которые вам нужно знать. Остальное вы можете просто прочитать в документациях.

1. Component/Connected Component(Home.spec.js)

Что значит Connected component? Это компонент в котором используется connect для связи с redux. Если вы посмотрите код компонента Home, вы увидите там два export'а.

import React from 'react';
import ReactDOM from 'react-dom';
import { connect } from 'react-redux';
import {
  addInputs,
  subtractInputs,
  async_addInputs
} from '../actions/calculatorActions';

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

export class Home extends React.Component{
  render(){
    ...
  }
}

export default connect(mapStateToProps, {
  addInputs,
  subtractInputs,
  async_addInputs
})(Home);

Первый экспорт нужен для так называемых 'глупых компонентов' и export default нужен для connected/smart component. И мы будем тестировать оба варианта для начала компонент, который не получает значения извне.

А с connect компонентом мы будет тестировать react-redux часть.

Не используйте декораторы для кода который собираетесь тестировать

@connect(mapStateToProps)
export default class Home extends React.Component{
...

1.1 Глупый компонент

Импортируем глупый компонент (компонент без connect).

import { Home } from '../src/js/components/Home'
import { shallow } from 'enzyme'
// и напишем наш тест для компонента

*********************************
describe('>>>H O M E --- Shallow Render REACT COMPONENTS',()=>{
    let wrapper
     const output = 10

    beforeEach(()=>{
        wrapper = shallow(<Home output={output}/>)
        
    })

    it('+++ render the DUMB component', () => {
       expect(wrapper.length).toEqual(1)
    });
      
    it('+++ contains output', () => {
        expect(wrapper.find('input[placeholder="Output"]').prop('value')).toEqual(output)
    });
    
});

Мы используем shallow рендер из enzyme, потому что нам нужно получить только react объект компонента.

Подробно разберем следующий фрагмент

beforeEach(()=>{       
   wrapper = shallow(<Home output={output}/>)    
})

Он означает, что перед каждым выполнением функции it() мы будем выполнять функцию, которая передана в beforeEach(), тем самым получая каждый раз обновленный компонент, как будто он отрендерился впервые.

Обратите внимание, что в Home.js наше поле вывода ожидает this.props.output, поэтому нам нужно передать prop во время тестирования.

<div>
  Результат : <span id="output">{this.props.output}</span>
</div>

1.2 Умные компоненты

Теперь кое что поинтереснее, импортируем наш умный компонент в тест. Теперь импорт выглядит так.

import ConnectedHome, { Home } from '../src/js/components/Home'

И так же мы будем использовать redux-mock-store.

import configureStore from 'redux-mock-store'

Сейчас рассмотрим два варианта тестирования умных компонентов. Вам нужно будет выбрать, какой больше нравится вам.

// Вставляем store прямиком в компонент
//*****************************************************************************
describe('>>>H O M E --- REACT-REDUX (Shallow + passing the {store} directly)',()=>{
    const initialState = { output:100 };
    const mockStore = configureStore();
    let store,container;

    beforeEach(()=>{
        store = mockStore(initialState);
        container = shallow(<ConnectedHome store={store} /> );
    })

    it('+++ render the connected(SMART) component', () => {
       expect(container.length).toEqual(1);
    });

    it('+++ check Prop matches with initialState', () => {
       expect(container.prop('output')).toEqual(initialState.output);
    });

В этом тесте мы проверяем соответствует ли initialState, которое получается компонент через mapStateToProps.

// Оборачиваем умный компонент в Provider и нужно полностью отрендерить компонент.
//*****************************************************************************
describe('>>>H O M E --- REACT-REDUX (Mount + wrapping in <Provider>)',()=>{
    const initialState = { output:10 };
    const mockStore = configureStore();
    let store,wrapper;

    beforeEach(()=>{
        store = mockStore(initialState);
        wrapper = mount( <Provider store={store}><ConnectedHome /></Provider> );
    })


    it('+++ render the connected(SMART) component', () => {
       expect(wrapper.find(ConnectedHome).length).toEqual(1);
    });

    it('+++ check Prop matches with initialState', () => {
       expect(wrapper.find(Home).prop('output')).toEqual(initialState.output);
    });

    it('+++ check action on dispatching ', () => {
        let action;
        store.dispatch(addInputs(500));
        store.dispatch(subtractInputs(100));
        action = store.getActions();
        expect(action[0].type).toBe("ADD_INPUTS");
        expect(action[1].type).toBe("SUBTRACT_INPUTS");
    });

});

Если посмотреть на код, то мы делаем тоже самое, что и в первом тесте, плюс я еще дописал пару других сравнений, но их можно реализовать и в первом тесте.
В первом и втором варианте мы используем mock store и поэтому мы не можем зафиксировать изменения, но мы можем использовать настоящий store, без дополнительных библиотек.

//*******************************************************************************************************
describe('>>>H O M E --- REACT-REDUX (actual Store + reducers) more of Integration Testing',()=>{
    const initialState = { output:10 };
    let store,wrapper;
    beforeEach(()=>{
      store = createStore(calculatorReducers);
      wrapper = mount( <Provider store={store}><ConnectedHome /></Provider> );
    })
    it('+++ check Prop matches with initialState', () => {
      store.dispatch(addInputs(500));
      expect(wrapper.find(Home).prop('output')).toBe(500);
    });
});

Но так не рекомендуется делать, ведь это не часть unit тестирования.

1.3 Snapshot'ы

Еще одна вещь которую я люблю в Jest это snapshot testing(тестирование снэпшотов).
Когда jest сравнивает snapshot первый раз, когда их нет, он кладет их в папку __snapshots__ рядом с вашим тестируемым файлом. Для того, чтобы сделать snapshot нам нужно для начала отрендерить компонент, для этого импортируем библиотеку react-test-renderer.

import renderer from 'react-test-renderer'
// После чего сравнить snapshot
describe('>>>H O M E --- Snapshot',()=>{
    it('+++capturing Snapshot of Home', () => {
        const renderedValue =  renderer.create(<Home output={10}/>).toJSON()
        expect(renderedValue).toMatchSnapshot();
    });
});

Вот как выглядит snapshot для нашего компонента Home.js

exports[`>>>H O M E --- Snapshot +++capturing Snapshot of Home 1`] = `
<div
  className="container">
  <h2>
    using React and Redux
  </h2>
  <div>
    Input 1:
    <input
      placeholder="Input 1"
      type="text" />
  </div>
  <div>
    Input 2 :
    <input
      placeholder="Input 2"
      type="text" />
  </div>
  <div>
    Output :
    <input
      placeholder="Output"
      readOnly={true}
      type="text"
      value={10} />
  </div>
  <div>
    <button
      id="add"
      onClick={[Function]}>
      Add
    </button>
    <button
      id="subtract"
      onClick={[Function]}>
      Subtract
    </button>
  </div>
  <hr />
</div>
`;

И если мы что то изменим в файле Home.js и попробуем запустить тест получим ошибку.

image

Для того чтобы обновить snapshot'ы нужно запустить тесты с флагом -u

jest test -u || yarn test -u

Благодаря этому нам не нужно тратить много времени на тестирование, ведь если snapshot не совпадает то это значит, что мы получим ошибку при сравнении snapshots. Snapshot не содержить props и state вашего компонента, если вам нужно протестировать их в компоненте то придется создавать два экземпляра.

Создавать snapshot можно не только для компонента, но и для reducer'а, что очень удобно.
К примеру, напишем такой тест.

import reducer from './recipe';

describe('With snapshots ', () => {
  it('+++ reducer with shapshot', () => {
    expect(calculatorReducers(undefined, { type: 'default' })).toMatchSnapshot();
  });

  it('+++ reducer with shapshot', () => {
    const action = {
      type: 'ADD_INPUTS',
      output: 50,
    };
    expect(calculatorReducers(undefined, action)).toMatchSnapshot();
  });
});

Получим следующее:

image

Теперь вы понимаете почему я упомянул это вначале.

2. ActionCreators(calculatorActions.spec.js)

Мы просто сравним, что возвращает ActionCreators с тем, что должно быть.

import { addInputs,subtractInputs } from '../src/js/actions/calculatorActions'
describe('>>>A C T I O N --- Test calculatorActions', ()=>{
    it('+++ actionCreator addInputs', () => {
        const add = addInputs(50)
        expect(add).toEqual({ type:"ADD_INPUTS", output:50 })
    });
    it('+++ actionCreator subtractInputs', () => {
        const subtract = subtractInputs(-50)
        expect(subtract).toEqual({ type:"SUBTRACT_INPUTS", output:-50 })
    });
});

3. Reducers(calculatorReducers.spec.js)

Так же просто как actionCreators мы тестируем reducers.

import calculatorReducers from '../src/js/reducers/calculatorReducers'
describe('>>>R E D U C E R --- Test calculatorReducers',()=>{
    it('+++ reducer for ADD_INPUT', () => {
        let state = {output:100}
        state = calculatorReducers(state,{type:"ADD_INPUTS",output:500})
        expect(state).toEqual({output:500})
    });
    it('+++ reducer for SUBTRACT_INPUT', () => {
        let state = {output:100}
        state = calculatorReducers(state,{type:"SUBTRACT_INPUTS",output:50})
        expect(state).toEqual({output:50})
    });
});

Async action

Одно из самых важных это тестирование асинхронных действий или действий с side эффектом.
Для асинхронных действий мы будем использовать redux-thunk. Давайте посмотрим как выглядит наше асинхронное действие.

export const async_addInputs = output => dispatch =>
  new Promise((res, rej) => {
    setTimeout(() => res(output), 3000);
  }).then(res => dispatch(addInputs(res)));

А теперь самое время написать для этого jest тест. Нужно импортировать все то, что нужно.

import {
  addInputs,
  subtractInputs,
  async_addInputs
} from '../src/js/actions/calculatorActions';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
const mockStore = configureMockStore([ thunk ]);

Теперь напишем наш тест.

describe('>>>Async action --- Test calculatorActions', () => {
  it('+++ thunk async_addInputs', async () => {
    const store = mockStore({ output: 0 });
    await store.dispatch(async_addInputs(50));
    expect(store.getActions()[0]).toEqual({ type: 'ADD_INPUTS', output: 50 });
  });
});

Посмотрите на функцию async_addInputs, мы дожидаемся окончания действия и возвращаем ответ, который нам нужно протестировать(В данном случае успешный). Тем самым мы можем утверждать, что было вызвано всего одно и только одно действие, а это: ADD_INPUTS.

Пойдем дальше и можно в тестах проверить примитивную систему кеширования.
(просто пример не из проекта)

it('does check if we already fetched that id and only calls fetch if necessary', () => {
  const store = mockStore({id: 1234, isFetching: false }});
  window.fetch = jest.fn().mockImplementation(() => Promise.resolve());  
  
  store.dispatch(fetchData(1234)); // Same id
  expect(window.fetch).not.toBeCalled();

  store.dispatch(fetchData(1234 + 1)); // Different id
  expect(window.fetch).toBeCalled();
});

Как видно выше, id 1234 уже есть в store и нам больше не нужно получать данные с запросом на данный id.

Здесь мы рассмотрели самые базовые тесты для асинхронных действий. Так же в вашем приложении могут быть с другими side эффектами. Например работа с базой напрямую, как firebase или работа с другими api напрямую.

Code coverage

Отчет о покрытии тестами поддерживается из коробки. Для того, что бы увидеть статистику нужно запустить тесты с флагом --coverage

yarn test -- --coverage  || npm test -- --coverage

Выглядеть он будет примерно так:

image

Если проверить папку с проектом, можно будет обнаружить, папку coverage, где лежит файл index.html в котором отчет для отображения в браузере.

image

Нажмите на файл и посмотрите подробную статистику по покрытию тестами.

Вот и все основные моменты в тестировании на jest. Все остальное вы уже после освоения этого можете посмотреть в документации, для того чтобы расширить свой кругозор по тестированию.

Автор: merrick_krg

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js