TypeScript sans types, c'est du JavaScript avec une étape de compilation en plus. Tu as ajouté un build step et un tsconfig.json pour ne rien gagner, parce que tout est typé any et le compilateur est trop poli pour se plaindre. La vraie valeur de TypeScript dans le code de tests repose sur trois choses. D'abord, savoir quand choisir une interface plutôt qu'un alias de type. Ensuite, comment les generics rendent les fixtures réutilisables sans sacrifier la sécurité. Enfin, quels types utilitaires apparaissent vraiment dans le code de tests.

Alias de type vs interfaces : la règle pratique

Tous les tutoriels TypeScript finissent par expliquer que les interfaces et les alias de types se recoupent beaucoup. Les deux décrivent des formes d'objets. Les deux supportent l'extension. Les deux fonctionnent dans les mêmes endroits la plupart du temps. La règle utile pour le code de tests est plus simple que la spécification du langage ne le laisse penser.

Utilise des interfaces quand tu décris une forme de données que d'autres types étendront. Utilise des alias de type pour les unions, les intersections, ou tout ce qui n'est pas une forme d'objet simple.

// Interface : une forme dont l'extension a du sens
interface User {
  id: number;
  email: string;
  role: 'admin' | 'viewer';
}

// Étendre une interface : naturel, lisible
interface AdminUser extends User {
  permissions: string[];
}

// Alias de type : le bon choix pour les unions
type UserRole = 'admin' | 'viewer' | 'guest';

// Alias de type : le bon choix pour les intersections
type AuthenticatedUser = User & { token: string; expiresAt: Date };

// Alias de type : le bon choix pour nommer une primitive ou un tuple
type UserId = number;
type Credentials = [string, string]; // [email, password]

Les interfaces fonctionnent mieux pour les formes extensibles grâce à la fusion de déclarations : tu peux déclarer la même interface deux fois et TypeScript fusionne les déclarations. C'est utile dans les fichiers de fixtures où différentes parties de ta suite de tests ajoutent des propriétés au même type de fixture. Les alias de type ne supportent pas la fusion ; une déclaration en double est une erreur.

[!note]
Ni les interfaces ni les alias de types ne produisent de code JavaScript. Ils existent uniquement dans la couche TypeScript et sont supprimés à la compilation. Aucun coût à l'exécution pour l'un ou l'autre.

L'erreur à éviter : passer du temps à débattre lequel utiliser. Choisis les interfaces pour les formes d'objets, les alias de type pour tout le reste, et avance. Les deux fonctionneront correctement. C'est une décision d'organisation du code, pas de correction.

Typer les données de test : utilisateurs, commandes et formulaires

Les fixtures de test vivent ou meurent par leurs données. Un objet utilisateur avec la mauvaise forme casse cinq tests silencieusement avant que quelqu'un ne s'en aperçoive. TypeScript rend la forme explicite.

// 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; // Chaîne de date ISO depuis l'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;
}

Avec ces interfaces définies, tes fichiers de données de test deviennent auto-documentés et vérifiés par le compilateur :

// 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 détecte ça immédiatement — 'superadmin' n'est pas un rôle valide
export const invalidUser: UserProfile = {
  id: 99,
  email: 'super@example.com',
  firstName: 'Super',
  lastName: 'Admin',
  role: 'superadmin', // Erreur : '"superadmin"' n'est pas assignable à '"admin" | "editor" | "viewer"'
  createdAt: '2025-03-01T00:00:00Z',
};

Quand le contrat API change et que role gagne une nouvelle valeur, tu l'ajoutes à l'interface une seule fois. Le compilateur signale tous les endroits dans tes tests qui ne gèrent pas le nouveau cas.

Typer les classes Page Object

Les classes Page Object bénéficient du typage explicite plus que n'importe quelle autre partie d'une suite de tests. Un Page Object typé documente sa propre API : la signature du constructeur, ce que chaque méthode accepte, et ce qu'elle retourne.

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

Quelques points à noter dans cette classe. Les modificateurs private readonly sur les locators empêchent leur réaffectation accidentelle depuis l'extérieur de la classe. Le paramètre de constructeur private readonly page: Page est un raccourci TypeScript pour déclarer une propriété et l'affecter en une seule étape. Le type de retour Promise sur getErrorMessage indique aux appelants qu'ils doivent gérer les deux cas. Le compilateur avertit s'ils utilisent le résultat comme s'il ne pouvait jamais être 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; // soustraire la ligne d'en-tête
  }

  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'];
  }
}

