Pull to refresh

Модульное и интеграционное тестирование в Redux Saga на примерах

Reading time9 min
Views4.9K

hero image


Redux — чрезвычайно полезный менеджер состояний. Среди многих "плагинов", Redux-Saga нравится мне больше всего. В проекте на React-Native, над которым я сейчас работаю, мне приходилось сталкиваться с множеством побочных эффектов. Они приносили бы мне головные боли в случае, если я поместил их в компоненты. С помощью этого инструмента создание сложных логических потоков с разветвлениями становится простой задачей. Но как насчет тестирования? Так же это просто, как и использование библиотеки? Хотя я не могу дать вам точный ответ, я покажу вам реальный пример проблем, с которыми я столкнулся.


Если вы не знакомы с тестированием саг, я рекомендую прочитать отдельную страницу в документации. В следующих примерах я использую redux-saga-test-plan, поскольку эта библиотека дает полную силу интеграционного тестирования наряду с модульным тестированием.


Немного о модульном тестировании


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


Забегая на перед я бы сказал, что пока не видел смысла модульного тестирования в своем проекте. Я перенес всю бизнес-логику и абстракции API во внешние модули, оставляя сагам только управление потоком приложения. Таким образом, у меня не было огромных саг, которые я не мог бы разделить на более мелкие, ясно видя при этом, что они делают (благо конструкции получаются наглядными).

// Только самые важные импорты
import {call, put, take} from "redux-saga/effects";

export function* initApp() {
    // Инициализация хранилища и 
    // получение последней сохраненной сессии
    yield put(initializeStorage());
    yield take(STORAGE_SYNC.STORAGE_INITIALIZED);

    yield put(loadSession());
    let { session } = yield take(STORAGE_SYNC.STORAGE_SESSION_LOADED);

    // Загрузка последнего проекта
    if (session) {
        yield call(loadProject, { projectId: session.lastLoadedProjectId });
    } else {
        logger.info({message: "No session available"});
    }
}

// Только самые важные импорты
import {testSaga} from "redux-saga-test-plan";

it("должно загрузить последнюю сессию и вызвать `loadProject`", () => {
    const projectId = 1;
    const mockSession = {
        lastLoadedProjectId: projectId
    };

    testSaga(initApp)
        // `next` служит командой перехода на следующую конструкцию `yield`
        // при этом мы можем передать аргумент,
        // который будет выходным результатом действующего `yield`

        // Фактически он просто запускает новую итерацию 
        //(не забываем что функции-генераторы возвращают итератор)
        .next()
        .put(initializeStorage())

        .next()
        .take(STORAGE_SYNC.STORAGE_INITIALIZED)

        .next()
        .put(loadSession())

        .next()
        .take(STORAGE_SYNC.STORAGE_SESSION_LOADED)

        // Сохранение метки, чтобы иметь возможность вернуться
        .save("до развилки")

        // Передаем объект, который будет возвращен из `yield take...`
        .next({session: mockSession})
        .call(loadProject, {projectId})

        .next()
        .isDone()

        // Возвращаемся назад к метке
        .restore("до развилки")

        // Проверяем, что функция сразу завершится,
        // если последняя сессия будет отсутствовать
        .next({})
        .isDone();
});

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


Так как мы завершили, давайте перейдем к основному блюду!


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


Существенными недостатками модульного тестирования являются внешние вызовы. Вы должны заменять их, иначе это будут не модульные тесты. В случае, если ваши саги состоят только из этих вызовов и не имеют никакой логики, пошаговое тестирование, подменяя все зависимости, становится бессмысленным занятием. Но что, если мы хотим проверить корректность потока исполнения, не имея дело с каждым из эффектов отдельно? Что делать, если нам нужно протестировать саги в контексте состояния системы с использованием модификаторов состояния (reducers)? У меня отличные новости, именно этим я и хотел поделиться с вами.


Тестирование дерева саг


Давайте рассмотрим следующий пример, который является адаптированной версией кода из моего проекта:


// Только самые важные импорты
import {call, fork, put, take, takeLatest, select} from "redux-saga/effects";

// Корневая сага
export default function* sessionWatcher() {
    yield fork(initApp);
    yield takeLatest(SESSION_SYNC.SESSION_LOAD_PROJECT, loadProject);
}

export function* initApp() {
    // Инициализация хранилища и получение последней сохраненной сессии
    yield put(initializeStorage());
    yield take(STORAGE_SYNC.STORAGE_INITIALIZED);

    yield put(loadSession());
    let { session } = yield take(STORAGE_SYNC.STORAGE_SESSION_LOADED);

    // Загрузка последнего проекта
    if (session) {
        yield call(loadProject, { projectId: session.lastLoadedProjectId });
    } else {
        logger.info({message: "Нет доступной сессии"});
    }
}

export function* loadProject({ projectId }) {
    // Загрузка последнего проекта и последующая попытка его обработки
    yield put(loadProjectIntoStorage(projectId));
    const project = yield select(getProjectFromStorage);

    // Сохранение проекта, сессии с ново загрузившимся проектом и загрузка карты
    try {
        yield put({type: SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC, project});
        yield fork(saveSession, projectId);
        yield put(loadMap());
    } catch(error) {
        yield put({type: SESSION_SYNC.SESSION_ERROR_WHILE_LOADING_PROJECT, error});
    }
}

