Снэпшот тестирование

Снэпшот тестирование — подход, при котором результат рендеринга компонента сохраняется в отдельный файл-снимок (snapshot), а при последующих запусках тестов сравнивается с новым результатом. Если структура изменилась, тест сообщает о различиях.

В экосистеме React и FormatJS снэпшоты особенно полезны для проверки:

  • локализованных строк;
  • ICU-шаблонов;
  • структуры компонентов после перевода;
  • корректности подстановки параметров;
  • поведения plural/sel ect конструкций;
  • изменений в форматировании дат, валют и чисел.

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


Почему FormatJS требует отдельного подхода к тестированию

Интернационализация создаёт дополнительные источники нестабильности:

  • текст зависит от locale;
  • форматирование чисел различается по регионам;
  • даты отображаются по-разному;
  • plural-правила отличаются между языками;
  • переводчики могут менять ICU-шаблоны;
  • часть сообщений может отсутствовать.

Обычный snapshot-тест без контроля locale становится ненадёжным.

Например, компонент:

<IntlProvider locale="en">
  <Price value={1200.5} />
</IntlProvider>

может выдавать:

$1,200.50

а при locale fr:

1 200,50 $US

С точки зрения тестов это два разных снимка.


Инструменты для snapshot-тестирования

Наиболее распространённый стек:

  • Jest
  • React Testing Library
  • react-test-renderer
  • @testing-library/react
  • FormatJS
  • @formatjs/intl

Установка зависимостей

npm install --save-dev jest
npm install --save-dev @testing-library/react
npm install --save-dev react-test-renderer

Для React-приложения с FormatJS:

npm install react-intl

Базовый snapshot-тест

Компонент

import {FormattedMessage} fr om 'react-intl';

export default function Greeting() {
  return (
    <h1>
      <FormattedMessage
        id="greeting"
        defaultMessage="Hello, World!"
      />
    </h1>
  );
}

Тест

import renderer from 'react-test-renderer';
import {IntlProvider} from 'react-intl';
import Greeting from './Greeting';

test('matches snapshot', () => {
  const tree = renderer
    .create(
      <IntlProvider locale="en">
        <Greeting />
      </IntlProvider>
    )
    .toJSON();

  expect(tree).toMatchSnapshot();
});

Результат snapshot

Jest создаст файл:

__snapshots__/Greeting.test.js.snap

Содержимое:

exports[`matches snapshot 1`] = `
<h1>
  Hello, World!
</h1>
`;

Проверка локализаций

Snapshot-тесты особенно полезны при тестировании нескольких языков.


Подготовка переводов

const messages = {
  en: {
    greeting: 'Hello'
  },
  ru: {
    greeting: 'Привет'
  },
  de: {
    greeting: 'Hallo'
  }
};

Тестирование нескольких locale

import renderer from 'react-test-renderer';
import {IntlProvider} from 'react-intl';
import Greeting from './Greeting';

const messages = {
  en: {
    greeting: 'Hello'
  },
  ru: {
    greeting: 'Привет'
  }
};

describe('Greeting snapshots', () => {
  ['en', 'ru'].forEach(locale => {
    test(`locale ${locale}`, () => {
      const tree = renderer
        .create(
          <IntlProvider
            locale={locale}
            messages={messages[locale]}
          >
            <Greeting />
          </IntlProvider>
        )
        .toJSON();

      expect(tree).toMatchSnapshot();
    });
  });
});

Snapshot-тестирование ICU MessageFormat

FormatJS активно использует ICU-синтаксис.


Пример plural

<FormattedMessage
  id="items"
  defaultMessage="{count, plural,
    =0 {No items}
    one {# item}
    other {# items}
  }"
  values={{count}}
/>

Snapshot-тест

test.each([0, 1, 5])('count = %i', count => {
  const tree = renderer
    .create(
      <IntlProvider locale="en">
        <FormattedMessage
          id="items"
          defaultMessage="{count, plural,
            =0 {No items}
            one {# item}
            other {# items}
          }"
          values={{count}}
        />
      </IntlProvider>
    )
    .toJSON();

  expect(tree).toMatchSnapshot();
});

Полученные snapshots

No items
1 item
5 items

Такой подход позволяет быстро обнаруживать ошибки plural-логики.


Snapshot-тестирование sel ect-конструкций


Компонент

<FormattedMessage
  id="gender"
  defaultMessage="{gender, select,
    male {He}
    female {She}
    other {They}
  }"
  values={{gender}}
/>

Тест

test.each([
  'male',
  'female',
  'other'
])('%s', gender => {
  const tree = renderer
    .create(
      <IntlProvider locale="en">
        <FormattedMessage
          id="gender"
          defaultMessage="{gender, select,
            male {He}
            female {She}
            other {They}
          }"
          values={{gender}}
        />
      </IntlProvider>
    )
    .toJSON();

  expect(tree).toMatchSnapshot();
});