Le type de retour Order['status'] est un type d'accès indexé : il lit directement le type de la propriété status depuis l'interface Order. Si tu modifies l'union de statuts dans Order, le type de retour ici se met à jour automatiquement.

Fixtures génériques : étendre PlaywrightTestArgs

C'est là que TypeScript apporte le plus dans l'infrastructure de tests. test.extend de Playwright utilise les generics pour garantir que tes propriétés de fixtures personnalisées ont les bons types dans toute ta suite de tests.

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

// La forme de toutes les fixtures personnalisées
type AppFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
  authenticatedPage: DashboardPage;
};

// Fixtures de portée worker, partagées entre les tests d'un worker
type WorkerFixtures = {
  adminUser: UserCredentials;
};

export const test = base.extend<AppFixtures, WorkerFixtures>({
  // Fixtures de portée page : recréées pour chaque test
  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await loginPage.navigate();
    await use(loginPage);
  },

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

  // Une fixture qui configure une session authentifiée
  authenticatedPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await loginPage.navigate();
    await loginPage.login(adminCredentials);
    const dashboard = new DashboardPage(page);
    await use(dashboard);
  },

  // Portée worker : créée une fois par processus worker
  adminUser: [async ({}, use) => {
    await use(adminCredentials);
  }, { scope: 'worker' }],
});

export { expect };

Les paramètres génériques indiquent à TypeScript exactement quels types tes fixtures produisent. Quand un test destructure { loginPage }, TypeScript sait que c'est une instance de LoginPage, pas any. L'autocomplétion fonctionne. Les fautes de frappe dans les noms de méthodes sont détectées à la compilation.

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

test('la connexion admin affiche le tableau de bord', async ({ loginPage, authenticatedPage }) => {
  // TypeScript sait que loginPage est LoginPage — autocomplétion complète
  await loginPage.login({ email: 'admin@example.com', password: 'Admin$1!' });

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

[!tip]
Crée un barrel export dans fixtures/index.ts qui réexporte test et expect depuis ton fichier de fixtures. Les tests importent depuis ../fixtures au lieu de @playwright/test. Quand tu ajoutes de nouveaux page objects au type de fixture, tous les fichiers de tests qui importent depuis ../fixtures les voient automatiquement. Aucune modification dans les fichiers de tests individuels.

Types utilitaires dans le code de tests

TypeScript inclut un ensemble de types utilitaires qui transforment des types existants en nouveaux types. Quatre d'entre eux apparaissent régulièrement dans le code de tests.

Partial rend toutes les propriétés de T optionnelles. Utile quand une fonction crée un objet et que tu veux permettre aux appelants de surcharger des propriétés spécifiques :

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

// Crée un profil utilisateur valide avec des valeurs par défaut sensibles
// Les appelants peuvent surcharger n'importe quel sous-ensemble de propriétés
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,
  };
}

// Valeurs par défaut
const defaultUser = createTestUser();

// Surcharger uniquement ce qui compte pour ce test
const adminUser = createTestUser({ role: 'admin', email: 'admin@example.com' });

Required rend toutes les propriétés obligatoires, à l'inverse de Partial. Utile quand tu construis une fonction qui a besoin que tous les champs soient présents :

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

// Cette fonction a besoin de chaque champ — Required<T> rend le contrat explicite
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 crée un nouveau type avec uniquement les propriétés que tu nommes. Utile quand une fonction n'a besoin que d'un sous-ensemble d'un type plus large :

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

// La fonction de connexion n'a besoin que de l'email, pas du profil complet
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 crée un type d'objet où toutes les clés sont de type K et toutes les valeurs de type V. Il apparaît dans les factories de données de test et les définitions de mocks API :

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

// Une map de commandes de test nommées — clés de type string, valeurs de type 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 sait que testOrders['pendingOrder'] est de type Order
const pending = testOrders['pendingOrder'];

Types union pour les états de test

