È uscito il Corso Java Completo
Torna al blog

Cos'è Playwright e come si testano le app nel browser

Cos'è Playwright e come funzionano i test end-to-end: auto-waiting, locator per ruolo, codegen, trace viewer, integrazione in CI e quanti test e2e scrivere.

Edoardo Midali

Edoardo Midali

Developer · Content Creator

11 min di lettura

Hai i test sulle funzioni, passano tutti, e la registrazione è rotta da due giorni perché un pulsante finisce sotto il banner dei cookie. È il buco che nessun test unitario vede: nessuno ha mai aperto davvero l'applicazione. In questo articolo trovi cosa fa Playwright, perché l'auto-waiting rende i suoi test meno instabili, come si scrivono i locator giusti e — la parte che conta di più — quanti test end-to-end scrivere prima che diventino un problema.

Cos'è

Playwright è uno strumento che guida un browser vero al posto tuo: apre le pagine, clicca, compila i moduli e verifica che l'utente veda quello che deve vedere.

È la punta della piramide descritta in cosa sono i test software: il test end-to-end, quello che attraversa tutto — interfaccia, rete, backend, database — esattamente come farebbe una persona.

Nasce in Microsoft, dallo stesso gruppo che aveva costruito Puppeteer, e la differenza rispetto agli strumenti storici — Selenium in testa — è che è stato progettato quando i problemi dei test end-to-end erano già noti. Ha cercato di risolverli, invece di lasciarli a te.

Le tre cose che lo distinguono

1. Un browser vero, anzi tre

Playwright pilota Chromium, Firefox e WebKit (il motore di Safari) con la stessa identica API. WebKit è il motivo più concreto: è il motore di ogni browser su iPhone, e i bug che compaiono solo lì sono un classico. Poterli intercettare senza possedere un Mac non è poco.

Include anche l'emulazione dei dispositivi mobili — dimensioni dello schermo, eventi touch, user agent — e questo sposta i test di layout responsive da "ricordarsi di controllare" ad "automatico".

2. L'auto-waiting: il motivo per cui i test smettono di essere instabili

Questo è il punto centrale, ed è la ragione per cui oggi ha senso scrivere test end-to-end mentre dieci anni fa molti team ci avevano rinunciato.

Il problema. Il tuo test clicca un pulsante. Ma quel pulsante compare dopo una chiamata di rete, che a volte impiega 80 millisecondi e a volte 900. Il test parte, cerca il pulsante, non c'è ancora, fallisce. Lo rilanci, stavolta la rete è veloce, passa. Stesso codice, stesso commit, risultato diverso: un test flaky.

La soluzione sbagliata, che è quella che si usava, è mettere pause fisse:

await page.waitForTimeout(2000); // non fatelo
await page.click("#invia");

Due secondi non bastano mai e avanzano sempre. Non bastano in CI, dove la macchina è più lenta, e avanzano ogni volta che la pagina è pronta subito: moltiplicati per cento passaggi diventano minuti di attesa inutile. Poi qualcuno alza a cinque secondi, e la suite passa da lenta a inutilizzabile.

Cosa fa Playwright. Prima di ogni azione controlla in un ciclo che l'elemento sia effettivamente utilizzabile: presente nel DOM, visibile, fermo (non in mezzo a un'animazione), abilitato, e non coperto da un altro elemento. Quando tutte le condizioni sono vere, agisce. Se entro il timeout non lo diventano, fallisce dicendoti quale condizione non era soddisfatta.

await page.getByRole("button", { name: "Invia" }).click();

Quella riga contiene già l'attesa: il test aspetta il minimo necessario invece di una cifra inventata, e non scrivi mai attese a mano, quindi non le sbagli.

Lo stesso vale per le asserzioni, riprovate finché non sono vere o scade il tempo:

await expect(page.getByText("Ordine confermato")).toBeVisible();

Il controllo "coperto da un altro elemento" è quello che prende i casi reali: il banner dei cookie sopra il pulsante, il modale che si chiude con una dissolvenza, l'header appiccicoso che copre il link. Con Selenium quei clic finivano nel vuoto, con un messaggio incomprensibile.

3. I locator basati sui ruoli di accessibilità

Il secondo grande motivo di fragilità sono i selettori. Se scrivi:

await page.click(".btn.btn-primary.mt-4 > span:nth-child(2)");

hai legato il test a dettagli grafici. Il designer sposta un margine, qualcuno rinomina una classe, un componente aggiunge un <div> di troppo: il test si rompe anche se l'applicazione funziona benissimo.

Playwright spinge verso i locator semantici, quelli che descrivono cosa l'utente vede e fa:

