В этой статье мы поговорим о SafeTest — революционной библиотеке, которая предлагает свежий взгляд на сквозные (E2E) тесты для веб-приложений с пользовательским интерфейсом.
Проблемы традиционного UI тестирования
Традиционно тестирование пользовательского интерфейса проводилось с помощью модульного или интеграционного тестирования (также иногда называемого E2E-тестированием). Однако каждый из этих методов предполагает компромисс: вам приходится выбирать между контролем над настройкой тестов и контролем над тестовым драйвером.
Например, при использовании такого решения для модульного тестирования, как react-testing-library, вы сохраняете полный контроль над тем, что должно отображаться и как должны вести себя базовые сервисы и импорты. Однако вы теряете возможность взаимодействовать с реальной страницей, что может привести к множеству болезненных моментов:
- Проблемы при взаимодействии со сложными элементами пользовательского интерфейса, такими как компоненты <Dropdown />.
- Невозможность протестировать настройку CORS или вызовы GraphQL.
- Плохая видимость проблем с z-index, влияющих на кликабельность кнопок.
- Сложная и неинтуитивная разработка и отладка тестов.
И наоборот, использование инструментов интеграционного тестирования (например, Cypress или Playwright) обеспечивает контроль над страницей, но жертвует возможностью инструментировать код инициализации приложения. Эти инструменты работают путем удалённого управления браузером и взаимодействия со страницей. Такой подход имеет свои недостатки:
- Сложность выполнения вызовов альтернативных конечных точек API без реализации пользовательских правил перезаписи API сетевого уровня.
- Невозможность делать утверждения для шпионов / моков или выполнять код внутри приложения.
- Тестирование таких составляющих, как тёмный режим, требует нажатия на переключатель тем или знания механизма localStorage для переопределения.
- Невозможность тестировать сегменты приложения. Например, если компонент становится видимым только после нажатия кнопки и ожидания отсчёта 60-секундного таймера, тест должен будет выполнить эти действия, что займёт не менее минуты.
С целью справиться с этими недостатками компании Cypress и Playwright предлагают решения наподобие E2E Component Testing. Хотя эти инструменты пытаются устранить недостатки традиционных методов интеграционного тестирования, они имеют свои ограничения, связанные с архитектурой. Они запускают dev сервер с кодом инициализации для загрузки нужного компонента, что ограничивает их способность работы со сложными корпоративными приложениями, у которых может быть OAuth или сложный конвейер сборки. Более того, обновление TypeScript может привести к поломке тестов, пока команда Cypress/Playwright не обновит свой runner.
SafeTest
SafeTest призван решить эти проблемы с помощью нового подхода к UI-тестированию. Основная идея заключается в том, чтобы на этапе загрузки приложения иметь фрагмент кода, который внедряет хуки для запуска тестов. Обратите внимание, что этот способ работы не оказывает заметного влияния на обычное использование вашего приложения — SafeTest прибегает к ленивой загрузке для динамической загрузки тестов только при их выполнении (в примере с README тесты вообще не находятся в продакшен бандле). После этого для запуска обычных тестов можно использовать Playwright, что помогает достигнуть идеального контроля браузера, который мы хотим получить для наших тестов.
Этот подход также открывает некоторые интересные возможности:
- Глубокая привязка к конкретному тесту без необходимости запускать тестовый сервер.
- Двусторонняя связь между браузером и контекстом теста.
- Доступ ко всем фичам DX, которые поставляются с Playwright (за исключением тех, которые поставляются с playwright/test).
- Видеозапись тестов, просмотр трейсов и функция паузы страницы для опробования различных селекторов/действий страницы.
Примеры тестирования с помощью SafeTest
SafeTest покажется знакомым любому, кто уже проводил UI-тесты, поскольку он использует лучшие части существующих решений. Вот пример того, как можно протестировать всё приложение целиком:
import { describe, it, expect } from 'safetest/jest';
import { render } from 'safetest/react';
describe('my app', () => {
it('loads the main page', async () => {
const { page } = await render();
await expect(page.getByText('Welcome to the app')).toBeVisible();
expect(await page.screenshot()).toMatchImageSnapshot();
});
});
Так же легко можно протестировать конкретный компонент:
import { describe, it, expect, browserMock } from 'safetest/jest';
import { render } from 'safetest/react';
describe('Header component', () => {
it('has a normal mode', async () => {
const { page } = await render(<Header />);
await expect(page.getByText('Admin')).not.toBeVisible();
});
it('has an admin mode', async () => {
const { page } = await render(<Header admin={true} />);
await expect(page.getByText('Admin')).toBeVisible();
});
it('calls the logout handler when signing out', async () => {
const spy = browserMock.fn();
const { page } = await render(<Header handleLogout={spy} />);
await page.getByText('logout').click();
expect(await spy).toHaveBeenCalledWith();
});
});
Использование переопределений
SafeTest использует React Context, чтобы обеспечить возможность переопределения значений во время тестирования. В качестве примера предположим, что у нас есть функция fetchPeople, используемая в компоненте:
import { useAsync } from 'react-use';
import { fetchPerson } from './api/person';
export const People: React.FC = () => {
const { data: people, loading, error } = useAsync(fetchPeople);
if (loading) return <Loader />;
if (error) return <ErrorPage error={error} />;
return <Table data={data} rows=[...] />;
}
Мы можем модифицировать компонент People, чтобы использовать переопределение:
import { fetchPerson } from './api/person';
+import { createOverride } from 'safetest/react';
+const FetchPerson = createOverride(fetchPerson);
export const People: React.FC = () => {
+ const fetchPeople = FetchPerson.useValue();
const { data: people, loading, error } = useAsync(fetchPeople);
if (loading) return <Loader />;
if (error) return <ErrorPage error={error} />;
return <Table data={data} rows=[...] />;
}
Теперь в тесте можно переопределить ответ для этого вызова:
const pending = new Promise(r => { /* Do nothing */ });
const resolved = [{name: 'Foo', age: 23], {name: 'Bar', age: 32]}];
const error = new Error('Whoops');
describe('People', () => {
it('has a loading state', async () => {
const { page } = await render(
<FetchPerson.Override with={() => () => pending}>
<People />
</FetchPerson.Override>
);
await expect(page.getByText('Loading')).toBeVisible();
});
it('has a loaded state', async () => {
const { page } = await render(
<FetchPerson.Override with={() => async () => resolved}>
<People />
</FetchPerson.Override>
);
await expect(page.getByText('User: Foo, name: 23')).toBeVisible();
});
it('has an error state', async () => {
const { page } = await render(
<FetchPerson.Override with={() => async () => { throw error }}>
<People />
</FetchPerson.Override>
);
await expect(page.getByText('Error getting users: "Whoops"')).toBeVisible();
});
});
Функция render также принимает функцию, которая будет передана исходному компоненту приложения, что позволяет внедрять любые желаемые элементы в любом месте приложения:
it('has a people loaded state', async () => {
const { page } = await render(app =>
<FetchPerson.Override with={() => async () => resolved}>
{app}
</FetchPerson.Override>
);
await expect(page.getByText('User: Foo, name: 23')).toBeVisible();
});
С помощью переопределений мы можем написать сложные тест-кейсы. Например, чтобы убедиться, что метод сервиса, объединяющий API-запросы от
/foo
, /bar
и /baz
, имеет правильный механизм повторных попыток только для неудачных API-запросов и по-прежнему корректно отображает возвращаемое значение. Так, если для разрешения /bar требуется 3 попытки, метод выполнит в общей сложности 5 вызовов API.Переопределения не ограничиваются только вызовами API (поскольку можно использовать page.route), мы также можем переопределять специфические значения на уровне приложения, такие как фича-флаги или изменение статического значения:
+const UseFlags = createOverride(useFlags);
export const Admin = () => {
+ const useFlags = UseFlags.useValue();
const { isAdmin } = useFlags();
if (!isAdmin) return <div>Permission error</div>;
// ...
}
+const Language = createOverride(navigator.language);
export const LanguageChanger = () => {
- const language = navigator.language;
+ const language = Language.useValue();
return <div>Current language is { language } </div>;
}
describe('Admin', () => {
it('works with admin flag', async () => {
const { page } = await render(
<UseIsAdmin.Override with={oldHook => {
const oldFlags = oldHook();
return { ...oldFlags, isAdmin: true };
}}>
<MyComponent />
</UseIsAdmin.Override>
);
await expect(page.getByText('Permission error')).not.toBeVisible();
});
});
describe('Language', () => {
it('displays', async () => {
const { page } = await render(
<Language.Override with={old => 'abc'}>
<MyComponent />
</Language.Override>
);
await expect(page.getByText('Current language is abc')).toBeVisible();
});
});
Переопределения (Overrides) — мощная фича SafeTest, и приведённые здесь примеры коснулись лишь малой части. За дополнительной информацией и примерами обращайтесь к разделу «Overrides» в README.
Отчётность
SafeTest поставляется из коробки с мощными возможностями создания отчётов, такими как автоматическая привязка видеоповторов, просмотр трейсов Playwright и даже глубокая ссылка непосредственно на готовый протестированный компонент. В README репозитория SafeTest есть ссылки на все примеры приложений, а также на отчёты.
SafeTest в корпоративной среде
Многие крупные корпорации нуждаются в какой-то форме аутентификации для использования приложения. Как правило, переход на localhost:3000 приводит к вечной загрузке страницы. Вам нужно перейти на другой порт, например localhost:8000, у которого есть прокси-сервер для проверки и / или введения учётных данных аутентификации в базовые вызовы служб. Это ограничение — одна из основных причин, по которой компонентные тесты Cypress / Playwright не подходят для использования в Netflix.
Однако обычно существует сервис, который может генерировать тестовых пользователей, чьи учётные данные можно использовать для входа в приложение и взаимодействия с ним. Это позволяет создать лёгкую обёртку вокруг SafeTest для автоматической генерации и принятия этого тестового пользователя. Вот, например, как мы это делаем в Netflix:
import { setup } from 'safetest/setup';
import { createTestUser, addCookies } from 'netflix-test-helper';
type Setup = Parameters<typeof setup>[0] & {
extraUserOptions?: UserOptions;
};
export const setupNetflix = (options: Setup) => {
setup({
...options,
hooks: { beforeNavigate: [async page => addCookies(page)] },
});
beforeAll(async () => {
createTestUser(options.extraUserOptions)
});
};
После настройки мы просто импортируем вышеупомянутый пакет вместо того, чтобы использовать safetest / setup.
За пределами React
Хотя этот пост был посвящён тому, как SafeTest работает с React, он не ограничивается только React. SafeTest также работает с Vue, Svelte, Angular и даже может работать на NextJS или Gatsby. Он также работает с использованием Jest или Vitest, в зависимости от того, на каком тестовом исполнителе вы начали работу с кодогенерацией. Папка с примерами демонстрирует, как использовать SafeTest с различными комбинациями инструментов, и мы призываем добавлять новые примеры.
По своей сути SafeTest — это интеллектуальная связка для исполнителя тестов, UI-библиотеки и прогона в браузере. Хотя в Netflix чаще всего используется Jest / React / Playwright, можно легко добавить адаптеры для других вариантов.
Заключение
SafeTest — это мощный фреймворк для тестирования, который используется в Netflix. Он позволяет легко создавать тесты и предоставляет исчерпывающие отчёты о том, когда и как произошли сбои, а также ссылки для воспроизведения видео или ручного выполнения шагов теста, чтобы понять, что именно сломалось. Мы с нетерпением ждем, как он изменит тестирование пользовательского интерфейса.
Нашла и перевела: Ксения Мосеенкова