Beaucoup de tests doivent se comporter différemment selon qu'un utilisateur est connecté, quel rôle il a, ou dans quel état se trouve un enregistrement. Les types union modélisent ces états explicitement.

// Les deux états possibles d'une session utilisateur
type SessionState =
  | { status: 'authenticated'; userId: number; role: 'admin' | 'editor' | 'viewer' }
  | { status: 'guest' };

// Cycle de vie d'une commande — chaque état correspond à un ensemble d'actions UI valides
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 };

Ces unions discriminées permettent d'écrire des fonctions auxiliaires qui se comportent différemment selon l'état :

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

La propriété status agit comme discriminant : TypeScript l'utilise pour affiner le type dans chaque branche. Quand tu accèdes à order.trackingNumber dans la branche status === 'shipped', TypeScript sait que cette propriété existe sur cette variante spécifique.

Réduction de type dans les tests

La réduction de type (type narrowing), c'est comment TypeScript affine un type large vers un type spécifique dans un bloc conditionnel. Tu l'utilises en permanence dans le code de tests sans forcément reconnaître le concept formel.

// Réduction via typeof : distinguer les types primitifs
function formatDisplayValue(value: string | number | boolean): string {
  if (typeof value === 'string') {
    return value.trim(); // TypeScript sait que value est string ici
  }
  if (typeof value === 'number') {
    return value.toFixed(2); // TypeScript sait que value est number ici
  }
  return value ? 'Yes' : 'No'; // TypeScript sait que value est boolean ici
}

// Réduction via in : vérifier l'existence de propriétés sur les types 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); // Lève une exception si erreur, affine le type sinon
  // TypeScript sait maintenant que response est ApiSuccess
  expect(response.data).toBeDefined();
}

// Réduction via instanceof : utile avec les instances de classes
import { LoginPage, DashboardPage } from '../pages';

type AppPage = LoginPage | DashboardPage;

async function takeScreenshotWithContext(appPage: AppPage): Promise<void> {
  if (appPage instanceof LoginPage) {
    // TypeScript sait que appPage est LoginPage ici
    await appPage.navigate();
  } else {
    // TypeScript sait que appPage est DashboardPage ici
    const count = await appPage.getOrderCount();
    console.log(`Dashboard showing ${count} orders`);
  }
}

Le pattern qui apparaît le plus souvent dans les fixtures de tests concerne la réduction de null. Les méthodes DOM comme textContent() retournent string | null :

async function getHeadingText(page: Page): Promise<string> {
  const text = await page.getByRole('heading').first().textContent();

  // Sans cette vérification, TypeScript génère une erreur : 'text' peut être null
  if (text === null) {
    throw new Error('Heading element has no text content');
  }

  return text; // TypeScript sait que text est string ici
}

Paramètres de mode strict qui détectent de vrais bugs

Le flag strict dans tsconfig.json active un groupe de vérifications qui sont configurables individuellement mais presque toujours valent la peine d'être activées ensemble.

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

strict: true est un raccourci qui active six vérifications individuelles. Les deux qui détectent le plus de bugs dans le code de tests sont strictNullChecks et noImplicitAny. strictNullChecks rend null et undefined distincts de tous les autres types. Sans lui, string et string | null sont traités pareil, ce qui désactive tout l'intérêt de la vérification de null.

// Avec strictNullChecks: false (dangereux — le défaut avant le mode strict)
const text: string = null; // Pas d'erreur. Explosion à l'exécution quand tu appelles text.trim()

// Avec strictNullChecks: true
const text: string = null; // Erreur : 'null' n'est pas assignable à 'string'
const safeText: string | null = null; // Correct — tu as déclaré la possibilité

noImplicitAny exige des types explicites partout où TypeScript ne peut pas les inférer. Dans le code de tests, ça détecte les paramètres de fonctions non typés avant qu'ils deviennent des bugs :

// noImplicitAny détecte ça
function fillForm(data) { // Erreur : le paramètre 'data' a implicitement le type 'any'
  // data peut être n'importe quoi — aucune protection
}

// Correct : le type explicite t'oblige à définir la forme
function fillForm(data: RegistrationForm) {
  // TypeScript valide chaque accès à un champ
}

