O strictNullChecks do TypeScript força você a tratar o caso em que o texto de um elemento retorna null. Sem isso, text.length quebra no meio do teste com Cannot read properties of null. Métodos de page object com tipos de retorno explícitos evitam o outro erro comum. loginWith() retornando Page em vez de DashboardPage faz com que os testes não saibam em qual página chegaram.

Configurações estritas do tsconfig para código de teste

Comece com uma configuração TypeScript estrita:

// 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 é a configuração mais valiosa para código de teste. Ela força o tratamento explícito de null e undefined, prevenindo erros Cannot read properties of null em runtime. noUnusedLocals e noUnusedParameters eliminam código morto que se acumula silenciosamente nos arquivos de teste.

Tipar dados de teste

Dados de teste sem tipo são um erro comum. Você confia que o formato do objeto está correto no momento de escrever, um nome de campo muda, e os testes continuam passando com dados desatualizados.

// Ruim
const user = {
  email: 'test@example.com',
  password: 'pass123',
};

// Bom — o tipo garante que o formato permaneça correto
interface TestUser {
  email: string;
  password: string;
  role: 'admin' | 'user' | 'viewer';
}

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

Quando você adiciona um novo campo obrigatório a TestUser, o TypeScript imediatamente aponta cada objeto de dados de teste que não o inclui.

Tipar métodos de page object

Métodos de page object que navegam para uma nova página devem retornar o tipo da nova página:

export class LoginPage {
  // Tipo de retorno torna a navegação explícita
  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 para ações que ficam na mesma página
  async fillEmail(email: string): Promise<void> {
    await this.emailInput.fill(email);
  }

  // Tipo de retorno para extração de dados
  async getErrorMessage(): Promise<string | null> {
    if (await this.errorMessage.isVisible()) {
      return this.errorMessage.textContent();
    }
    return null;
  }
}

O tipo de retorno Promise força quem chama o método a tratar o caso null. Sem mais const text = await page.getErrorMessage(); expect(text.length).toBeGreaterThan(0) quebrando com null.

Fixtures tipadas

Fixtures tipadas evitam uso incorreto:

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

interface AuthFixtures {
  userPage: Page; // Pré-autenticado como usuário regular
  adminPage: Page; // Pré-autenticado como 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);
  },
});

O TypeScript garante que você só acesse userPage e adminPage em testes que usam essa extensão de fixture, não em testes que usam o import base de test.

as const para seletores

Evite duplicar strings de seletor entre testes:

// 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' torna as strings tipos literais — sem mutação acidental

Melhor ainda: use getByRole, getByLabel e similares em page objects em vez de strings de seletor. Mas quando strings forem necessárias, as const previne reatribuição acidental.

Discriminated unions para respostas de API

Quando você testa APIs que retornam formatos diferentes dependendo de sucesso ou falha:

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

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

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

// Type narrowing nos testes
const body = await response.json() as ApiResponse<{ orderId: string }>;

if (body.success) {
  expect(body.data.orderId).toBeTruthy(); // TypeScript sabe que data existe aqui
} else {
  expect(body.code).toBe(422); // TypeScript sabe que error e code existem aqui
}

Evite any — use unknown para dados não tipados

Quando você recebe dados de uma API e ainda não tem um tipo, use unknown em vez de any:

// Ruim — any desativa toda a verificação de tipos
const body: any = await response.json();
body.nonExistentField.deeply.nested; // Sem erro, silenciosamente errado

// Bom — unknown força validação antes de usar
const body: unknown = await response.json();

// Precisa validar antes de acessar
if (typeof body === 'object' && body !== null && 'orderId' in body) {
  console.log((body as { orderId: string }).orderId);
}

// Melhor ainda: use um 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(); // Tipado
}

Utility types para dados de teste

Os utility types integrados do TypeScript reduzem duplicação nos tipos de dados de teste:

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

// Input de criação — sem id nem createdAt (gerados pelo servidor)
type CreateUserInput = Omit<User, 'id' | 'createdAt'>;

// Input de atualização — todos os campos opcionais
type UpdateUserInput = Partial<Pick<User, 'email' | 'role'>>;

// Objeto de asserção — só os campos verificáveis
type UserAssertion = Pick<User, 'email' | 'role'>;

Esses tipos tornam o tratamento de dados de teste explícito. O que você envia ao servidor e o que recebe de volta são formatos diferentes, e o TypeScript garante essa diferença.

→ Veja também: Interfaces e Tipos TypeScript para o Page Object Model | Tipos, Interfaces e Genéricos em TypeScript para Fixtures de Testes | Page Object Model no Playwright: Do Caos à Manutenibilidade