Представляем SafeTest: новый подход к тестированию фронтенда.
Проблемы традиционного тестирования пользовательского интерфейса
Традиционно тестирование пользовательского интерфейса проводятся с помощью модульных или интеграционных тестов (также известных как тесты End-To-End или E2E). Однако каждый из этих методов представляет собой компромисс: приходится выбирать между контролем над тестовыми фикстурами и настройками и контролем над тестовым драйвером.
Например, при использовании react-testing-lib
Представляем SafeTest: новый подход к тестированию фронтенда...
Проблемы традиционного тестирования пользовательского интерфейса
Традиционно тестирование пользовательского интерфейса проводятся с помощью модульных или интеграционных тестов (также известных как тесты End-To-End или E2E). Однако каждый из этих методов представляет собой компромисс: приходится выбирать между контролем над тестовыми фикстурами и настройками и контролем над тестовым драйвером.
Например, при использовании react-testing-library, решения для модульного тестирования, вы сохраняете полный контроль над тем, что должно отображаться и как должны вести себя базовые сервисы и импорты. Однако при этом теряется возможность взаимодействовать с реальной страницей, что может привести ко множеству проблем.
- Трудностям при взаимодействии со сложными элементами пользовательского интерфейса, такими как компоненты <Dropdown />.
- Невозможности протестировать настройку CORS или вызовы GraphQL.
- Отсутствию видимости проблем с z-index, влияющих на кликабельность кнопок.
- Сложной и неинтуитивной разработке и отладке тестов.
И наоборот, использование инструментов интеграционного тестирования, таких как Cypress и Playwright, обеспечивает контроль над страницей, но при этом утрачивается возможность инструментирования кода инициализации приложения. Эти инструменты работают путем удаленного управления браузером для перехода по URL-адресам и взаимодействия со страницей. Такой подход имеет свои сложности:
- Сложность выполнения вызовов к альтернативным конечным точкам API без применения собственных правил перезаписи API сетевого уровня.
- Невозможность делать утверждения по шпионам/мокам или выполнять код внутри приложения.
- Тестирование функций наподобие темного режима требует нажатия на переключатель тем или знания механизма localStorage для переопределения.
- Невозможность тестировать сегменты приложения: так, если компонент становится видимым только после нажатия кнопки и нужно ждать отсчета таймера, настроенного на 60 секунд, тест должен будет выполнить эти действия и займет не менее минуты.
Понимая эти проблемы, специалисты компаний Cypress и Playwright разработали такие решения, как E2E Component Testing (E2E-тестирование компонентов).
Хотя цель этих инструментов — устранить недостатки традиционных методов интеграционного тестирования, они имеют ограничения, связанные с их архитектурой. Они запускают сервер разработки с кодом инициализации для загрузки нужного компонента и/или кода настройки, что ограничивает их возможности по работе со сложными корпоративными приложениями, которые могут быть оснащены OAuth или сложным конвейером сборки. Более того, обновление TypeScript может привести к сбою тестов, и ситуацию могут исправить только представители команд Cypress/Playwright, внеся обновления в свои исполнители тестов.
Представляем SafeTest
Библиотека SafeTest призвана решить указанные проблемы с помощью инновационного подхода к тестированию пользовательского интерфейса. Основная идея заключается в том, чтобы на этапе запуска приложения иметь фрагмент кода, который внедряет хуки для запуска тестов (см. главы “Как работает SafeTest” для получения дополнительной информации о том, какие задачи решает такой подход). Обратите внимание: этот способ работы не оказывает заметного влияния на регулярное использование приложения, поскольку 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={fn} />);
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 => { /* Ничего не делать */ });
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();
});
С помощью переопределений мы можем писать сложные тестовые примеры. Таким образом можно убедиться, например, в том, что метод service, объединяющий 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();
});
});
Переопределения — мощная функция SafeTest, и приведенные здесь примеры освещают лишь некоторые ее сильные стороны. Дополнительную информацию и примеры найдете в разделе “Переопределения” в 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 — это интеллектуальная “склейка” для исполнителя тестов, библиотеки пользовательского интерфейса и платформой исполнения тестов в браузере. Хотя в Netflix чаще всего используется комбинация Jest/React/Playwright, можно легко добавить дополнительные адаптивные варианты для других случаев применения.
Заключение
SafeTest — это мощный фреймворк для тестирования, который используется в Netflix. Он позволяет легко создавать тесты и предоставляет исчерпывающие отчеты о том, когда и как произошли сбои, дополняя их ссылками для просмотра видеозаписей и выполнения тестовых шагов вручную. Так можно понять, что именно пошло не так.