Инструкция: тестирование в Эффекторе

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

Дисклеймер. В примерах кода используется jest в качестве тест-раннера. Если вы используете другой пакет для запуска тестов в своём проекте, примеры могут не заработать «эз из».

Принципы написания хороших модульных тестов

Модульные тесты, несмотря на кажущуюся простоту, являются довольно сложной практикой тестирования. Написать тест-кейс, который не сломается от малейшего рефакторинга и будет тестировать хоть что-то, не так просто. Рассмотрим несколько принципов, которые помогают писать модульные тесты правильно.

Изоляция тест-кейсов

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

describe('event emitter', () => {
  let emitter

  beforeAll(() => {
    emitter = new Emitter()
  })

  afterAll(() => {
    emitter.destroy()
  })

  test('one', () => {
    // ...
  })

  test('two', () => {
    // ...
  })
})

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

describe('event emitter', () => {
  let emitter

  // beforeAll -> beforeEach
  beforeEach(() => {
    emitter = new Emitter()
  })

  // afterAll -> afterEach
  afterEach(() => {
    emitter.destroy()
  })

  test('one', () => {
    // ...
  })

  test('two', () => {
    // ...
  })
})

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

describe('event emitter', () => {
  test('one', () => {
    let emitter = new Emitter()

    // ...

    emitter.destroy()
  })

  test('two', () => {
    let emitter = new Emitter()

    // ...

    emitter.destroy()
  })
})

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

Ограничение сайд-эффектов

Однажды мы в Авиасейлс писали модуль отправки сообщений в саппорт с сайта. И, конечно же, покрыли его модульными тестами. Через пару дней в чатик пришли ребята из саппорта. В их систему летели сотни сообщений от автора «Тест Тест» с текстом «Тестовый тест для теста». Неловко!

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

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

describe('event emitter', () => {
  test('one', () => {
   let handler = jest.fn()

    // подменяем модуль http-client на уровне импортов
    jest.mock('http-client', () => ({
      post: handler,
    }));

    let emitter = new Emitter()

    // ...

    emitter.destroy()
  })

  test('two', () => {
    let handler = jest.fn()

    let httpClient = { post: handler }

    // явно внедряем зависимость в тестируемый модуль
    let emitter = new Emitter({ httpClient })

    // ...

    emitter.destroy()
  })
})

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

Тесты Эффектор-модулей

Сначала пример:

describe('support service', () => {
  test('should send message after submit', async () => {
    // создаём фейковый обработчик для сайд-эффекта
    let sendMessageToSupportHandler = jest.fn()

    // создаём независимую копию всего приложения
    // чтобы один тест не мог повлиять на другой
    let scope = fork({
      handlers: new Map([
        // и для этой копии заменяем обработчик отправки
        [support.sendMessageFx, sendMessageToSupportHandler]
      ])
    })
    
    // имитируем ввод пользователем сообщения
    await allSettled(support.messageChanged, {
      params: 'Test Message',
      scope,
    })

    // имитируем ввод пользователем имени
    await allSettled(support.nameChanged, {
      params: 'Test User',
      scope,
    })

    // имитируем сабмит формы пользователем
    await allSettled(support.formSubmitted, {
      scope,
    })

    // проверяем, что сообщение с параметрами отправилось
    expect(sendMessageToSupportHandler).toHaveBeedCalledWith({
      name: 'Test User',
      message: 'Test Message',
    })
  })
})

Этот тест следует принципам хорошего модульного теста:

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

Теперь рассмотрим подробнее, как это все работает и зачем нужно.

fork()

До 22 версии Эффектора, в fork нужно было передавать домен. Теперь можно не передавать.

Функция fork создает скоуп (scope) — независимую изолированную копию приложения. Любые вычисления на скоупе не затрагивают основное приложение и другие скоупы.

let scope = fork()

Все связи юнитов будут работать как в оригинальном приложении.

Обратите внимание, что для корректной работы Fork API в приложении должны быть установлены sid-ы. Подробно об этом будет рассказать дальше. Кратко — используйте фирменный babel-плагин.

fork({ handlers })

Одна из важнейших особенностей скоупов — это возможность подменять обработчики любых эффектов в конкретном скоупе. Для этого нужно передать в функцию fork объект с полем handlers. Это Map, где ключами выступают оригинальные эффекты, а значениями — новые хэндлеры.

