Передать { productId: 123 } в метод который ожидает { id: number; name: string; price: number }: ошибка молча проглатывается в рантайме. С интерфейсом TypeScript поймает неверное имя поля прямо в редакторе, до запуска теста. Изменил интерфейс, и все вызовы которые не совпадают сразу подсвечиваются. Статья разбирает интерфейсы для тестовых данных, сигнатур методов Page Object, API-ответов и типизированных фикстур, плюс разницу между type и interface, и дженерик-паттерны для переиспользуемых форм вроде пагинированных ответов.

Зачем TypeScript в POM

Без типов:

class ProductPage {
  async addToCart(product) {  // product — что угодно
    await this.page.click(`[data-id="${product.id}"]`);
  }
}

// На месте вызова нет подсказок, легко передать не то
await productPage.addToCart({ productId: 123 }); // опечатка в имени поля

С интерфейсом:

interface Product {
  id: number;
  name: string;
  price: number;
}

class ProductPage {
  async addToCart(product: Product) {
    await this.page.click(`[data-id="${product.id}"]`);
  }
}

// Редактор поймает сразу
await productPage.addToCart({ productId: 123 }); // Error: productId не существует в Product
await productPage.addToCart({ id: 123, name: 'Laptop', price: 999 }); // корректно

Базовый синтаксис интерфейса

Интерфейс описывает форму объекта:

interface User {
  id: number;
  email: string;
  role: 'admin' | 'member' | 'viewer';
  isActive: boolean;
  createdAt?: string;  // Необязательное поле (знак ?)
}

Обязательные поля должны присутствовать всегда. Необязательные поля (?) могут отсутствовать. Union-типы ('admin' | 'member') разрешают только эти конкретные значения.

Интерфейсы для тестовых данных

Самое частое применение: типизированные объекты с тестовыми данными.

// data/users.ts
export interface UserCredentials {
  email: string;
  password: string;
}

export interface UserProfile extends UserCredentials {
  name: string;
  role: 'admin' | 'member';
}

export const TEST_USERS = {
  admin: {
    email: 'admin@test.com',
    password: 'AdminPass1',
    name: 'Test Admin',
    role: 'admin' as const,
  } satisfies UserProfile,
  
  member: {
    email: 'member@test.com',
    password: 'MemberPass1',
    name: 'Test Member',
    role: 'member' as const,
  } satisfies UserProfile,
};

Ключевое слово satisfies (TypeScript 4.9+) проверяет что объект соответствует интерфейсу, но сохраняет литеральные типы.

Интерфейсы для Page Object

// pages/types.ts
import { Page, Locator } from '@playwright/test';

export interface PageObject {
  page: Page;
  navigate(): Promise<void>;
}

export interface LoginPageInterface extends PageObject {
  emailInput: Locator;
  passwordInput: Locator;
  submitButton: Locator;
  errorMessage: Locator;
  login(email: string, password: string): Promise<void>;
}

// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
import { LoginPageInterface } from './types';

