Сага о тестировании: библиотека redux-saga-test-plan

Фреймворк redux-saga предоставляет кучу интересных паттернов для работы с сайд-эффектами, но, как истинные кроваво-энтерпрайзные разработчики, мы должны покрывать весь свой код тестами. Давайте разберёмся, как мы будем тестировать наши саги.

boexix6wnmkabte_okdbvippk2i.png
Возьмем простейший кликер в качестве примера. Поток данных и смысл приложения будет таким:

  1. Юзер тыкает в кнопку.
  2. На сервер отправляется запрос, сообщающий, что юзер тыкнул в кнопку.
  3. Сервер возвращает количество сделанных кликов.
  4. В стейт записывается количество сделанных кликов.
  5. Обновляется UI, и юзер видит, что количество кликов увеличилось.
  6. PROFIT.


В работе мы используем Typescript, поэтому все примеры будут именно на этом языке.

Как вы уже, наверное, догадались, реализовывать это всё мы будем с помощью redux-saga. Приведу здесь код файла с сагами целиком:

export function* processClick() {
    const result = yield call(ServerApi.SendClick)
    yield put(Actions.clickSuccess(result))
}

export function* watchClick() {
    yield takeEvery(ActionTypes.CLICK, processClick)
}


В этом простом примере мы объявляем сагу processClick, которая непосредственно обрабатывает action и сагу watchClick, которая создаёт цикл обработки action’ов.

Генераторы


Итак, у нас есть простейшая сага. Она отправляет запрос на сервер (эффект call), получает результат и передаёт его в reducer (эффект put). Нам нужно каким-то образом протестировать, передаёт ли сага именно то, что получает от сервера. Приступим.

Для тестирования нам понадобится замокать серверный вызов и каким-то образом проверить, ушло ли в редюсер именно то, что пришло с сервера.

Так как саги — это функции-генераторы, самым очевидным путем для тестирования будет метод next(), который есть в прототипе генератора. При использовании этого метода у нас есть возможность как получать очередное значение из генератора, так и передавать значение в генератор. Таким образом мы из коробки получаем возможность мокать вызовы. Но всё ли так радужно? Вот тест, который я написал на голых генераторах:

it('should increment click counter (behaviour test)', () => {
    const saga = processClick()

    expect(saga.next().value).toEqual(call(ServerApi.SendClick))
    expect(saga.next(10).value).toEqual(put(Actions.clickSuccess(10)))
})


Тест получился лаконичным, но что он тестирует? По сути, он просто повторяет код метода саги, то есть при любом изменении саги придется менять и тест.

Такой тест ничем не помогает в разработке.


Redux-saga-test-plan


После столкновения с этой проблемой, мы решили погуглить и внезапно поняли, что мы такие не единственные и далеко не первые. Прямо в документации к redux-saga разработчики предлагают взглянуть на несколько библиотек, созданных специально для удовлетворения фанатов тестирования.

Из предложенного списка мы взяли библиотеку redux-saga-test-plan. Вот код первой версии теста, который я написал с её помощью:

it('should increment click counter (behaviour test with test-plan)', () => {
    return expectSaga(processClick)
        .provide([
            call(ServerApi.SendClick), 2]
        ])

        .dispatch(Actions.click())

        .call(ServerApi.SendClick)
        .put(Actions.clickSuccess(2))

        .run()
    })


Конструктором теста в redux-saga-test-plan является функция expectSaga, возвращающая интерфейс, которым описывается тест. В саму функцию передаётся тестируемая сага (processClick из первого листинга).

С помощью метода provide можно замокать вызовы сервера или другие зависимости. В неё передаётся массив из StaticProvider’ов, которые описывают какой метод что должен возвращать.

В блоке Act у нас один единственный метод — dispatch. В него передаётся action, на который будет реагировать сага.

Блок assert состоит из методов call и put, проверяющих были ли в ходе работы саги вызваны соответствующие эффекты.

Заканчивается это всё методом run(). Этот метод непосредственно запускает тест.

Плюсы такого подхода:
  • проверяется, был ли вызван метод, а не последовательность вызовов;
  • моки явно описывают, какая функция мокается и что возвращается.

Однако есть над чем поработать:
  • кода стало больше;
  • тест сложно читать;
  • это тест на поведение, а значит он всё-таки связан с реализацией саги.