// обработчик этого эффекта делает запрос в интернет
const fetchDataFx = createEffect(async () => {
  const { data } = await axios.get('some_url')

  console.log('REAL')

  return data
})

// чтобы заменить его на другой в конкретном скоупе,
// нужно передать новый обработчик при вызове fork
const scope = fork({
  handlers: new Map([
    [fetchDataFx, () => console.log('FAKED')]
  ])
})

// вызовы эффекта на скоупе будут писать в консоль
// вместо сетевого вызова
await allSettled(fetchDataFx, { scope }) // -> FAKED

// вызовы эффекта без скоупа будут
// делать запросы в интернет
await fetchDataFx() // -> REAL

fork({ values })

Аналогично можно подменить значение любого стора в конкретном скоупе. Для этого нужно передать в функцию fork объект с полем values. Это Map, где ключами выступают оригинальные сторы, а значениями — новый значения для них.

const $data = createStore('REAL')

// чтобы заменить значение стора в конкретном скоупе,
// нужно передать новое значение при вызове fork
const scope = fork({
  values: new Map([
    [$data, 'FAKED']
  ])
})

// теперь в скоупе будет лежать новое значение
console.log(scope.getState($data)) // -> FAKED

// но в оригинальном сторе будет старое значение
console.log($data.getState()) // -> REAL
Кстати, не надо подменять значения производных сторов, полученных с помощью вызовов .map, combine и других. Производные сторы вообще лучше не модифицировать.

fork({ values, handlers })

При вызове fork можно передать одновременно и values, и handlers.

Предосторожности при императивных вызовах

Есть несколько ситуаций, в которых Эффектор может потерять текущий скоуп. Все они связаны с императивными вызовами ивентов и эффектов.

  • Вызов в одном обработчике эффекта других эффектов И обычных асинхронных функций. Документация. Вот примеры 👇
// 👍 так можно
// используются только обычные асинхронные функции
const fetchWithDelayFx = createEffect(async (url) => {
  await new Promise(rs => setTimeout(rs, 80))
  const { data } = await axios.get(url)
  
  return data
})

// 👍 так можно
// используются только эффекты
const sendWithAuthFx = createEffect(async () => {
  await authUserFx()
  await delayFx()
  await sendMessageFx()
})

// 👎 так не можно
// используются И эффекты, И обычные асинхронные функции
const sendWithAuthFx = app.createEffect(async () => {
  await authUserFx()
  await new Promise(rs => setTimeout(rs, 80))
  await sendMessageFx()
})
  • Вызов ивентов в не-эффектор контексте. Документация. Правильно делать вот так 👇
const installHistory = createEvent()
const changeLocation = createEvent()

installHistory.watch(history => {
  // прикрепляем событие changeLocation к текущему скоупу
  const locationUpdate = scopeBind(changeLocation)

  history.listen(location => {
    // теперь при вызове скоуп не потеряется
    locationUpdate(location.pathname)
  })
})

Особенности работы скоупов с React

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

В этом случае внутри Реакта (в компонентах и кастомных хуках) нельзя вызывать Эффектор-ивенты напрямую, нужно привязывать их к скоупу через useEvent. Для клиентского кода этот хук можно импортировать из effector-react и он будет просто возвращать оригинальный ивент.

import { useStore, useEvent } from 'effector-react'

const inc = createEvent()
const $count = createStore(0)
  .on(inc, x => x + 1)

const TestableComponent = () => {
  const count = useStore($count)

  // Прибиваем контекст для тестов
  const clickHandler = useEvent(inc)

  return (
    <>
      <p>Count: {count}</p>
      {/* 👍 все правильно */}
      <button onClick={clickHandler}>increment</button>
      {/* 👎 так не сработает */}
      <button onClick={inc}>increment</button>
    </>
  )
}

После этого, в тестах достаточно заменить все импорты из effector-react на аналогичные из effector-react/scope и обернуть компонент в провайдер.

Заменить импорт можно средствами тест-раннера, или бабель-плагина для тестового окружения. Например, так:

// babel.config.js

