Снэпшот тестирование — подход, при котором результат рендеринга компонента сохраняется в отдельный файл-снимок (snapshot), а при последующих запусках тестов сравнивается с новым результатом. Если структура изменилась, тест сообщает о различиях.
В экосистеме React и FormatJS снэпшоты особенно полезны для проверки:
Основная задача — зафиксировать ожидаемый результат интернационализированного интерфейса и обнаруживать непреднамеренные изменения.
Интернационализация создаёт дополнительные источники нестабильности:
Обычный snapshot-тест без контроля locale становится ненадёжным.
Например, компонент:
<IntlProvider locale="en">
<Price value={1200.5} />
</IntlProvider>
может выдавать:
$1,200.50
а при locale fr:
1 200,50 $US
С точки зрения тестов это два разных снимка.
Наиболее распространённый стек:
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
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();
});
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'
}
};
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();
});
});
});
FormatJS активно использует ICU-синтаксис.
<FormattedMessage
id="items"
defaultMessage="{count, plural,
=0 {No items}
one {# item}
other {# items}
}"
values={{count}}
/>
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();
});
No items
1 item
5 items
Такой подход позволяет быстро обнаруживать ошибки plural-логики.
<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"
/>
);
}
test('price snapshot', () => {
const tree = renderer
.create(
<IntlProvider locale="en">
<Price value={1999.99} />
</IntlProvider>
)
.toJSON();
expect(tree).toMatchSnapshot();
});
$1,999.99
Intl API зависит от:
Из-за этого snapshots могут различаться между окружениями.
Частая проблема — различие времени.
<FormattedDate
value={new Date('2025-01-01T00:00:00Z')}
/>
В разных timezone результат отличается.
В Jest setup:
process.env.TZ = 'UTC';
Либо:
TZ=UTC jest
Иногда требуется полифилл.
npm install @formatjs/intl-numberformat
npm install @formatjs/intl-datetimeformat
import '@formatjs/intl-numberformat/polyfill';
import '@formatjs/intl-datetimeformat/polyfill';
Сегодня чаще используется 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();
});
Snapshot сохраняет полный DOM.
function UserInfo() {
return (
<div className="user">
<span>Name</span>
</div>
);
}
Snapshot:
<DocumentFragment>
<div
class="user"
>
<span>
Name
</span>
</div>
</DocumentFragment>
При большом количестве тестов удобно создать обёртку.
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();
});
FormatJS поддерживает JSX внутри сообщений.
<FormattedMessage
id="link"
defaultMessage="Click <b>here</b>"
values={{
b: chunks => <strong>{chunks}</strong>
}}
/>
<strong>
here
</strong>
Снимки плохо работают с изменяемыми значениями:
<FormattedDate value={new Date()} />
Snapshot будет постоянно меняться.
const fixedDate = new Date(
'2025-01-01T00:00:00Z'
);
<FormattedDate value={fixedDate} />
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(
new Date('2025-01-01')
);
});
afterAll(() => {
jest.useRealTimers();
});
FormatJS выводит fallback.
<FormattedMessage
id="unknown"
defaultMessage="Fallback text"
/>
Fallback text
FormatJS может выбрасывать предупреждения.
<IntlProvider
locale="en"
onEr ror={err => {
throw err;
}}
>
<App />
</IntlProvider>
Такой подход превращает предупреждения i18n в падающие тесты.
При использовании @formatjs/cli snapshots помогают
контролировать изменения переводов после extraction.
formatjs extract "src/**/*.{js,jsx,ts,tsx}"
После обновления переводов snapshot-тесты позволяют быстро выявить:
Jest поддерживает встроенные snapshots.
test('inline snapshot', () => {
const {asFragment} = render(
<IntlProvider locale="en">
<Greeting />
</IntlProvider>
);
expect(asFragment()).toMatchInlineSnapshot(`
<DocumentFragment>
<h1>
Hello
</h1>
</DocumentFragment>
`);
});
После изменения интерфейса snapshots обновляются:
jest -u
или:
npm test -- -u
Наиболее распространённая ошибка — автоматическое обновление snapshots без анализа изменений.
Изменения могут скрывать:
Snapshot-тестирование особенно эффективно для:
Неудачные сценарии:
Лучший подход — сочетание snapshot и точечных проверок.
const {getByText, asFragment} = render(
<IntlProvider locale="ru">
<Greeting />
</IntlProvider>
);
expect(
getByText('Привет')
).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
import {useIntl} from 'react-intl';
export function usePrice(value) {
const intl = useIntl();
return intl.formatNumber(value, {
style: 'currency',
currency: 'USD'
});
}
function TestComponent() {
const price = usePrice(1000);
return <span>{price}</span>;
}
const intl = createIntl({
locale: 'en',
messages: {
hello: 'Hello {name}'
}
});
expect(
intl.formatMessage(
{id: 'hello'},
{name: 'John'}
)
).toMatchInlineSnapshot(
`"Hello John"`
);
Snapshot-тесты особенно полезны в pipeline:
Оптимальная структура:
src/
components/
Greeting/
Greeting.jsx
Greeting.test.jsx
__snapshots__/
Greeting.test.jsx.snap
Большие snapshots сложно поддерживать.
Плохой признак:
500+ строк в одном snapshot
Хорошая практика:
Jest поддерживает сериализаторы.
expect.addSnapshotSerializer({
test: value => true,
print: value => JSON.stringify(value)
});
Сериализаторы помогают:
TypeScript не меняет подход тестирования.
type Props = {
count: number;
};
function Counter({count}: Props) {
return (
<FormattedMessage
id="count"
defaultMessage="{count} items"
values={{count}}
/>
);
}
Тест:
expect(asFragment()).toMatchSnapshot();
Нельзя полагаться на системный locale.
<IntlProvider>
<IntlProvider locale="en">
const locales = ['en', 'ru', 'de'];
describe.each(locales)(
'locale %s',
locale => {
test('snapshot', () => {
const {asFragment} = render(
<IntlProvider locale={locale}>
<Greeting />
</IntlProvider>
);
expect(
asFragment()
).toMatchSnapshot();
});
}
);
Наиболее надёжный подход: