Представляем 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. Он позволяет легко создавать тесты и предоставляет исчерпывающие отчеты о том, когда и как произошли сбои, дополняя их ссылками для просмотра видеозаписей и выполнения тестовых шагов вручную. Так можно понять, что именно пошло не так.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Netflix Technology Blog: Introducing SafeTest: A Novel Approach to Front End Testing

Предыдущая статьяОтложенная загрузка на уровне шаблонов в Angular
Следующая статьяБорьба с веб-скрейперами с помощью Rust