export class LoginPage implements LoginPageInterface {
  page: Page;
  emailInput: Locator;
  passwordInput: Locator;
  submitButton: Locator;
  errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput    = page.getByTestId('email-input');
    this.passwordInput = page.getByTestId('password-input');
    this.submitButton  = page.getByTestId('submit-btn');
    this.errorMessage  = page.getByTestId('error-message');
  }

  async navigate(): Promise<void> {
    await this.page.goto('/login');
  }

  async login(email: string, password: string): Promise<void> {
    await this.navigate();
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

Ключевое слово implements говорит TypeScript: «этот класс обязан реализовать всё что требует интерфейс». Забыл метод, получаешь ошибку компиляции.

type vs interface

Оба описывают форму объекта. Практические отличия:

| | interface | type |

|-|-------------|--------|

| Расширение | ключевое слово extends | пересечение через & |

| Declaration merging | Да (поля можно дописывать в нескольких местах) | Нет |

| Union-типы | Нет | Да |

| Для объектов | предпочтительно | тоже работает |

Правило простое: interface для форм объектов (особенно Page Object и модели данных), type для union-ов, примитивов и сложных комбинаций.

// interface: хорошо подходит для форм объектов
interface ProductFilter {
  category?: string;
  minPrice?: number;
  maxPrice?: number;
  inStock?: boolean;
}

// type: нужен для union-ов
type TestEnvironment = 'local' | 'staging' | 'production';
type Callback = () => void | Promise<void>;
type UserOrAdmin = User | Admin;

Дженерик-интерфейсы

Дженерики позволяют писать гибкие интерфейсы:

// Пагинированный API-ответ для любого типа данных
interface PaginatedResponse<T> {
  data: T[];
  total: number;
  page: number;
  pageSize: number;
  totalPages: number;
}

// Использование с конкретными типами
type UsersResponse    = PaginatedResponse<User>;
type ProductsResponse = PaginatedResponse<Product>;
type OrdersResponse   = PaginatedResponse<Order>;

// В тесте
const response = await request.get('/api/users?page=1&limit=10');
const body: UsersResponse = await response.json();

expect(body.data).toHaveLength(10);
expect(body.totalPages).toBeGreaterThan(0);
// TypeScript знает что body.data[0] — это User
expect(body.data[0].email).toBeTruthy();

Интерфейсы для API-ответов

Типизируй API-ответы: получишь автодополнение и защиту от опечаток в именах полей.

// types/api.ts
export interface LoginResponse {
  token: string;
  expiresAt: string;
  user: {
    id: number;
    email: string;
    role: string;
  };
}

export interface ErrorResponse {
  error: string;
  message: string;
  field?: string;  // Присутствует при ошибках валидации
}

export interface CreateUserRequest {
  email: string;
  password: string;
  name?: string;
  role?: 'admin' | 'member';
}

// В тестах
test('login returns correct response', async ({ request }) => {
  const response = await request.post('/api/auth/login', {
    data: { email: 'user@test.com', password: 'ValidPass1' },
  });
  
  const body: LoginResponse = await response.json();
  
  // TypeScript знает точно что есть на объекте body
  expect(body.token).toBeTruthy();
  expect(body.user.id).toBeGreaterThan(0);
  expect(body.user.role).toBe('member');
});

Наследование интерфейсов

extends позволяет строить на основе существующих интерфейсов:

interface BaseEntity {
  id: number;
  createdAt: string;
  updatedAt: string;
}

interface User extends BaseEntity {
  email: string;
  name: string;
  role: 'admin' | 'member';
}

interface Product extends BaseEntity {
  name: string;
  price: number;
  category: string;
  inStock: boolean;
}

User и Product оба получают id, createdAt, updatedAt из BaseEntity, плюс собственные поля.

Интерфейсы для фикстур

Типизируй кастомные фикстуры Playwright:

// fixtures/types.ts
import { Page } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';

export interface AppFixtures {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
  authenticatedPage: Page;
}

export interface TestUser {
  id: number;
  email: string;
  token: string;
  role: 'admin' | 'member';
}

export interface ApiFixtures {
  testUser: TestUser;
  adminToken: string;
}

// fixtures/index.ts
import { test as base } from '@playwright/test';
import { AppFixtures, ApiFixtures } from './types';

export const test = base.extend<AppFixtures & ApiFixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  // ...
});

tsconfig.json для Playwright

Включи строгий режим для полноценной проверки типов:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "strict": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "outDir": "./dist",
    "baseUrl": ".",
    "paths": {
      "@fixtures": ["./fixtures/index.ts"],
      "@pages/*": ["./pages/*"],
      "@data/*": ["./data/*"]
    }
  },
  "include": ["**/*.ts"],
  "exclude": ["node_modules"]
}

Конфигурация paths позволяет использовать алиасы при импорте:

import { test } from '@fixtures';             // вместо '../../fixtures/index'
import { LoginPage } from '@pages/LoginPage'; // вместо '../../pages/LoginPage'

Итог

  • interface для Page Object-ов, тестовых данных и форм API-ответов
  • type для union-типов и сложных комбинаций типов
  • extends для построения на основе существующих интерфейсов (принцип DRY)
  • Дженерик-интерфейсы (PaginatedResponse) для переиспользуемых форм
  • Типизируй API-ответы, чтобы TypeScript помогал верифицировать поля
  • implements в классах Page Object закрепляет контракт

TypeScript в автоматизации тестов: не про церемонии. Про то чтобы редактор поймал product.productId вместо product.id до того как потратишь 10 минут отлаживая почему клик не срабатывает.

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