page.getByRole("button", { name: "Accedi" });
page.getByLabel("Indirizzo email");
page.getByPlaceholder("cerca...");
page.getByText("Benvenuto");
page.getByTestId("riepilogo-ordine");

getByRole è quello da usare quasi sempre. Interroga l'albero di accessibilità: il ruolo dell'elemento (pulsante, link, casella di testo, intestazione) e il nome con cui viene annunciato. Sono le due cose che restano stabili quando cambia il DOM attorno, perché rappresentano il significato e non la forma.

C'è un effetto collaterale utile: se non riesci a scrivere getByRole per un elemento, di solito è perché quell'elemento è una <div> con un onClick sopra, senza ruolo né etichetta. Non è accessibile a chi usa un lettore di schermo, e il test te lo ha appena segnalato. Test più robusti e interfaccia più accessibile arrivano insieme.

getByTestId resta per quando non c'è niente di semantico a cui aggrapparsi. Usalo come ultima risorsa.

Un test completo

npm init playwright@latest    # configurazione, esempi e browser
npx playwright test
npx playwright test --ui      # la modalità interattiva, la userai molto
// tests/checkout.spec.js
import { test, expect } from "@playwright/test";

test.describe("Checkout", () => {
  test("un utente registrato completa un ordine", async ({ page }) => {
    await page.goto("/login");

    await page.getByLabel("Email").fill("mario@example.com");
    await page.getByLabel("Password").fill("password-di-prova");
    await page.getByRole("button", { name: "Accedi" }).click();

    await expect(page.getByRole("heading", { name: "Il tuo account" })).toBeVisible();

    await page.goto("/corsi/javascript");
    await page.getByRole("button", { name: "Aggiungi al carrello" }).click();

    await page.getByRole("link", { name: /carrello/i }).click();
    await expect(page.getByText("1 articolo")).toBeVisible();

    await page.getByRole("button", { name: "Vai al pagamento" }).click();
    await expect(page).toHaveURL(/\/checkout/);
    await expect(page.getByRole("heading", { name: "Riepilogo ordine" })).toBeVisible();
  });
});

Si legge come la descrizione di quello che fa una persona, ed è il punto. Nessuna attesa esplicita, nessun selettore CSS.

Una nota pratica: i passaggi di login ripetuti in ogni test fanno perdere minuti. Playwright permette di eseguire il login una volta, salvare lo stato della sessione (cookie e localStorage) e riusarlo, così ogni test parte già autenticato. Su venti test è la differenza tra otto minuti e due.

Il codegen: scrivere test registrando

npx playwright codegen https://miosito.it

Si apre un browser e una finestra accanto. Tu navighi normalmente e Playwright scrive il codice del test mentre lo fai, scegliendo da solo i locator migliori disponibili.

È utilissimo per due cose: capire come si scrivono i locator quando inizi, e buttare giù lo scheletro di un percorso lungo. Poi il codice va ripulito — il codegen registra anche clic inutili e non sa quali sono le asserzioni che ti interessano. Non è un modo per generare la suite senza pensarci: un test registrato e mai toccato è un test che nessuno capisce quando fallisce tra sei mesi.

Capire i fallimenti in CI: trace viewer e screenshot

Il momento peggiore dei test end-to-end è questo: la pipeline è rossa, in locale funziona tutto, e l'unica informazione che hai è "timeout dopo 30 secondi". Playwright è lo strumento che ha risolto meglio questo problema, ed è un motivo di scelta più che sufficiente.

// playwright.config.js
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./tests",
  retries: process.env.CI ? 2 : 0,
  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
    video: "retain-on-failure",
  },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "webkit", use: { ...devices["Desktop Safari"] } },
  ],
  webServer: {
    command: "npm run build && npm run start",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
  },
});

Con trace: "on-first-retry" ogni test fallito produce una traccia, che apri così:

npx playwright show-trace trace.zip

Dentro c'è una linea del tempo con uno screenshot del DOM per ogni singola azione, navigabile avanti e indietro. Vedi lo stato esatto della pagina al momento del clic mancato, le chiamate di rete partite, la console del browser e i log. Nella maggior parte dei casi il motivo è evidente in trenta secondi — quasi sempre "l'elemento c'era ma sotto un overlay" o "la chiamata ha risposto 500".

Nota anche webServer: Playwright avvia l'applicazione da solo prima dei test e la spegne alla fine.

Il tema vero: quanti test end-to-end scrivere

Qui va detta la cosa che le guide tendono a nascondere, perché è la ragione per cui la maggior parte delle suite end-to-end viene abbandonata.

Sono lenti. Un test unitario dura millisecondi, un end-to-end secondi o decine di secondi. Cento test su tre browser sono mezz'ora di attesa a ogni push, e una suite da mezz'ora non viene lanciata prima di aprire una pull request: quindi non protegge più niente.

Sono fragili anche con Playwright. L'auto-waiting elimina la categoria più grossa di instabilità, non tutte. Restano i dati di test che si sporcano tra un'esecuzione e l'altra, i servizi esterni lenti, le animazioni, i fusi orari, le macchine di CI sovraccariche. Un test che fallisce a caso una volta su venti, per cento test, significa pipeline rossa quasi sempre — e una pipeline rossa per abitudine è una pipeline che nessuno guarda più.

Sono costosi da diagnosticare. Quando fallisce un test unitario sai quale funzione guardare. Con un end-to-end il colpevole può essere ovunque tra il pixel e il database.

La regola onesta: pochi test end-to-end, sui percorsi che se si rompono ti fanno perdere soldi o utenti. Per un prodotto tipico sono tra i cinque e i quindici:

  • Registrazione e login
  • Il checkout completo, fino al pagamento confermato
  • La funzione che è la ragione per cui la gente usa il prodotto
  • Il flusso di recupero password
  • Una pagina pubblica chiave, per accorgerti se il sito è semplicemente giù

Tutto il resto — la validazione del modulo, il calcolo dell'IVA, il messaggio d'errore quando la password è corta, i casi limite — sta più in basso nella piramide, dove costa cento volte meno: è quello che fai con Vitest o Jest.

Il criterio per aggiungerne uno: "se questo si rompe di notte e nessuno se ne accorge, cosa succede?" Se la risposta è "niente di grave", non è un test end-to-end.

Test unitarioTest end-to-end
DurataMillisecondiSecondi o decine di secondi
Cosa verificaUna funzione isolataL'intero sistema, come lo vede l'utente
Quando fallisceSai subito doveDevi indagare
InstabilitàPraticamente nullaPresente, da gestire
QuantiCentinaiaCinque-quindici
Dove staBase della piramidePunta

In CI

Su GitHub Actions sono poche righe:

name: E2E
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

Tre cose che contano davvero:

  • playwright install --with-deps scarica i browser e le librerie di sistema. Saltarlo è il primo errore di chiunque.
  • Carica il report come artifact. Senza, quando la pipeline è rossa hai solo il testo del log e nessuna traccia da aprire.
  • retries: 2 solo in CI. Un ritentativo salva la pipeline dai fallimenti casuali, ma è una medicina, non una cura: se un test ha bisogno del ritentativo sempre, va sistemato, non riprovato.

Il quadro generale è in cos'è la CI/CD.

Quando non ti serve

Su un sito statico — un blog, una vetrina, una pagina di presentazione — non c'è quasi niente da testare: nessun flusso, nessuno stato, nessun login. Un controllo che le pagine rispondano 200 e un occhio ai link rotti fanno lo stesso lavoro con un centesimo dello sforzo.

Su un progetto piccolo o all'inizio, quando l'interfaccia cambia ogni due giorni, i test end-to-end si rompono più in fretta di quanto li riscrivi. Prima si stabilizzano i flussi, poi si bloccano con i test.

Se non hai nemmeno i test unitari, comincia da quelli. Partire dalla punta della piramide è l'errore classico: ottieni la suite più lenta e più fragile possibile, e la abbandoni entro un mese.

Se hai un solo flusso critico, un test scritto bene può bastare. Meglio uno che funziona sempre di quaranta che nessuno guarda.

In sintesi

Playwright guida un browser vero e verifica quello che l'utente vede, su Chromium, Firefox e WebKit con lo stesso codice.

L'auto-waiting è la differenza sostanziale rispetto agli strumenti storici: aspetta che l'elemento sia visibile, fermo, abilitato e non coperto, invece di farti indovinare quanti secondi mettere in una pausa. È quello che toglie di mezzo la maggior parte dei test instabili.

I locator per ruolo rendono i test resistenti al refactoring grafico, e ti dicono quando un elemento non è accessibile.

Trace viewer e screenshot risolvono il problema peggiore: capire perché un test è fallito in CI e non in locale.

Pochi, sui percorsi che generano ricavi. Cinque-quindici test, non cento. Il resto sta più in basso nella piramide, dove è veloce e non si rompe da solo.

Su un sito statico o un progetto ancora instabile, non ti serve.

Per i test alla base della piramide vedi Vitest o Jest e cosa sono i test software. Per farli girare a ogni push, cosa sono le GitHub Actions.