Тестирование форматирования чисел

FormatJS использует Intl.NumberFormat.


Компонент

import {FormattedNumber} fr om 'react-intl';

export default function Price({value}) {
  return (
    <FormattedNumber
      value={value}
      style="currency"
      currency="USD"
    />
  );
}

Snapshot-тест

test('price snapshot', () => {
  const tree = renderer
    .create(
      <IntlProvider locale="en">
        <Price value={1999.99} />
      </IntlProvider>
    )
    .toJSON();

  expect(tree).toMatchSnapshot();
});

Snapshot

$1,999.99

Проблема нестабильных snapshot-тестов

Intl API зависит от:

  • версии Node.js;
  • ICU-данных;
  • операционной системы;
  • браузера;
  • timezone.

Из-за этого snapshots могут различаться между окружениями.


Фиксация timezone

Частая проблема — различие времени.


Пример

<FormattedDate
  value={new Date('2025-01-01T00:00:00Z')}
/>

В разных timezone результат отличается.


Решение

В Jest setup:

process.env.TZ = 'UTC';

Либо:

TZ=UTC jest

Стабилизация Intl API

Иногда требуется полифилл.


Установка

npm install @formatjs/intl-numberformat
npm install @formatjs/intl-datetimeformat

Подключение

import '@formatjs/intl-numberformat/polyfill';
import '@formatjs/intl-datetimeformat/polyfill';

Snapshot и React Testing Library

Сегодня чаще используется React Testing Library вместо react-test-renderer.


Пример

import {render} from '@testing-library/react';
import {IntlProvider} from 'react-intl';

test('snapshot', () => {
  const {asFragment} = render(
    <IntlProvider locale="en">
      <Greeting />
    </IntlProvider>
  );

  expect(asFragment()).toMatchSnapshot();
});

Проверка HTML-структуры

Snapshot сохраняет полный DOM.


Пример

function UserInfo() {
  return (
    <div className="user">
      <span>Name</span>
    </div>
  );
}

Snapshot:

<DocumentFragment>
  <div
    class="user"
  >
    <span>
      Name
    </span>
  </div>
</DocumentFragment>

Интеграция кастомного render

При большом количестве тестов удобно создать обёртку.


test-utils.js

import {render} from '@testing-library/react';
import {IntlProvider} from 'react-intl';

export function renderWithIntl(
  ui,
  {
    locale = 'en',
    messages = {}
  } = {}
) {
  return render(
    <IntlProvider
      locale={locale}
      messages={messages}
    >
      {ui}
    </IntlProvider>
  );
}

Использование

import {renderWithIntl} from './test-utils';

test('snapshot', () => {
  const {asFragment} = renderWithIntl(
    <Greeting />
  );

  expect(asFragment()).toMatchSnapshot();
});

Snapshot для rich text сообщений

FormatJS поддерживает JSX внутри сообщений.


Компонент

<FormattedMessage
  id="link"
  defaultMessage="Click <b>here</b>"
  values={{
    b: chunks => <strong>{chunks}</strong>
  }}
/>

Snapshot

<strong>
  here
</strong>

Snapshot и динамические данные

Снимки плохо работают с изменяемыми значениями:

  • текущая дата;
  • UUID;
  • случайные числа;
  • timestamp;
  • API-данные.

Плохой пример

<FormattedDate value={new Date()} />

Snapshot будет постоянно меняться.


Хороший пример

const fixedDate = new Date(
  '2025-01-01T00:00:00Z'
);

<FormattedDate value={fixedDate} />

Mocking Date


Jest mock

beforeAll(() => {
  jest.useFakeTimers();

  jest.setSystemTime(
    new Date('2025-01-01')
  );
});

afterAll(() => {
  jest.useRealTimers();
});

Тестирование отсутствующих переводов

FormatJS выводит fallback.


Компонент

<FormattedMessage
  id="unknown"
  defaultMessage="Fallback text"
/>

Snapshot

Fallback text

Проверка ошибок интернационализации

FormatJS может выбрасывать предупреждения.


Пример

<IntlProvider
  locale="en"
  onEr ror={err => {
    throw err;
  }}
>
  <App />
</IntlProvider>

Такой подход превращает предупреждения i18n в падающие тесты.


Snapshot и extract messages

При использовании @formatjs/cli snapshots помогают контролировать изменения переводов после extraction.


Пример extraction

formatjs extract "src/**/*.{js,jsx,ts,tsx}"

После обновления переводов snapshot-тесты позволяют быстро выявить:

  • пропавшие сообщения;
  • неверные ICU-шаблоны;
  • ошибки plural;
  • сломанный rich text.

Inline snapshots

Jest поддерживает встроенные snapshots.


Пример

