Когда test.extend типизирует фикстуры Playwright, TypeScript видит loginPage как экземпляр LoginPage в каждом тесте, а не как any. Опечатки в именах методов ловятся на этапе компиляции, автодополнение работает без дополнительной настройки в каждом файле. Статья разбирает TypeScript-паттерны которые важны на масштабе сьюта: дженерик-фикстуры, discriminated union-типы для состояний заказов и сессий, utility-типы для фабрик тестовых данных, сужение типов для DOM-методов и настройки strict mode за пределами strict: true.
Type aliases vs interfaces: практическое правило
Каждый туториал по TypeScript в какой-то момент объясняет что интерфейсы и type alias-ы сильно пересекаются. Оба описывают формы объектов. Оба поддерживают расширение. Оба работают в одних и тех же местах большую часть времени. Полезное правило для тестового кода проще чем предлагает спецификация языка.
Используй интерфейсы когда описываешь форму данных которую другие типы будут расширять. Используй type alias-ы когда строишь union-ы, пересечения или что-то что не является простой формой объекта.
// Interface: форма для которой расширение имеет смысл
interface User {
id: number;
email: string;
role: 'admin' | 'viewer';
}
// Расширение интерфейса: естественно, читабельно
interface AdminUser extends User {
permissions: string[];
}
// Type alias: правильный выбор для union-ов
type UserRole = 'admin' | 'viewer' | 'guest';
// Type alias: правильный выбор для пересечений
type AuthenticatedUser = User & { token: string; expiresAt: Date };
// Type alias: правильный выбор для примитивов и кортежей
type UserId = number;
type Credentials = [string, string]; // [email, password]Интерфейсы лучше подходят для расширяемых форм из-за declaration merging: один и тот же интерфейс можно объявить дважды, и TypeScript объединит объявления. Это удобно в файлах фикстур где разные части сьюта добавляют свойства к одному типу фикстуры. Type alias-ы merging не поддерживают. Дублирующее объявление в них вызывает ошибку.
[!note]
Ни интерфейсы, ни type alias-ы не производят никакого JavaScript-вывода. Они существуют только на уровне TypeScript и вырезаются при компиляции. Никаких рантаймовых затрат ни у того ни у другого.
Ошибка которую стоит избегать: тратить время споря что именно использовать. Интерфейсы для форм объектов, type alias-ы для всего остального, и двигаться дальше. Оба варианта будут работать корректно. Это решение об организации кода, а не о корректности.
Типизация тестовых данных: пользователи, заказы, формы
Фикстуры живут и умирают вместе с данными. Объект пользователя неправильной формы молча ломает пять тестов до того как кто-то это замечает. TypeScript делает форму явной.
// types/test-data.ts
export interface UserCredentials {
email: string;
password: string;
}
export interface UserProfile {
id: number;
email: string;
firstName: string;
lastName: string;
role: 'admin' | 'editor' | 'viewer';
createdAt: string; // ISO-строка даты из API
}
export interface Order {
id: string;
status: 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
items: OrderItem[];
totalAmount: number;
currency: 'USD' | 'EUR' | 'GBP';
}
export interface OrderItem {
productId: string;
name: string;
quantity: number;
unitPrice: number;
}
export interface RegistrationForm {
firstName: string;
lastName: string;
email: string;
password: string;
confirmPassword: string;
acceptTerms: boolean;
}После того как интерфейсы определены, файлы с тестовыми данными становятся самодокументирующимися и проверяются компилятором:
// fixtures/test-users.ts
import { UserCredentials, UserProfile } from '../types/test-data';
export const adminCredentials: UserCredentials = {
email: 'admin@example.com',
password: 'Admin$ecure1!',
};
export const viewerProfile: UserProfile = {
id: 42,
email: 'viewer@example.com',
firstName: 'Alex',
lastName: 'Rivera',
role: 'viewer',
createdAt: '2025-01-15T09:00:00Z',
};
// TypeScript поймает сразу: 'superadmin' — невалидная роль
export const invalidUser: UserProfile = {
id: 99,
email: 'super@example.com',
firstName: 'Super',
lastName: 'Admin',
role: 'superadmin', // Error: Type '"superadmin"' is not assignable to type '"admin" | "editor" | "viewer"'
createdAt: '2025-03-01T00:00:00Z',
};Когда API-контракт изменится и role получит новое значение, достаточно добавить его в интерфейс один раз. Компилятор укажет на каждое место в тестах которое не обрабатывает новый случай.
Типизация классов Page Object
Классы Page Object получают от явной типизации больше пользы чем любая другая часть тестового сьюта. Типизированный Page Object документирует собственное API: сигнатуру конструктора, что принимает каждый метод и что возвращает.
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
import { UserCredentials } from '../types/test-data';
export class LoginPage {
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly submitButton: Locator;
private readonly errorAlert: Locator;
constructor(private readonly page: Page) {
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorAlert = page.getByRole('alert');
}
async navigate(): Promise<void> {
await this.page.goto('/login');
}
async login(credentials: UserCredentials): Promise<void> {
await this.emailInput.fill(credentials.email);
await this.passwordInput.fill(credentials.password);
await this.submitButton.click();
}
async getErrorMessage(): Promise<string | null> {
if (await this.errorAlert.isVisible()) {
return this.errorAlert.textContent();
}
return null;
}
async isSubmitEnabled(): Promise<boolean> {
return this.submitButton.isEnabled();
}
}Несколько моментов в этом классе стоит объяснить. Модификаторы private readonly на локаторах не дают случайно переназначить их снаружи класса. Запись private readonly page: Page в конструкторе объявляет одноимённое свойство класса и одновременно присваивает его. Возвращаемый тип Promise на getErrorMessage говорит вызывающим что нужно обработать оба случая; компилятор предупредит если использовать результат как будто он никогда не может быть null.
// pages/DashboardPage.ts
import { Page, Locator } from '@playwright/test';
import { Order } from '../types/test-data';
export class DashboardPage {
constructor(private readonly page: Page) {}
async getOrderCount(): Promise<number> {
const rows = await this.page.getByRole('row').count();
return rows - 1; // вычитаем строку заголовка
}
async getFirstOrderStatus(): Promise<Order['status']> {
const statusCell = this.page.getByRole('row').nth(1).getByTestId('status');
const text = await statusCell.textContent();
// Сужение типов гарантирует что возвращаем валидный статус
return text?.toLowerCase() as Order['status'];
}
}Возвращаемый тип Order['status']: indexed access type. Он читает тип свойства status напрямую из интерфейса Order. Изменишь union статусов в Order, возвращаемый тип здесь обновится автоматически.
Дженерик-фикстуры: расширение PlaywrightTestArgs
Здесь TypeScript приносит наибольшую пользу в тестовой инфраструктуре. test.extend Playwright использует дженерики чтобы обеспечить корректные типы кастомных свойств фикстур по всему сьюту.
// fixtures/index.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
import { UserCredentials } from '../types/test-data';
import { adminCredentials } from './test-users';
// Форма всех кастомных фикстур
type AppFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
authenticatedPage: DashboardPage;
};
// Worker-scoped фикстуры: общие для тестов внутри одного воркера
type WorkerFixtures = {
adminUser: UserCredentials;
};
export const test = base.extend<AppFixtures, WorkerFixtures>({
// Page-scoped фикстуры: создаются заново для каждого теста
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.navigate();
await use(loginPage);
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
// Фикстура которая устанавливает аутентифицированную сессию
authenticatedPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.navigate();
await loginPage.login(adminCredentials);
const dashboard = new DashboardPage(page);
await use(dashboard);
},
// Worker-scoped: создаётся один раз на воркер-процесс
adminUser: [async ({}, use) => {
await use(adminCredentials);
}, { scope: 'worker' }],
});
export { expect };Дженерик-параметры говорят TypeScript точно какие типы производят фикстуры. Когда тест деструктурирует { loginPage }, TypeScript знает что это экземпляр LoginPage, а не any. Автодополнение работает. Опечатки в именах методов ловятся на этапе компиляции.
// tests/login.spec.ts
import { test, expect } from '../fixtures';
test('admin login shows dashboard', async ({ loginPage, authenticatedPage }) => {
// TypeScript знает что loginPage — это LoginPage, полное автодополнение
await loginPage.login({ email: 'admin@example.com', password: 'Admin$1!' });
// TypeScript знает что authenticatedPage — это DashboardPage
const count = await authenticatedPage.getOrderCount();
expect(count).toBeGreaterThan(0);
});[!tip]
Создай barrel-экспорт вfixtures/index.tsкоторый реэкспортируетtestиexpectиз файла фикстур. Тесты импортируют из../fixturesвместо@playwright/test. Когда добавляешь новые Page Object-ы в тип фикстуры, каждый тестовый файл который импортирует из../fixturesавтоматически их видит. Изменения в отдельных файлах тестов не нужны.
Utility-типы в тестовом коде
TypeScript поставляется с набором utility-типов которые трансформируют существующие типы в новые. Четыре из них регулярно встречаются в тестовом коде.
Partial делает все свойства T необязательными. Используй когда функция создаёт объект и нужно позволить вызывающим переопределять конкретные свойства:
import { UserProfile } from '../types/test-data';
// Создаёт валидный профиль пользователя с разумными значениями по умолчанию
// Вызывающие могут переопределить любое подмножество свойств
function createTestUser(overrides: Partial<UserProfile> = {}): UserProfile {
return {
id: Math.floor(Math.random() * 10000),
email: `user${Date.now()}@example.com`,
firstName: 'Test',
lastName: 'User',
role: 'viewer',
createdAt: new Date().toISOString(),
...overrides,
};
}
// Использование с дефолтами
const defaultUser = createTestUser();
// Переопределяем только то что важно для этого теста
const adminUser = createTestUser({ role: 'admin', email: 'admin@example.com' });Required делает все свойства обязательными, это противоположность Partial. Используй когда строишь функцию которой нужны все поля:
interface CheckoutForm {
firstName?: string;
lastName?: string;
address?: string;
city?: string;
cardNumber?: string;
}
// Эта функция требует каждое поле — Required<T> делает контракт явным
async function fillCheckoutForm(page: Page, data: Required<CheckoutForm>): Promise<void> {
await page.getByLabel('First name').fill(data.firstName);
await page.getByLabel('Last name').fill(data.lastName);
// ...
}Pick создаёт новый тип только с указанными свойствами. Используй когда функции нужно лишь подмножество большого типа:
import { UserProfile } from '../types/test-data';
// Функции логина нужен только email, не весь профиль
type LoginData = Pick<UserProfile, 'email'>;
async function verifyEmailDisplayed(
page: Page,
user: Pick<UserProfile, 'email' | 'firstName' | 'lastName'>
): Promise<void> {
await expect(page.getByText(user.email)).toBeVisible();
await expect(page.getByText(`${user.firstName} ${user.lastName}`)).toBeVisible();
}Record создаёт тип объекта: ключи типа K, значения типа V. Встречается в фабриках тестовых данных и определениях API-моков:
import { Order } from '../types/test-data';
// Карта именованных тестовых заказов
const testOrders: Record<string, Order> = {
pendingOrder: {
id: 'ord-001',
status: 'pending',
items: [{ productId: 'p1', name: 'Widget', quantity: 2, unitPrice: 9.99 }],
totalAmount: 19.98,
currency: 'USD',
},
confirmedOrder: {
id: 'ord-002',
status: 'confirmed',
items: [{ productId: 'p2', name: 'Gadget', quantity: 1, unitPrice: 49.99 }],
totalAmount: 49.99,
currency: 'USD',
},
};
// TypeScript знает что testOrders['pendingOrder'] — это Order
const pending = testOrders['pendingOrder'];Union-типы для состояний тестов
Многим тестам нужно вести себя по-разному в зависимости от того вошёл ли пользователь, какая у него роль, в каком состоянии находится запись. Union-типы моделируют эти состояния явно.
// Два состояния пользовательской сессии
type SessionState =
| { status: 'authenticated'; userId: number; role: 'admin' | 'editor' | 'viewer' }
| { status: 'guest' };
// Жизненный цикл заказа — каждое состояние соответствует набору допустимых UI-действий
type OrderState =
| { status: 'pending'; canCancel: true; canShip: false }
| { status: 'confirmed'; canCancel: true; canShip: true }
| { status: 'shipped'; canCancel: false; canShip: false; trackingNumber: string }
| { status: 'delivered'; canCancel: false; canShip: false }
| { status: 'cancelled'; canCancel: false; canShip: false; cancelReason: string };Discriminated union-ы позволяют писать хелпер-функции которые ведут себя по-разному в зависимости от состояния:
async function verifyOrderActions(page: Page, order: OrderState): Promise<void> {
const cancelButton = page.getByRole('button', { name: 'Cancel order' });
const shipButton = page.getByRole('button', { name: 'Ship order' });
if (order.status === 'shipped') {
// TypeScript знает что order.trackingNumber существует здесь
await expect(page.getByText(order.trackingNumber)).toBeVisible();
await expect(cancelButton).not.toBeVisible();
} else if (order.status === 'confirmed') {
// TypeScript знает что canShip — true здесь
await expect(shipButton).toBeEnabled();
await expect(cancelButton).toBeEnabled();
}
}Свойство status работает как дискриминант: TypeScript использует его для сужения типа внутри каждой ветки. Когда обращаешься к order.trackingNumber внутри ветки status === 'shipped', TypeScript знает что это свойство существует у этого конкретного варианта.
Сужение типов в тестах
Сужение типов: TypeScript уточняет широкий тип до конкретного внутри условного блока. В тестовом коде это происходит постоянно, не всегда осознанно.
// typeof: различение примитивных типов
function formatDisplayValue(value: string | number | boolean): string {
if (typeof value === 'string') {
return value.trim(); // TypeScript знает что value — string здесь
}
if (typeof value === 'number') {
return value.toFixed(2); // TypeScript знает что value — number здесь
}
return value ? 'Yes' : 'No'; // TypeScript знает что value — boolean здесь
}// in: проверка существования свойства в union-типах
type ApiSuccess = { data: unknown; status: 'success' };
type ApiError = { message: string; code: number; status: 'error' };
type ApiResponse = ApiSuccess | ApiError;
function assertApiSuccess(response: ApiResponse): asserts response is ApiSuccess {
if (response.status === 'error') {
throw new Error(`API error ${response.code}: ${response.message}`);
}
}
async function testOrderCreation(): Promise<void> {
const response: ApiResponse = await createOrder({ productId: 'p1', quantity: 1 });
assertApiSuccess(response); // Бросает ошибку если error, сужает тип если нет
// TypeScript теперь знает что response — это ApiSuccess
expect(response.data).toBeDefined();
}// instanceof: полезно при работе с экземплярами классов
import { LoginPage, DashboardPage } from '../pages';
type AppPage = LoginPage | DashboardPage;
async function takeScreenshotWithContext(appPage: AppPage): Promise<void> {
if (appPage instanceof LoginPage) {
// TypeScript знает что appPage — LoginPage здесь
await appPage.navigate();
} else {
// TypeScript знает что appPage — DashboardPage здесь
const count = await appPage.getOrderCount();
console.log(`Dashboard showing ${count} orders`);
}
}Самый частый паттерн в тестовых фикстурах: сужение null. Обработка того что DOM-методы вроде textContent() возвращают string | null:
async function getHeadingText(page: Page): Promise<string> {
const text = await page.getByRole('heading').first().textContent();
// Без этой проверки TypeScript ошибётся: 'text' может быть null
if (text === null) {
throw new Error('Heading element has no text content');
}
return text; // TypeScript знает что text — string здесь
}Настройки strict mode которые ловят реальные баги
Флаг strict в tsconfig.json включает группу проверок которые можно настраивать по отдельности, но почти всегда стоит включать вместе.
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}Флаг strict: true включает шесть отдельных проверок одновременно. Две которые ловят больше всего багов в тестовом коде: strictNullChecks и noImplicitAny.
strictNullChecks делает null и undefined отдельными от всех других типов. Без него string и string | null считаются одним и тем же, что полностью отключает смысл null-проверок.
// strictNullChecks: false (опасно — дефолт до strict mode)
const text: string = null; // Нет ошибки. Падение в рантайме при вызове text.trim()
// strictNullChecks: true
const text: string = null; // Error: Type 'null' is not assignable to type 'string'
const safeText: string | null = null; // Нормально — ты объявил возможность nullnoImplicitAny требует явных типов везде где TypeScript не может их вывести. В тестовом коде это ловит нетипизированные параметры функций до того как они становятся багами:
// noImplicitAny поймает это
function fillForm(data) { // Error: Parameter 'data' implicitly has an 'any' type
// data может быть чем угодно — никакой защиты
}
// Правильно: явный тип заставляет описать форму
function fillForm(data: RegistrationForm) {
// TypeScript валидирует каждое обращение к полю
}noUncheckedIndexedAccess стоит включать отдельно. Он добавляет | undefined к доступу по индексу в массивах и объектах, потому что array[0] на пустом массиве возвращает undefined в рантайме:
// Без noUncheckedIndexedAccess
const rows: string[] = [];
const first: string = rows[0]; // TypeScript пропускает, но rows[0] — undefined в рантайме
// С noUncheckedIndexedAccess
const rows: string[] = [];
const first: string | undefined = rows[0]; // TypeScript заставляет обработать undefined
if (first !== undefined) {
console.log(first.toUpperCase()); // Теперь безопасно
}[!warning]
Добавление этих strict-настроек к существующей миграции с JavaScript на TypeScript разом покажет много ошибок. Если конвертируешь существующий проект: сначала добавь"strict": trueи исправь эти ошибки, потом включайnoUncheckedIndexedAccess. Попытка исправить всё одновременно делает миграцию бесконечной.
exactOptionalPropertyTypes полезен для фабрик тестовых данных. Без него явное присвоение undefined необязательному свойству трактуется так же как его отсутствие, хотя в JSON это разные вещи:
interface UpdateRequest {
email?: string;
firstName?: string;
}
// exactOptionalPropertyTypes: true
const partial: UpdateRequest = { email: undefined }; // Error: undefined не присваивается к string
const correct: UpdateRequest = { email: 'new@example.com' }; // Нормально: включаем только то что обновляемЧастые вопросы
Когда использовать дженерик-параметр вместо конкретного типа?
Когда пишешь функцию или фикстуру которая работает с несколькими типами, но нужно сохранить связь между типом входных и выходных данных. Если пишешь фабричную функцию которая возвращает тот же тип что получила параметром, это дженерик. Если функция всегда работает с UserProfile, используй UserProfile напрямую. Не добавляй дженерики ради гибкости которая пока не нужна.
Безопасно ли использовать оператор non-null assertion (!) в тестах?
Иногда да. Когда по контексту знаешь что значение не может быть null, но TypeScript не может это проверить (например, после ассерта что элемент видим), использование value! разумно. Риск в том чтобы использовать его для заглушки реальных ошибок. Если часто пишешь !, это сигнал что типы не точно описывают данные.
Нужно ли явно типизировать каждую переменную или можно полагаться на вывод?
Предпочитай вывод там где TypeScript может однозначно определить тип: const user = createTestUser() не нуждается в аннотации если у createTestUser есть возвращаемый тип. Добавляй явные аннотации на параметрах функций, возвращаемых типах и свойствах классов. Это даёт преимущества проверки типов на границах без засорения каждой строки.
В чём разница между type Foo = Bar и interface Foo extends Bar?
Оба создают тип с именем Foo включающий все свойства Bar. Практическая разница: расширение интерфейса чище когда добавляешь свойства к существующей форме и хочешь чтобы намерение было понятно. Пересечение типов (Foo = Bar & { extra: string }) гибче, потому что работает с любым типом, не только с интерфейсами. В тестовом коде оба варианта нормальные.
Наша команда не использует TypeScript. Стоит ли добавлять только в тестовый сьют?
Да. Тестовый сьют зачастую лучшее место для введения TypeScript в JavaScript-команде: область ограничена, польза очевидна сразу. Playwright поддерживает TypeScript нативно, тестовые файлы самостоятельны, а типизированные Page Object-ы и определения фикстур служат живой документацией форм данных приложения. Команды часто начинают с типизированных тестов и потом расширяют TypeScript на код приложения.
→ See also: TypeScript интерфейсы и типы для Page Object Model | Лучшие практики TypeScript в тестовом коде Playwright | Page Object Model в Playwright: от хаоса к поддерживаемым тестам