module.exports = (api) => {
  // Добавляем плагин только для тестового окружения
  if (api.env('test')) {
    config.plugins.push([
      'module-resolver',
      {
        root: ['.'],
        alias: {
          effector-react: 'effector-react/scope',
        },
      },
    ]);
  }

  return config;
};

Оборачивать в провайдер тестируемый компонент лучше прямо в тест-кейсе:

import { render } from '@testing-library/react';
import { Provider } from 'effector-react/scope'

describe('TestableComponent', () => {
  test('should increment counter after click', () => {
    const scope = fork()

    const rendered = render(
      <Provider value={scope}>
        <TestableComponent />
      </Provider>
    );

    // можно делать что угодно с компонентом
    // и проверять что угодно в скоупе
  })
})

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

allSettled()

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

  • вызывает ивент/эффект на конкретном скоупе;
  • дожидается, пока закончатся все вычисления, спровоцированные вызовом.

Вторая особенность важна при тестировании сложных асинхронных пользовательских сценариев. Например, представьте себе, что приложение должно делать примерно такое:

// Логин пользователя вернет userId
const loginFx = createEffect(async () => {
  const { data: userId } = await axios.post('...')

  return userId
})

// Для пользователя можно получить текущую валюту по userId
const fetchCurrencyFx = createEffect(async (userId) => {
  const { data: currency } = await axios.get('...')

  return currency
})

// Сюда сохраним полученную валюту пользователя
const $userCurrency = createStore(null)

// Пользоваетель нажимает на кнопку логин
const loginButtonClicked = createEvent()

// Когда
forward({
  // пользователь кликнул на кнопку «логин»
  from: loginButtonClicked,
  // выполняем сайд-эффект логина
  to: loginFx
})

// Когда
forward({
  // процесс логина успешно завершился, берем результат
  from: loginFx.doneData,
  // и вызываем запрос валюты с ним
  to: fetchCurrencyFx
})

// Когда
forward({
  // запрос валюты завершился, берем результат
  from: fetchCurrencyFx.doneData,
  // и кладем его в стор
  to: $userCurrency
})

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

describe('login flow', () => {
  test('set currency after login', async () => {
    const scope = fork({
      // подменяем обработчики
      // чтобы не делать настоящих запросов
      handlers: new Map([
        [loginFx, () => 'FAKE_ID'],
        [fetchCurrencyFx, () => 'FAKE_CURRENCY']
      ])
    })

    // имитируем клик пользователя
    // и дожидаемся, пока все ивенты и эффекты выполнятся
    await allSettled(loginButtonClicked, { scope })

    // проверяем, что в итоге валюта окажется корректной
    expect(scope.getState($userCurrency)).toBe('FAKE_CURRENCY')
  })
})

Подробнее про важность allSettled я рассказывал в статье про лучшую часть Эффектора.

scope.getState()

Это просто получение значение стора в конкретном скоупе. Тут все просто, передаем стор в scope.getState(), немедленно получаем его текущее значение. Значение проверяем привычными методами тест-раннера.

effector/babel-plugin

Последний важный ингредиент удобных тестов с Эффектором — это бабель-плагин. Он поставляется вместе с библиотекой и делает много рутинной работы. Документация.

При работе с Fork API важно отличать юниты друг от друга, для этого внутри используются sid-ы — уникальные имена юнитов. На самом деле, их можно проставлять руками при создании каждого юнита, но это неудобно. Лучше эту работу переложить на бабель-плагин.

// babel.config.js

module.exports = (api) => {
  // Добавляем плагин только для тестового окружения
  if (api.env('test')) {
    config.plugins.push([
      'effector/babel-plugin',
    ]);
  }

  return config;
};

На самом деле, этот плагин имеет смысл использовать во всех окружениях. Кроме проставления sid-ов он помогает утилитам effector-logger, effector-inspector и patronum/debug отображать осмысленные имена юнитов. Плюс, пригодится, если в приложении появится SSR.

Резюме

Тестирование Эффектор-модулей — это один из самых приятных видов тестирования приложения.

  • Все нужные фичи (подмена значений и обработчиков, allSettled, независимость скоупов) доступны из коробки и поддерживаются как граждане первой категории.
  • Тесты не зависят от пользовательского интерфейса. Их проще писать, рефакторить и поддерживать, они стабильные и быстрые.

Пишите тесты, будьте счастливы, записывайтесь на консультации 💙