test('inline snapshot', () => {
  const {asFragment} = render(
    <IntlProvider locale="en">
      <Greeting />
    </IntlProvider>
  );

  expect(asFragment()).toMatchInlineSnapshot(`
<DocumentFragment>
  <h1>
    Hello
  </h1>
</DocumentFragment>
`);
});

Преимущества inline snapshots

  • тест и snapshot находятся рядом;
  • проще читать изменения;
  • удобно для небольших компонентов.

Недостатки inline snapshots

  • большие snapshots делают тесты громоздкими;
  • сложнее ревьюить;
  • merge-конфликты возникают чаще.

Обновление snapshots

После изменения интерфейса snapshots обновляются:

jest -u

или:

npm test -- -u

Опасность бездумного обновления

Наиболее распространённая ошибка — автоматическое обновление snapshots без анализа изменений.

Изменения могут скрывать:

  • потерянные переводы;
  • сломанный ICU-синтаксис;
  • неправильный locale;
  • неверное форматирование валюты;
  • удаление HTML-структуры.

Когда snapshot-тесты полезны

Snapshot-тестирование особенно эффективно для:

  • компонентов с большим количеством текста;
  • сложных ICU-конструкций;
  • rich text переводов;
  • таблиц локализации;
  • UI-компонентов с большим DOM;
  • библиотек компонентов.

Когда snapshot-тесты вредны

Неудачные сценарии:

  • огромные DOM-деревья;
  • нестабильные данные;
  • часто меняющийся UI;
  • тестирование бизнес-логики;
  • проверки пользовательского поведения.

Комбинирование snapshot и semantic testing

Лучший подход — сочетание snapshot и точечных проверок.


Пример

const {getByText, asFragment} = render(
  <IntlProvider locale="ru">
    <Greeting />
  </IntlProvider>
);

expect(
  getByText('Привет')
).toBeInTheDocument();

expect(asFragment()).toMatchSnapshot();

Тестирование кастомных hooks


Hook

import {useIntl} from 'react-intl';

export function usePrice(value) {
  const intl = useIntl();

  return intl.formatNumber(value, {
    style: 'currency',
    currency: 'USD'
  });
}

Snapshot

function TestComponent() {
  const price = usePrice(1000);

  return <span>{price}</span>;
}

Snapshot для formatMessage


Пример

const intl = createIntl({
  locale: 'en',
  messages: {
    hello: 'Hello {name}'
  }
});

expect(
  intl.formatMessage(
    {id: 'hello'},
    {name: 'John'}
  )
).toMatchInlineSnapshot(
  `"Hello John"`
);

Snapshot и CI/CD

Snapshot-тесты особенно полезны в pipeline:

  • проверка pull request;
  • контроль изменений переводов;
  • обнаружение случайных UI-изменений;
  • проверка миграций locale.

Практика организации snapshot-файлов

Оптимальная структура:

src/
  components/
    Greeting/
      Greeting.jsx
      Greeting.test.jsx
      __snapshots__/
        Greeting.test.jsx.snap

Размер snapshot-файлов

Большие snapshots сложно поддерживать.

Плохой признак:

500+ строк в одном snapshot

Хорошая практика:

  • тестировать маленькие компоненты;
  • избегать глубокого дерева;
  • разбивать UI;
  • хранить snapshots компактными.

Snapshot serializers

Jest поддерживает сериализаторы.


Пример

expect.addSnapshotSerializer({
  test: value => true,
  print: value => JSON.stringify(value)
});

Сериализаторы помогают:

  • удалять шум;
  • скрывать динамические поля;
  • нормализовать locale;
  • стабилизировать snapshots.

Snapshot и TypeScript

TypeScript не меняет подход тестирования.


Пример

type Props = {
  count: number;
};

function Counter({count}: Props) {
  return (
    <FormattedMessage
      id="count"
      defaultMessage="{count} items"
      values={{count}}
    />
  );
}

Тест:

expect(asFragment()).toMatchSnapshot();

Изоляция locale в тестах

Нельзя полагаться на системный locale.


Плохой пример

<IntlProvider>

Хороший пример

<IntlProvider locale="en">

Проверка нескольких locale в одном тесте


Пример

const locales = ['en', 'ru', 'de'];

describe.each(locales)(
  'locale %s',
  locale => {
    test('snapshot', () => {
      const {asFragment} = render(
        <IntlProvider locale={locale}>
          <Greeting />
        </IntlProvider>
      );

      expect(
        asFragment()
      ).toMatchSnapshot();
    });
  }
);

Стратегия эффективного snapshot-тестирования

Наиболее надёжный подход:

  1. фиксировать locale;
  2. фиксировать timezone;
  3. избегать динамических данных;
  4. использовать маленькие snapshots;
  5. комбинировать snapshots с semantic assertions;
  6. тестировать ICU отдельно;
  7. не обновлять snapshots автоматически;
  8. использовать deterministic rendering;
  9. проверять plural/select конструкции;
  10. тестировать fallback-сообщения.