Метаморфоза тестирования redux-saga

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



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

    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’ов.

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



    Это происходит из-за эффекта 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.
    Dodo Pizza Engineering
    240,71
    О том как IT доставляет пиццу
    Поделиться публикацией

    Похожие публикации

    Комментарии 8

      +2

      Подскажите, а вот тут:


      код из статьи
      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)
      })

      мы не можем проверить промежуточный результат (14) тоже? А то получается что это по сути тест на unclick.

        +1
        Хорошее замечание. Я сам об этом думал, когда делал пример. Он, действительно, получился не очень удачным, но проверять промежуточный результат я бы не стал, т.к. в этом случае нарушится требование сфокусированности теста — у теста появится вторая причина для падения.
        Здесь два dispatch подряд можно интерпретировать как то, что мы хотим проверить, что событие click() никак не изменит то, что в итоге в стейт будет просуното то, что вернётся в последнем событии.
          +1
          сфокусированности теста — у теста появится вторая причина для падения

          Разве это не прекрасно? Это же ведь интеграционный тест.

            +1
            Философский вопрос. Я бы сказал, что не прекрасно, т.к. даже в интеграционных тестах нужно стремиться к сфокусированности. Если мы от неё отказываемся, то для этого должна быть веская причина, например низкая скорость выполнения теста. В нашем случае тест хоть и не быстрый, но еще не настолько медленный, чтобы мы не могли написать отдельный тест на каждый из кейсов.
              +1
              Философский вопрос. Я бы сказал, что не прекрасно, т.к. даже в интеграционных тестах нужно стремиться к сфокусированности.

              Сфокусированность теста обратно пропорциональна его полезности. Т.е., набор несфокусированных тестов всегда строго лучше, чем эквивалентный набор сфокусированных, т.к. отловит строго большее количество ошибок.


              Если, конечно, никаких ограничений нет (т. е. у вас есть актуально бесконечный набор тестов), то несфокусированные лучше. Но поскольку на практике набор тестов всегда конечный, то, выбирая сфокусированность, вы улучшаете метрику, которая сама по себе бесполезна, ухудшая метрику, которая как раз является основной.

                0
                Сфокусированность теста обратно пропорциональна его полезности. Т.е., набор несфокусированных тестов всегда строго лучше, чем эквивалентный набор сфокусированных, т.к. отловит строго большее количество ошибок.

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

                Я привык считать полезностью теста то, насколько он облегчает разработку. По опыту, тест, который падает на каждый чих не очень полезен, т.к. при его падении непонятно что конкретно сломалось.
                  +1
                  Я привык считать полезностью теста то, насколько он облегчает разработку.

                  Это очень расплывчатое определение, а потому бессодержательное. Можно много вещей придумать, которые упрощают разработку, следует ли из этого, что все эти вещи — хорошие тесты? Т.е. хороший тест — он упрощает разработку вполне определенным образом. Каким? Дешевое обнаружение ошибок.


                  По опыту, тест, который падает на каждый чих не очень полезен, т.к. при его падении непонятно что конкретно сломалось.

                  Здесь важно разделять два фактора, по причине которых могут падать тесты. Во-первых, хрупкость (когда тест падает из-за рефакторинга, а не из-за реальной ошибки), и, во-вторых, с-но корректное срабатывание, когда тест находит реальную ошибку. Вот во втором случае, лучше пусть тест падает на 10 реальных ошибках, и при этом не показывает, в чем конкретно эти ошибки, чем падает только на одной, а 9 уходят дальше. Ресурсов на поиск и отладку этих 9 ошибок в итоге уйдет почти наверняка сильно больше чем на уточнение их в рамках "несфокусированного" теста.
                  В случае же с хрупкостью — это отдельный вопрос, тут уже речь не о сфокусированности, а о том, чтобы не было привязки к реализации (ваш тест, например, очень хрупкий, т.к. привязан к реализации — в нем упоминаются clickSuccess и sendClick; в идеале в тесте не должно упоминаться никаких используемых в тестируемой ф-и зависимостей).

                    +1
                    По опыту, тест, который падает на каждый чих не очень полезен, т.к. при его падении непонятно что конкретно сломалось.

                    Возможно у вас идеальные условия для работы. Мне пока в таких конторах работать не доводилось. Наоборот, время на написание тестов исчезающе мало, и если тест может упасть — пусть падает, даже если он падает на каждый чих (при условии что чих требует починки). Ведь если никто не упадёт на этот чих, то его выявление и починка потребует куда большего времени. В разы большего.


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

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

        Самое читаемое