strictNullChecks в TypeScript заставляет обрабатывать случай когда textContent() элемента возвращает null, вместо того чтобы text.length падало посреди теста с Cannot read properties of null. Типизированные возвращаемые значения методов page-объектов ловят другую распространённую ошибку: loginWith() возвращающий Page вместо DashboardPage означает что тесты не могут определить на какой странице оказались. Эта статья разбирает настройки tsconfig с наибольшим влиянием на качество тестов, типизированные фикстуры, unknown вместо any для данных API-ответов, и утилитарные типы для разделения того что отправляешь серверу и того что получаешь обратно.

Строгие настройки tsconfig для тестового кода

Начни со строгой конфигурации TypeScript:

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "exactOptionalPropertyTypes": true
  },
  "include": ["tests/**/*", "fixtures/**/*", "pages/**/*"]
}

strictNullChecks самая ценная настройка для тестового кода. Она заставляет явно обрабатывать null и undefined. Предотвращает ошибки Cannot read properties of null в рантайме. noUnusedLocals и noUnusedParameters устраняют мёртвый код который молча накапливается в тестовых файлах.

Типизация тестовых данных

Нетипизированные тестовые данные: распространённая ошибка. Полагаешься на правильную форму объекта в момент написания, потом имя поля меняется и тесты молча проходят на устаревших данных.

// Плохо
const user = {
  email: 'test@example.com',
  password: 'pass123',
};

// Хорошо — тип гарантирует что форма остаётся корректной
interface TestUser {
  email: string;
  password: string;
  role: 'admin' | 'user' | 'viewer';
}

const testUser: TestUser = {
  email: 'test@example.com',
  password: 'pass123',
  role: 'user',
};

Когда добавляешь новое обязательное поле в TestUser, TypeScript сразу помечает все объекты тестовых данных которые его не включают.

Типизация методов page-объектов

Методы page-объектов которые переходят на новую страницу должны возвращать тип нового page-объекта:

export class LoginPage {
  // Тип возврата делает навигацию явной
  async loginWith(email: string, password: string): Promise<DashboardPage> {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
    await this.page.waitForURL('/dashboard');
    return new DashboardPage(this.page);
  }

  // void для действий которые остаются на той же странице
  async fillEmail(email: string): Promise<void> {
    await this.emailInput.fill(email);
  }

  // Тип возврата для извлечения данных
  async getErrorMessage(): Promise<string | null> {
    if (await this.errorMessage.isVisible()) {
      return this.errorMessage.textContent();
    }
    return null;
  }
}

Тип возврата Promise заставляет вызывающий код обрабатывать случай null. Больше не будет ситуаций когда const text = await page.getErrorMessage(); expect(text.length).toBeGreaterThan(0) падает на null.

Типизированные фикстуры

Типизированные фикстуры предотвращают их неправильное использование:

// fixtures/auth.ts
import { test as base } from '@playwright/test';

interface AuthFixtures {
  userPage: Page; // Предаутентифицирован как обычный пользователь
  adminPage: Page; // Предаутентифицирован как admin
  authToken: string;
}

export const test = base.extend<AuthFixtures>({
  authToken: async ({ request }, use) => {
    const response = await request.post('/api/auth/login', {
      data: { email: process.env.TEST_USER_EMAIL!, password: process.env.TEST_USER_PASSWORD! },
    });
    expect(response.ok()).toBeTruthy();
    const { token } = await response.json() as { token: string };
    await use(token);
  },

  userPage: async ({ page, authToken }, use) => {
    await page.context().addCookies([{
      name: 'auth_token',
      value: authToken,
      domain: 'localhost',
      path: '/',
    }]);
    await use(page);
  },
});

TypeScript гарантирует что userPage и adminPage доступны только в тестах использующих это расширение фикстур, но не в тестах с базовым импортом test.

Константные утверждения для селекторов

Не дублируй строки селекторов между тестами:

// selectors.ts
export const Selectors = {
  login: {
    emailInput: 'label:has-text("Email") >> input',
    passwordInput: 'label:has-text("Password") >> input',
    submitButton: 'button[type="submit"]',
  },
  checkout: {
    cartTotal: '[data-testid="cart-total"]',
    placeOrderButton: 'button:has-text("Place order")',
  },
} as const; // 'as const' делает строки литеральными типами — исключает случайную мутацию

Лучше: использовать getByRole, getByLabel и другие методы в page-объектах вместо строковых селекторов вообще. Но когда строковые селекторы необходимы, as const предотвращает случайное переприсваивание.

Дискриминированные объединения для API-ответов

Когда тестируешь API которые возвращают разные структуры в зависимости от успеха или ошибки:

type ApiSuccess<T> = {
  success: true;
  data: T;
};

type ApiError = {
  success: false;
  error: string;
  code: number;
};

type ApiResponse<T> = ApiSuccess<T> | ApiError;

// Сужение типов в тестах
const body = await response.json() as ApiResponse<{ orderId: string }>;

if (body.success) {
  expect(body.data.orderId).toBeTruthy(); // TypeScript знает что data существует
} else {
  expect(body.code).toBe(422); // TypeScript знает что error и code существуют
}

Избегай any, используй unknown для нетипизированных данных

Когда получаешь данные от API и типа ещё нет, используй unknown вместо any:

// Плохо — any отключает всю проверку типов
const body: any = await response.json();
body.nonExistentField.deeply.nested; // Нет ошибки, молча неправильно

// Хорошо — unknown заставляет проверить перед использованием
const body: unknown = await response.json();

// Обязательно проверить перед доступом
if (typeof body === 'object' && body !== null && 'orderId' in body) {
  console.log((body as { orderId: string }).orderId);
}

// Лучше: использовать type guard
function isOrderResponse(data: unknown): data is { orderId: string; status: string } {
  return typeof data === 'object' && data !== null && 'orderId' in data;
}

if (isOrderResponse(body)) {
  expect(body.orderId).toBeTruthy(); // Типизировано
}

Утилитарные типы для тестовых данных

Встроенные утилитарные типы TypeScript сокращают дублирование в типах тестовых данных:

interface User {
  id: string;
  email: string;
  password: string;
  role: 'admin' | 'user';
  createdAt: Date;
}

// Входные данные для создания — без id и createdAt (генерирует сервер)
type CreateUserInput = Omit<User, 'id' | 'createdAt'>;

// Входные данные для обновления — все поля опциональны
type UpdateUserInput = Partial<Pick<User, 'email' | 'role'>>;

// Объект для проверки — только верифицируемые поля
type UserAssertion = Pick<User, 'email' | 'role'>;

Эти типы делают работу с тестовыми данными явной: то что отправляешь серверу и то что получаешь обратно имеют разную форму, и TypeScript применяет это различие принудительно.

→ See also: TypeScript интерфейсы и типы для Page Object Model | Типы, интерфейсы и обобщения в TypeScript для тестовых фикстур | Page Object Model в Playwright: от хаоса к поддерживаемым тестам