Русский
Русский
English
Статистика
Реклама

Модульное тестирование

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

29.07.2020 18:17:33 | Автор: admin

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.


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

Подробнее..

Перевод Расширяемая и удобная в сопровождении архитектура игр на Unity

26.11.2020 14:19:49 | Автор: admin

Будущих студентов курса "Unity Game Developer. Professional" приглашаем посетить открытый вебинар на тему "Продвинутый искусственный интеллект врагов в шутерах".

А пока предлагаем прочитать перевод полезной статьи.


Введение

За годы работы над множеством проектов я выработал четкий подход к структурированию игровых проектов в Unity, который зарекомендовал себя в особой степени расширяемым и удобным в сопровождении.

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

Эта статья является обновленной версией моего выступления на GDC в 2017 году (Data Binding Architectures for Rapid UI Creation in Unity).

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

Второй дисклеймер: после того, как эта статья была опубликована, читатели обратили мое внимание, что я не одинок в данном подходе, поскольку Kolibri Games также практикует нечто подобное: их статья

Архитектура

Основными целями этой архитектуры являются:

  • поддерживаемость

  • расширяемость

  • тестируемость

Эти три цели нелегко достичь в движке, который в первую очередь нацелен на быстрое прототипирование. Среди разработчиков игр распространено мнение, что эти принципы больше подходят для бизнес-решений, чем для игр, и я категорически с этим не согласен. Игры все больше и больше переплывают в парадигму программного обеспечения как сферы услуг. Обращая свой взгляд на решения в этой области, мы можем обнаружить, что существуют полезные инструменты, которые мы можем применить и к играм.

  1. Инверсия управления (inversion of control)

  2. Интерфейс передачи сообщений (MPI)

  3. Модель / представление / контроллер (MVC)

  4. Модульное тестирование (Unit testing)

Инверсия управления

Следующая диаграмма показывает, как обычно работают сильно связанные компоненты:

ClassA напрямую зависит от ServiceA/ServiceB. Это обременяет независимое тестирование ClassA необходимостью заботиться о деталях реализации этих двух служб.

Внедрение зависимостей (DI Dependency Injection) это подход к реализации инверсии управления. На следующем рисунке показан предыдущий пример с использованием внедрения зависимостей:

Внедрение зависимостей используется как строитель (Builder) для генерации нашего ClassA, проверки необходимых зависимостей и их автоматического предоставления. ClassA не заботит то, какой конкретно класс, реализующий требуемый интерфейс, используется, пока таковой имеется в наличии.

Для реализации этого паттерна мы остановились на Zenject/Extenject. Он основан на рефлексии. Используя функцию запекания рефлексий (reflection-baking), мы можем избавиться от негативного влияния рефлексии на производительность.

Модель-Представление-Контроллер

Суть этой архитектуры разбиение кода на отдельные уровни. Паттерн Модель-Представление-Контроллер (Model-View-Controller MVC), перенесенный на Unity, выглядит следующим образом:

Monobehaviour-ы Unity обитают на уровне представления (View), что, как предполагается, защищает остальную часть архитектуры от затрудняющих модульное тестирование элементов Unity. Этот уровень имеет доступ только к уровню контроллера. Представление создает инстансы префабов и использует [SerializeField] для использования типичных dragndrop компонентов Unity. Здесь не должно быть никакой игровой логики, только чистая визуализация данных.

Уровень контроллера содержит бизнес-логику и выполняет всю тяжелую работу. Этот код должен быть тестируемым, он не зависит от специфики уровня представления Unity. Но все же этот уровень не определяет способ хранения данных на уровне модели, он только контролирует изменения на нем.

Модель содержит фактические данные, они могут быть эфемерными, хранимыми на диске или в каком-либо бэкенде. Обычно модели это старые добрые, хорошо нам известные типы данных.

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

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

Передача сообщений

Вышеупомянутая архитектура полагается на соответствующих уведомлениях (notification messages), чтобы уровень представления мог подписаться и реагировать на изменения/события (events):

Мы используем Zenject Signals.

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

struct MessageType {}bus.Subscribe<MessageType>(()=>Debug.Log("Msg received"));bus.Fire<MessageType>();

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

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

Модульное тестирование

Благодаря всем вышеперечисленным ограничениям и механизмам мы теперь можем покрыть модульными тестами почти всю нашу игровую (бизнес) логику.

Для реализации технической части написания этих тестов мы используем стандартный фреймворк Unity NUnit и NSubstitute в качестве решения для создания моков.

Давайте посмотрим на один из наших тестов:

var level = Substitute.For<ILevel>();var buildings = Substitute.For<IBuildings>();// test subject: var build = new BuildController(null,buildings,level);// smoke testAssert.AreEqual(0, build.GetCurrentBuildCount());// assert that `GetCurrent` was exactly called oncelevel.ReceivedWithAnyArgs(1).GetCurrent();

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

Давайте посмотрим на более интересный пример билдинга чего-либо на слоте 0:

var level = Substitute.For<ILevel>();var bus = _container.Resolve<SignalBus>();var buildCommandSent = false;bus.Subscribe<BuildingBuild>(() => buildCommandSent = true);// test subject var build = new BuildController(bus,new BuildingsModel(),level);// test callbuild.Build(0);Assert.AreEqual(1, build.GetCurrentBuildCount());// assert signals was firedAssert.IsTrue(buildCommandSent);

Теперь мы проверяем, что наш GetCurrentBuildCount возвращает правильное количество новых билдов после успешного билда на слоте 0. Мы также ожидаем, что на шину будет отправлен правильный сигнал таким образом, соответствующее представление сможет обновиться.

"Погодите-ка, нельзя мокать то, что имеет корни в Zenject?" (что очень метко сказано моим хорошим другом Питером)

Да, к сожалению, SignalBus не имеет интерфейса, который мы можем передать в NSubstitute - поэтому мы должны фактически подписаться и проверить, был ли запущен правильный сигнал.

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

Заключение

Это было всего лишь взгляд с высоты птичьего полета на эту тему. Но подведем итоги:

Мы хотим иметь возможность писать тестируемый код, поэтому мы максимально отделяем Unity от нашей бизнес-логики, общаемся с Unity посредством сообщений, и у нас есть четкий интерфейс от Unity для доступа к данным. При этом у нас есть небольшая область того, что специфично для Unity и не может быть протестировано (игнорируя playmode тесты).

В будущих статьях мы напишем конкретный пример игры, чтобы применить все это на практике, и, кроме того, посмотрим, как объединить эту архитектуру с:

  • практическим примером применения этих подходов,

  • мокингом сцены для тестирования пользовательского интерфейса

  • фейковыми бэкендами и сторонними SDK

  • промисами для поддерживаемого асинхронного кода


- Узнать подробнее о курсе "Unity Game Developer. Professional" и карьерных перспективах.

- Зарегистрироваться на бесплатный вебинар на тему "Продвинутый искусственный интеллект врагов в шутерах" .


Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru