Quando você tipar suas fixtures com test.extend, o TypeScript sabe que loginPage é uma instância de LoginPage em qualquer teste que importar o arquivo. Typos em nomes de método aparecem no editor antes de rodar a suite.

Type aliases vs interfaces: a regra prática

Todo tutorial de TypeScript eventualmente explica que interfaces e type aliases se sobrepõem bastante. Os dois descrevem formas de objetos. Os dois suportam extensão. Os dois funcionam nos mesmos lugares na maior parte do tempo. A regra útil para código de testes é mais simples do que a especificação da linguagem sugere.

Use interfaces quando estiver descrevendo uma forma de dado que outros tipos vão estender. Use type aliases quando estiver construindo unions, interseções ou qualquer coisa que não seja uma forma simples de objeto.

// Interface: uma forma que faz sentido estender
interface User {
  id: number;
  email: string;
  role: 'admin' | 'viewer';
}

// Estendendo uma interface: natural, legível
interface AdminUser extends User {
  permissions: string[];
}

// Type alias: a escolha certa para unions
type UserRole = 'admin' | 'viewer' | 'guest';

// Type alias: a escolha certa para interseções
type AuthenticatedUser = User & { token: string; expiresAt: Date };

// Type alias: a escolha certa para primitivos ou tuplas
type UserId = number;
type Credentials = [string, string]; // [email, password]

O motivo de interfaces funcionarem melhor para formas extensíveis é o declaration merging: você pode declarar a mesma interface duas vezes e o TypeScript une as declarações. Isso é útil em arquivos de fixture onde partes diferentes da suite adicionam propriedades ao mesmo tipo de fixture. Type aliases não suportam merging; uma declaração duplicada é um erro.

[!note]
Interfaces e type aliases não geram nenhuma saída em JavaScript. Pertencem apenas à camada TypeScript e são removidos durante a compilação. Não há custo em runtime para nenhum dos dois.

O erro a evitar: ficar debatendo qual usar. Use interfaces para formas de objetos, type aliases para todo o resto, e siga em frente. Os dois funcionam corretamente de qualquer forma. É uma decisão de organização de código, não de correção.

Tipando dados de teste: usuários, pedidos e formulários

As fixtures de teste dependem dos dados. Um objeto de usuário com a forma errada quebra cinco testes silenciosamente antes de alguém perceber. TypeScript torna a forma explícita.

// 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; // String de data ISO da 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;
}

Com essas interfaces definidas, seus arquivos de dados de teste se tornam autodocumentados e verificados pelo compilador:

// 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 detecta isso imediatamente — 'superadmin' não é um role válido
export const invalidUser: UserProfile = {
  id: 99,
  email: 'super@example.com',
  firstName: 'Super',
  lastName: 'Admin',
  role: 'superadmin', // Erro: Type '"superadmin"' is not assignable to type '"admin" | "editor" | "viewer"'
  createdAt: '2025-03-01T00:00:00Z',
};

Quando o contrato da API muda e role ganha um novo valor, adicione na interface uma vez. O compilador marca todos os lugares nos seus testes que não tratam o novo caso.

Tipando classes Page Object

Classes Page Object se beneficiam de tipagem explícita mais do que qualquer outra parte de uma suite de testes. Um Page Object tipado documenta sua própria API: a assinatura do construtor, o que cada método aceita e o que ele retorna.

// 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();
  }
}

Alguns pontos dessa classe merecem explicação. Os modificadores private readonly nos locators impedem que sejam reatribuídos acidentalmente de fora da classe. O parâmetro do construtor private readonly page: Page é uma forma abreviada do TypeScript para declarar uma propriedade e atribuí-la em um único passo. O tipo de retorno Promise em getErrorMessage informa ao chamador que ele precisa tratar os dois casos. O compilador vai avisar se o resultado for usado como se nunca pudesse ser 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; // subtrai a linha de cabeçalho
  }

  async getFirstOrderStatus(): Promise<Order['status']> {
    const statusCell = this.page.getByRole('row').nth(1).getByTestId('status');
    const text = await statusCell.textContent();
    // Type narrowing garante que retornamos um status válido
    return text?.toLowerCase() as Order['status'];
  }
}