noUncheckedIndexedAccess mérite d'être activé séparément. Il ajoute | undefined aux accès aux éléments de tableaux et aux signatures d'index d'objets, car accéder à array[0] sur un tableau vide retourne undefined à l'exécution :

// Sans noUncheckedIndexedAccess
const rows: string[] = [];
const first: string = rows[0]; // TypeScript l'accepte, mais rows[0] est undefined à l'exécution

// Avec noUncheckedIndexedAccess
const rows: string[] = [];
const first: string | undefined = rows[0]; // TypeScript t'oblige à gérer undefined
if (first !== undefined) {
  console.log(first.toUpperCase()); // Maintenant sûr
}

[!warning]
Ajouter ces paramètres stricts à une migration JavaScript vers TypeScript existante produira beaucoup d'erreurs d'un coup. Si tu convertis un projet existant, ajoute d'abord "strict": true et corrige ces erreurs avant d'activer noUncheckedIndexedAccess. Essayer de tout corriger simultanément rend la migration interminable.
exactOptionalPropertyTypes est utile pour les factories de données de test. Sans lui, définir explicitement une propriété optionnelle à undefined est traité de la même façon que l'omettre, alors que ce n'est pas équivalent quand la propriété est sérialisée en JSON :

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

// Avec exactOptionalPropertyTypes: true
const partial: UpdateRequest = { email: undefined }; // Erreur : undefined n'est pas assignable à string
const correct: UpdateRequest = { email: 'new@example.com' }; // Correct — n'inclure que ce qu'on met à jour

FAQ

Quand utiliser un paramètre de type générique plutôt qu'un type spécifique ?

Quand tu écris une fonction ou une fixture qui fonctionne avec plusieurs types mais doit préserver la relation entre le type d'entrée et le type de sortie. Si tu écris une fonction factory qui retourne quel que soit le type passé en paramètre, c'est un generic. Si ta fonction travaille toujours avec UserProfile, utilise UserProfile directement. N'ajoute pas de generics pour une flexibilité dont tu n'as pas encore besoin.

Est-il sûr d'utiliser l'opérateur d'assertion non-null (!) dans les tests ?

Parfois, oui. Quand tu sais par le contexte qu'une valeur ne peut pas être null mais que TypeScript ne peut pas le vérifier, utiliser value! est raisonnable. C'est le cas par exemple après avoir vérifié qu'un élément est visible. Le risque, c'est de l'utiliser pour faire taire des erreurs légitimes. Si tu écris ! fréquemment, c'est un signe que tes types ne décrivent pas fidèlement tes données.

Faut-il typer chaque variable, ou peut-on laisser TypeScript inférer ?

Préfère l'inférence quand TypeScript peut clairement déterminer le type : const user = createTestUser() n'a pas besoin d'annotation si createTestUser a un type de retour. Ajoute des annotations explicites sur les paramètres de fonctions, les types de retour et les propriétés de classes. Tu obtiens les bénéfices du typage aux frontières sans encombrer chaque ligne.

Quelle est la différence entre type Foo = Bar et interface Foo extends Bar ?

Les deux créent un type nommé Foo qui inclut toutes les propriétés de Bar. La différence pratique : l'extension d'interface est plus lisible quand tu ajoutes des propriétés à une forme existante et que tu veux que l'intention soit claire. L'intersection de types (Foo = Bar & { extra: string }) est plus flexible parce qu'elle fonctionne avec n'importe quel type, pas seulement les interfaces. Dans le code de tests, les deux conviennent. Choisis celui qui se lit le plus naturellement.

Mon équipe n'utilise pas TypeScript. Ça vaut la peine de l'ajouter uniquement à la suite de tests ?

Oui. La suite de tests est souvent le meilleur endroit pour introduire TypeScript dans une équipe JavaScript, parce que la portée est délimitée et les bénéfices sont immédiats. Playwright supporte TypeScript nativement, les fichiers de tests sont autonomes, et les Page Objects et définitions de fixtures typés servent de documentation vivante des formes de données de l'application. Les équipes commencent fréquemment avec des tests typés et étendent ensuite TypeScript au code de l'application.

→ See also: Interfaces et Types TypeScript pour le Page Object Model | Meilleures Pratiques TypeScript dans le Code de Tests Playwright | Page Object Model dans Playwright: Du Chaos à la Maintenabilité