export function getProjectFromStorage(state) {
    // Вытягивает загруженный проект из состояния системы
}

export function* saveSession(projectId) {
    // .... Вызывает внешние API
    yield call(console.log, "Вызов API...");
}

Здесь у нас есть корневая сага sessionWatcher, которая инициализирует приложение, вызывая initApp сразу после загрузки, а также ожидает команды по загрузке проекта по id. Проект загружается из хранилища, после чего мы сохраняем проект в состоянии системы и вызываем другую сагу, которая сохраняет сеанс и загружает карту. В примере показаны всевозможные проблемы, с которыми мы можем столкнуться в процессе тестирования:


  • Работа с несколькими сагами
  • Доступ к состоянию
  • Вызовы API, которых мы хотели бы избежать.

// Только самые важные импорты
import { expectSaga } from "redux-saga-test-plan";
import { select } from "redux-saga/effects";
import * as matchers from "redux-saga-test-plan/matchers";

it("должно инициализировать приложение и загрузить последний проект из предыдущей сессии", () => {
    // Стадия подготовки
    const projectId = 1;
    const anotherProjectId = 2;
    const mockedSession = {
        lastLoadedProjectId: projectId,
    };
    const mockedProject = "project";

    // Тестируем `sessionWatcher`
    // `silentRun` в конце возвращает промис с некоторыми плюшками для тестирования
    // подробнее можете почитать в официальной документации
    return (
        expectSaga(sessionWatcher)
            // Подмена генераторов эффектов
            .provide([
                // Заменить каждый генератор `select` эффекта, который использует
                // `getProjectFromStorage` и на его месте вернуть `mockedProject`
                // при этом обращать внимание как на вызываемую функцию так и на аргументы,
                // которые мы можем передать в `select`,
                // в нашем случае мы не передаем никаких

                // Пример использования стандартного генератора эффектов
                // из Redux-Saga, как фильтра
                [select(getProjectFromStorage), mockedProject],

                // Заменить каждый генератор `fork` эффекта, который вызывает `saveSession` 
                // и ничего не вернуть (undefined)
                // при этом обращать внимание только на вызываемую функцию,
                // игнорирую аргументы

                // Пример использования фильтров из Redux Saga Test Plan
                [matchers.fork.fn(saveSession)],
            ])

            // Порядок не имеет значения
            // Мы упоминаем только те генераторы эффектов, которые мы хотим проверить

            // Тестирование инициализации
            .put(initializeStorage())
            .take(STORAGE_SYNC.STORAGE_INITIALIZED)
            // Генерация команды, которую в данный момент ожидает первый `take` в `initApp`
            // Генераторы команд ДОЛЖНЫ вызываться в нужном порядке
            .dispatch({ type: STORAGE_SYNC.STORAGE_INITIALIZED })

            .put(loadSession())
            .take(STORAGE_SYNC.STORAGE_SESSION_LOADED)
            .dispatch({ type: STORAGE_SYNC.STORAGE_SESSION_LOADED, session: mockedSession })

            // Тест загрузки проекта, вызванную `initApp`
            .put(loadProjectFromStorage(projectId))
            .put({ type: SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC, project: mockedProject })
            .fork(saveSession, projectId)
            .put(loadMap())

            // Генерируем команду, которая будет перехвачена `takeLatest` в `sessionWatcher`
            // и опять протестировать загрузку проекта
            // Тест загрузки проекта, вызванную `sessionWatcher`
            .dispatch({ type: SESSION_SYNC.SESSION_LOAD_PROJECT, projectId: anotherProjectId })
            .put(loadProjectFromStorage(anotherProjectId))
            .put({ type: SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC, project: mockedProject })
            .fork(saveSession, anotherProjectId)
            .put(loadMap())

            // Запустить тест, подавить ошибку связанную с истечением времени
            .silentRun()
    );
});

Приведенный выше тест проверяет все саги сразу. Первая часть инициализирует объекты, которые мы будем использовать для тестирования наших саг, вторая — само тестирование. Мы вызываем функцию waitSaga, которая запустит корневую сагу и проверит ее на соответствие перечисленным ниже проверкам.


Первая функция, которую мы видим, — provide использует фильтры, чтобы найти генераторы эффектов и впоследствии заменить их. Первый кортеж (пара значений) использует генератор эффекта select из библиотеки Redux Saga и в точности соответствует эффекту, вызывающего getProjectFromStorage. Если мы хотим большей гибкости, мы можем использовать средства сопоставления, которые предоставляются библиотекой Redux Saga Test Plan. Пример этого мы можем увидеть во втором кортеже, где мы говорим, что фильтрация будет происходить по вызываемой функции saveSession, игнорируя ее аргументы. Этот механизм позволяет нам избежать использования состояния системы, подмены вызовов нежелательных функций или API.