O tipo de retorno Order['status'] é um indexed access type: ele lê o tipo da propriedade status diretamente da interface Order. Se você alterar a union de status em Order, o tipo de retorno aqui se atualiza automaticamente.

Fixtures genéricas: estendendo PlaywrightTestArgs

É aqui que o TypeScript paga seu maior dividendo na infraestrutura de testes. O test.extend do Playwright usa generics para garantir que suas propriedades de fixture customizadas tenham os tipos corretos em toda a suite.

// 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';

// A forma de todas as fixtures customizadas
type AppFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
  authenticatedPage: DashboardPage;
};

// Fixtures de escopo worker: compartilhadas entre testes no mesmo worker
type WorkerFixtures = {
  adminUser: UserCredentials;
};

export const test = base.extend<AppFixtures, WorkerFixtures>({
  // Fixtures de escopo page: recriadas para cada teste
  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await loginPage.navigate();
    await use(loginPage);
  },

  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },

  // Fixture que configura uma sessão autenticada
  authenticatedPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await loginPage.navigate();
    await loginPage.login(adminCredentials);
    const dashboard = new DashboardPage(page);
    await use(dashboard);
  },

  // Escopo worker: criada uma vez por processo worker
  adminUser: [async ({}, use) => {
    await use(adminCredentials);
  }, { scope: 'worker' }],
});

export { expect };

Os parâmetros genéricos informam ao TypeScript exatamente quais tipos suas fixtures produzem. Quando um teste desestrutura { loginPage }, o TypeScript sabe que é uma instância de LoginPage, não any. Autocomplete funciona. Typos em nomes de método são detectados em tempo de compilação.

// tests/login.spec.ts
import { test, expect } from '../fixtures';

test('login de admin mostra dashboard', async ({ loginPage, authenticatedPage }) => {
  // TypeScript sabe que loginPage é LoginPage — autocomplete completo
  await loginPage.login({ email: 'admin@example.com', password: 'Admin$1!' });

  // TypeScript sabe que authenticatedPage é DashboardPage
  const count = await authenticatedPage.getOrderCount();
  expect(count).toBeGreaterThan(0);
});

[!tip]
Crie um barrel export em fixtures/index.ts que re-exporta test e expect do seu arquivo de fixture. Os testes importam de ../fixtures em vez de @playwright/test. Quando você adicionar novos page objects ao tipo de fixture, todos os arquivos de teste que importam de ../fixtures os verão automaticamente. Nenhuma mudança necessária nos arquivos de teste individuais.

Tipos utilitários em código de testes

O TypeScript tem um conjunto de tipos utilitários que transformam tipos existentes em novos. Quatro deles aparecem com frequência em código de testes.

Partial torna todas as propriedades de T opcionais. Use quando uma função cria um objeto e você quer permitir que os chamadores sobrescrevam propriedades específicas:

import { UserProfile } from '../types/test-data';

// Cria um perfil de usuário válido com defaults razoáveis
// Os chamadores podem sobrescrever qualquer subconjunto de propriedades
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,
  };
}

// Usar defaults
const defaultUser = createTestUser();

// Sobrescrever apenas o que importa para este teste
const adminUser = createTestUser({ role: 'admin', email: 'admin@example.com' });

Required torna todas as propriedades obrigatórias, o oposto de Partial. Use quando estiver construindo uma função que precisa de todos os campos presentes:

interface CheckoutForm {
  firstName?: string;
  lastName?: string;
  address?: string;
  city?: string;
  cardNumber?: string;
}

// Esta função precisa de todos os campos — Required<T> torna o contrato explícito
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 cria um novo tipo com apenas as propriedades que você nomear. Use quando uma função precisa de apenas um subconjunto de um tipo maior:

import { UserProfile } from '../types/test-data';

// A função de login só precisa de email e password, não do perfil completo
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 cria um tipo de objeto onde todas as chaves são do tipo K e todos os valores são do tipo V. Aparece em factories de dados de teste e definições de mock de API:

import { Order } from '../types/test-data';

// Um mapa de pedidos de teste nomeados — chaves são strings, valores são objetos Order
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 sabe que testOrders['pendingOrder'] é Order
const pending = testOrders['pendingOrder'];

Union types para estados de teste

Muitos testes precisam se comportar de forma diferente dependendo de o usuário estar logado, qual role ele tem, ou em que estado um registro se encontra. Union types modelam esses estados explicitamente.