Два последних штриха


Тест на состояние


Сначала исправим последнее: сделаем из теста на поведение тест на состояние. В этом нам поможет тот факт, что test-plan позволяет задать начальный state и передать reducer, который должен реагировать на эффекты put, порождаемые сагой. Выглядит это так:

it('should increment click counter (state test with test-plan)', () => {
    const initialState = {
        clickCount: 11,
    

    return expectSaga(processClick)
        .provide([
            call(ServerApi.SendClick), 14]
        ])
        .withReducer(rootReducer, initialState)

        .dispatch(Actions.click())

        .run()
        .then(result => expect(result.storeState.clickCount).toBe(14))
})


В этом тесте мы уже не проверяем, что были вызваны какие-либо эффекты. Мы проверяем итоговый стейт после выполнения, и это прекрасно.

Нам удалось отвязаться от реализации саги, теперь попробуем сделать тест более понятным. Это легко, если заменить then() на async/await:

it('should increment click counter (state test with test-plan async-way)', async () => {
    const initialState = {
        clickCount: 11,
    }    

    const saga = expectSaga(processClick)
        .provide([
            call(ServerApi.SendClick), 14]
        ])
        .withReducer(rootReducer, initialState)

    const result = await saga.dispatch(Actions.click()).run()

    expect(result.storeState.clickCount).toBe(14)
})


Интеграционные тесты


А что если у нас появилась ещё и обратная клику операция (назовём её unclick), и теперь наш файл с сагами выглядит вот так:

export function* processClick() {
    const result = yield call(ServerApi.SendClick)
    yield put(Actions.clickSuccess(result))
}

export function* processUnclick() {
    const result = yield call(ServerApi.SendUnclick)
    yield put(Actions.clickSuccess(result))
}

function* watchClick() {
    yield takeEvery(ActionTypes.CLICK, processClick)
}

function* watchUnclick() {
    yield takeEvery(ActionTypes.UNCLICK, processUnclick)
}

export default function* mainSaga() {
    yield all([watchClick(), watchUnclick()])
}


Допустим, нам нужно протестировать, что при последовательном вызове action«ов click и unclick в state запишется результат последнего похода на сервер. Такой тест также можно легко сделать с помощью redux-saga-test-plan:

it('should change click counter (integration test)', async () => {
    const initialState = {
        clickCount: 11,
    }            

    const saga = expectSaga(mainSaga)
        .provide([
            call(ServerApi.SendClick), 14],
            call(ServerApi.SendUnclick), 18]
        ])
        .withReducer(rootReducer, initialState)

    const result = await saga
        .dispatch(Actions.click())
        .dispatch(Actions.unclick())
        .run()

    expect(result.storeState.clickCount).toBe(18)
})


Обратите внимание, теперь мы тестируем mainSaga, а не отдельные обработчики action«ов.

Однако, если мы запустим этот тест как есть, то получим ворнинг:

j3-kfzdcrzhhsnhlapzuecdts2m.png

Это происходит из-за эффекта takeEvery — это цикл обработки сообщений, который будет работать, пока открыто наше приложение. Соответственно, тест, в котором вызывается takeEvery не сможет без посторонней помощи завершить работу, и redux-saga-test-plan принудительно завершает работу таких эффектов через 250 мс после начала теста. Этот таймаут можно изменить с помощью вызова expectSaga.DEFAULT_TIMEOUT = 50.

Если же вы не хотите получать такие ворнинги по одному на каждый тест со сложным эффектом, просто используйте вместо метода run() метод silentRun().



Подводные камни


Куда же без подводных камней… На момент написания этой статьи, последняя версия redux-saga: 1.0.2. В то же время redux-saga-test-plan пока умеет работать с ней только на JS.

Если хотите TypeScript, придется ставить версию из beta-канала:
npm install redux-saga-test-plan@beta
и выключить из билда тесты. Для этого в файле tsconfig.json нужно прописать путь »./src/**/*.spec.ts» в поле «exclude».

Несмотря на это, мы считаем redux-saga-test-plan самой лучшей библиотекой для тестирования redux-saga. Если у вас в проекте есть redux-saga, возможно, она станет для вас хорошим выбором.

Исходный код примера на GitHub.

© Habrahabr.ru