После этого у нас появляется цепочка тестов генераторов эффектов. Обратите внимание, что нам не нужно размещать их в определенном порядке или включать все эффекты, достаточно перечислить эффекты, которые мы ожидаем увидеть. Тем не менее генераторы команд (dispatch) должны быть в нужном порядке для выполнения кода.


Цепочка заканчивается функцией silentRun, которая выполняет три вещи: запускает наш тест, подавляет ошибку тайм-аута и возвращает промис с дополнительными данными для последующих тестов, в нашем случае мы игнорируем их и просто возвращаем промис из теста.


Тестирование обработки ошибок


Чтобы смоделировать возникновение ошибки, мы можем использовать уже знакомую функцию provide вспомогательную от redux-saga-test-plan/providers, чтобы заменить генератор эффекта выбросом ошибки ошибкой.


// Только самые важные импорты
import {expectSaga} from "redux-saga-test-plan";
import {select} from "redux-saga/effects";
import * as matchers from "redux-saga-test-plan/matchers";
import * as providers from "redux-saga-test-plan/providers";

it("должно инициализировать приложение и обработать ошибку загрузки проекта", () => {
    const projectId = 1;
    const mockedSession = {
        lastLoadedProjectId: projectId
    };
    const mockedProject = "project";
    const mockedError = new Error("Оууу, кажется что-то пошло не так!");

    return expectSaga(sessionWatcher)

        .provide([
            [select(getProjectFromStorage), mockedProject],
            // Заменяем генератор эффекта ошибкой
            [matchers.fork.fn(saveSession), providers.throwError(mockedError)]
        ])

        // Тестирование инициализации
        .put(initializeStorage())
        .take(STORAGE_SYNC.STORAGE_INITIALIZED)
        .dispatch({type: STORAGE_SYNC.STORAGE_INITIALIZED})

        .put(loadSession())
        .take(STORAGE_SYNC.STORAGE_SESSION_LOADED)
        .dispatch({type: STORAGE_SYNC.STORAGE_SESSION_LOADED, session: mockedSession})

        // Тест загрузки проекта, вызванную `initApp`
        .put(loadProjectFromStorage(projectId))
        .put({type: SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC, project: mockedProject})
        // Ожидаем возникновение ошибки здесь
        .fork(saveSession, projectId)
        // Тестируем, что ошибка была обработана
        .put({type: SESSION_SYNC.SESSION_ERROR_WHILE_LOADING_PROJECT, error: mockedError})

        .silentRun();
});

Использование модификаторов состояния и самого состояния


А как же глобальное состояние приложения, как нам протестировать код, используя модификаторы (reducers). С redux-saga-test-plan это становится тривиальной задачей. Во-первых, нам нужно представить модификатор состояния:


const defaultState = {
    loadedProject: null,
};

export function sessionReducers(state = defaultState, action) {
    if (!SESSION_ASYNC[action.type]) {
        return state;
    }
    const newState = copyObject(state);

    switch(action.type) {
        case SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC: {
            newState.loadedProject = action.project;
        }
    }

    return newState;
}

Во-вторых, мы немного изменим наш тест, добавив withReducer, который позволяет нам использовать динамическое состояние (вы можете предоставить состояние без модификатора, вызвав withState). Также мы добавим тест hasFinalState, который сравнивает состояние с ожидаемым.


// Только самые важные импорты
import {expectSaga} from "redux-saga-test-plan";
import {select} from "redux-saga/effects";
import * as matchers from "redux-saga-test-plan/matchers";

it("должно инициализировать приложение и загрузить последний проект из предыдущей сессии", () => {
    const projectId = 1;
    const mockedSession = {
        lastLoadedProjectId: projectId
    };
    const mockedProject = "project";
    const expectedState = {
        loadedProject: mockedProject
    };

    return expectSaga(sessionWatcher)
        // Вы можете подключить корневой модификатор, чтобы
        // динамически изменять состояние либо указать его статически с помощью `withState`
        .withReducer(sessionReducers)

        .provide([
            [select(getProjectFromStorage), mockedProject],
            [matchers.fork.fn(saveSession)]
        ])

        // Тестирование инициализации
        .put(initializeStorage())
        .take(STORAGE_SYNC.STORAGE_INITIALIZED)
        .dispatch({type: STORAGE_SYNC.STORAGE_INITIALIZED})

        .put(loadSession())
        .take(STORAGE_SYNC.STORAGE_SESSION_LOADED)
        .dispatch({type: STORAGE_SYNC.STORAGE_SESSION_LOADED, session: mockedSession})

        // Тест загрузки проекта, вызванную `initApp`
        .put(loadProjectFromStorage(projectId))

        // Теперь мы можем пропустить некоторые тесты, которые изменяют состояние,
        // так как мы его тестируем в конце
        // .put({type: SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC, project: mockedProject})
        .fork(saveSession, projectId)
        .put(loadMap())

        // Тестирование конечного состояния
        .hasFinalState(expectedState)

        .silentRun();
});

Это была локализация моей статьи с Medium.


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

Tags:
Hubs:
Total votes 6: ↑6 and ↓0+6
Comments3

Articles