// Os dois estados em que uma sessão de usuário pode estar
type SessionState =
  | { status: 'authenticated'; userId: number; role: 'admin' | 'editor' | 'viewer' }
  | { status: 'guest' };

// Ciclo de vida de um pedido — cada estado mapeia para um conjunto de ações de UI válidas
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 };

Esses discriminated unions permitem escrever funções auxiliares que se comportam de forma diferente com base no estado:

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 sabe que order.trackingNumber existe aqui
    await expect(page.getByText(order.trackingNumber)).toBeVisible();
    await expect(cancelButton).not.toBeVisible();
  } else if (order.status === 'confirmed') {
    // TypeScript sabe que canShip é true aqui
    await expect(shipButton).toBeEnabled();
    await expect(cancelButton).toBeEnabled();
  }
}

A propriedade status funciona como discriminante: o TypeScript a usa para refinar o tipo dentro de cada branch. Quando você acessa order.trackingNumber dentro do branch status === 'shipped', o TypeScript sabe que essa propriedade existe nessa variante específica.

Type narrowing em testes

Type narrowing é como o TypeScript refina um tipo amplo para um específico dentro de um bloco condicional. Você faz isso o tempo todo em código de testes sem necessariamente reconhecer como um conceito formal.

// Narrowing com typeof: distinguindo tipos primitivos
function formatDisplayValue(value: string | number | boolean): string {
  if (typeof value === 'string') {
    return value.trim(); // TypeScript sabe que value é string aqui
  }
  if (typeof value === 'number') {
    return value.toFixed(2); // TypeScript sabe que value é number aqui
  }
  return value ? 'Yes' : 'No'; // TypeScript sabe que value é boolean aqui
}

// Narrowing com in: verificando existência de propriedade em union types
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); // Lança se houver erro, refina o tipo se não houver
  // TypeScript agora sabe que response é ApiSuccess
  expect(response.data).toBeDefined();
}

// Narrowing com instanceof: útil ao trabalhar com instâncias de classes
import { LoginPage, DashboardPage } from '../pages';

type AppPage = LoginPage | DashboardPage;

async function takeScreenshotWithContext(appPage: AppPage): Promise<void> {
  if (appPage instanceof LoginPage) {
    // TypeScript sabe que appPage é LoginPage aqui
    await appPage.navigate();
  } else {
    // TypeScript sabe que appPage é DashboardPage aqui
    const count = await appPage.getOrderCount();
    console.log(`Dashboard mostrando ${count} pedidos`);
  }
}

O padrão que aparece com mais frequência em fixtures de teste é o narrowing de null: lidar com o fato de que métodos do DOM como textContent() retornam string | null:

async function getHeadingText(page: Page): Promise<string> {
  const text = await page.getByRole('heading').first().textContent();
  
  // Sem essa verificação, o TypeScript dá erro: 'text' pode ser null
  if (text === null) {
    throw new Error('O elemento heading não tem conteúdo de texto');
  }
  
  return text; // TypeScript sabe que text é string aqui
}

Configurações de strict mode que detectam bugs reais

A flag strict no tsconfig.json ativa um grupo de verificações que são configuráveis individualmente, mas quase sempre valem ser habilitadas juntas.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}

strict: true é um atalho que ativa seis verificações individuais. As duas que detectam mais bugs em código de testes são strictNullChecks e noImplicitAny. strictNullChecks torna null e undefined separados de todos os outros tipos. Sem ele, string e string | null são tratados da mesma forma, o que desativa o ponto inteiro da verificação de null.

// Com strictNullChecks: false (perigoso — padrão antes do strict mode)
const text: string = null; // Sem erro. Explode em runtime quando você chama text.trim()

// Com strictNullChecks: true
const text: string = null; // Erro: Type 'null' is not assignable to type 'string'
const safeText: string | null = null; // Correto — você declarou a possibilidade

noImplicitAny exige tipos explícitos onde o TypeScript não consegue inferir. Em código de testes, isso detecta parâmetros de função sem tipo antes de virarem bugs:

// noImplicitAny detecta isso
function fillForm(data) { // Erro: Parameter 'data' implicitly has an 'any' type
  // data pode ser qualquer coisa — sem proteção
}

// Correto: tipo explícito força você a definir a forma
function fillForm(data: RegistrationForm) {
  // TypeScript valida cada acesso a campo
}

noUncheckedIndexedAccess vale habilitar separadamente. Ele adiciona | undefined ao acesso de elementos de array e assinaturas de índice de objetos, porque acessar array[0] em um array vazio retorna undefined em runtime:

// Sem noUncheckedIndexedAccess
const rows: string[] = [];
const first: string = rows[0]; // TypeScript permite, mas rows[0] é undefined em runtime

// Com noUncheckedIndexedAccess
const rows: string[] = [];
const first: string | undefined = rows[0]; // TypeScript força você a tratar undefined
if (first !== undefined) {
  console.log(first.toUpperCase()); // Seguro agora
}

[!warning]
Adicionar essas configurações de strict a uma migração existente de JavaScript para TypeScript vai produzir muitos erros de uma vez. Se estiver convertendo um projeto existente, adicione "strict": true primeiro e corrija esses erros antes de habilitar noUncheckedIndexedAccess. Tentar corrigir todos os erros simultaneamente faz a migração parecer interminável.

A flag exactOptionalPropertyTypes é útil para factories de dados de teste. Sem ela, definir uma propriedade opcional como undefined explicitamente é tratado da mesma forma que omiti-la, o que não é verdade quando a propriedade é serializada para JSON:

interface UpdateRequest {
  email?: string;
  firstName?: string;
}

// Com exactOptionalPropertyTypes: true
const partial: UpdateRequest = { email: undefined }; // Erro: undefined não é atribuível a string
const correct: UpdateRequest = { email: 'new@example.com' }; // Correto — inclua apenas o que está atualizando

FAQ

Quando usar um parâmetro de tipo genérico em vez de um tipo específico?

Quando estiver escrevendo uma função ou fixture que funciona com múltiplos tipos mas precisa preservar a relação entre o tipo de entrada e o tipo de saída. Se você está escrevendo uma função factory que retorna o tipo que você passa como parâmetro, isso é um generic. Se está escrevendo uma função que sempre trabalha com UserProfile, use UserProfile diretamente. Não adicione generics para uma flexibilidade que você ainda não precisa.

É seguro usar o operador de non-null assertion (!) em testes?

Às vezes, sim. Quando você sabe que um valor não pode ser null mas o TypeScript não consegue verificar, usar value! é razoável. Por exemplo: depois de assegurar que um elemento está visível. O risco é usá-lo para silenciar erros legítimos. Se você se pegar escrevendo ! com frequência, é sinal de que seus tipos não descrevem com precisão seus dados.

Devo tipar todas as variáveis ou posso deixar o TypeScript inferir?

Prefira inferência onde o TypeScript consegue determinar o tipo claramente: const user = createTestUser() não precisa de anotação se createTestUser tem um tipo de retorno. Adicione anotações explícitas em parâmetros de função, tipos de retorno e propriedades de classe. Isso dá os benefícios da verificação de tipos nas fronteiras sem poluir cada linha.

Qual a diferença entre type Foo = Bar e interface Foo extends Bar?

Os dois criam um tipo chamado Foo que inclui todas as propriedades de Bar. A diferença prática: a extensão de interface é mais limpa quando você está adicionando propriedades a uma forma existente e quer que a intenção fique clara. A interseção de tipos (Foo = Bar & { extra: string }) é mais flexível porque funciona com qualquer tipo, não apenas interfaces. Em código de testes, os dois funcionam. Use o que lê mais naturalmente.

Meu time não usa TypeScript. Vale adicionar só na suite de testes?

Vale. A suite de testes é frequentemente o melhor lugar para introduzir TypeScript em um time JavaScript, porque o escopo é delimitado e os benefícios são imediatos. O Playwright suporta TypeScript nativamente, arquivos de teste são independentes, e os Page Objects e definições de fixture tipados funcionam como documentação viva das formas de dados da aplicação. Times frequentemente começam com testes tipados e depois expandem o TypeScript para o código da aplicação.

→ Veja também: Interfaces e Tipos TypeScript para o Page Object Model | Melhores Práticas de TypeScript no Código de Testes Playwright | Page Object Model no Playwright: Do Caos à Manutenibilidade