Strategie di test moderne: dall'unità all'end-to-end

Ogni squadra di sviluppo scrive test. Non ogni squadra scrive test che prevengono i bug, sopravvivono alle rifattorizzazioni e danno la fiducia necessaria per rilasciare un venerdì pomeriggio. La differenza fra test che aiutano e test che danneggiano non sta nel framework né nella percentuale di copertura: sta nella strategia, cioè nel sapere che cosa testare, a quale livello e con quale compromesso.

L'approccio predefinito di molte squadre è scrivere test unitari per tutto e considerare chiusa la questione. Il rapporto di copertura segna il 90 per cento, l'integrazione continua è verde e tutti si sentono tranquilli, finché una modifica a una funzione rompe tre funzionalità che nessun test unitario aveva intercettato. Il problema non è la mancanza di disciplina, ma testare al livello sbagliato. Un componente che collega cinque servizi può avere il 100 per cento di copertura unitaria e fallire comunque in produzione, perché l'interazione fra quei servizi non è mai stata verificata.

Questa guida percorre l'intero spettro delle strategie moderne — dai test unitari rapidi e isolati agli end-to-end lenti ma affidabili — e spiega quando ciascuna aggiunge valore e quando genera solo peso. L'obiettivo non è convincervi a scrivere più test, ma aiutarvi a scriverne che giustifichino la propria esistenza: intercettano i bug che contano, girano abbastanza in fretta da essere eseguiti spesso e sopravvivono all'inevitabile riorganizzazione del vostro codice.

Buone pratiche dei test unitari e cosa conviene testare

I test unitari sono la base di quasi ogni strategia perché sono veloci, deterministici e isolati. Un test unitario scritto bene gira in millisecondi, copre un solo comportamento logico e non dipende da sistemi esterni come basi di dati, file system o API di rete. Quando fallisce, sapete esattamente quale pezzo di logica si è rotto e perché.

L'errore più comune è testare dettagli implementativi invece del comportamento. Verificare che una funzione chiami un certo metodo interno o imposti un campo privato rende il test fragile: una rifattorizzazione che preserva il comportamento esterno lo romperà lo stesso. Il test diventa un peso invece di una rete di sicurezza. Verificate l'output osservabile o gli effetti collaterali di una funzione, non il modo in cui ci arriva.

Buoni candidati sono le funzioni di utilità pure, la logica di dominio senza input/output, la validazione e il parsing, le trasformazioni di dati, i calcoli algoritmici e le macchine a stati. Cattivi candidati sono le funzioni che fanno chiamate di rete, i componenti che disegnano l'interfaccia e ogni codice strettamente legato agli interni del framework: quelli si testano meglio a livello di integrazione o end-to-end.

import { describe, it, expect } from "vitest";
import { calculateDiscount, type Order } from "./pricing";

describe("calculateDiscount", () => {
  it("returns 0 for orders below the threshold", () => {
    const order: Order = {
      items: [{ price: 30, quantity: 1 }],
      customerSince: new Date("2025-01-01"),
    };

    expect(calculateDiscount(order)).toBe(0);
  });

  it("applies 10 percent for orders over $100", () => {
    const order: Order = {
      items: [
        { price: 80, quantity: 1 },
        { price: 40, quantity: 1 },
      ],
      customerSince: new Date("2025-01-01"),
    };

    expect(calculateDiscount(order)).toBe(12);
  });

  it("applies loyalty bonus for customers over 2 years", () => {
    const order: Order = {
      items: [{ price: 100, quantity: 1 }],
      customerSince: new Date("2022-06-01"),
    };

    expect(calculateDiscount(order)).toBe(15);
  });

  it("does not apply discount when items are on sale", () => {
    const order: Order = {
      items: [{ price: 200, quantity: 1, onSale: true }],
      customerSince: new Date("2025-01-01"),
    };

    expect(calculateDiscount(order)).toBe(0);
  });
});

L'esempio qui sopra verifica quattro comportamenti distinti di una singola funzione, con ingressi e uscite attese diversi. Ogni test è indipendente, deterministico e gira in meno di 10 millisecondi. Se la logica dei prezzi cambia, i test dicono con esattezza quali scenari sono toccati. È questa la proposta di valore dei test unitari: riscontro rapido su logica pura.

  • Testate comportamenti, non implementazione. Verificate uscite ed effetti collaterali, non chiamate interne o stato privato.
  • Un'asserzione logica per caso. Un test dovrebbe fallire per esattamente un motivo, così è subito chiaro che cosa si è rotto.
  • Usate nomi descrittivi che si leggano come frasi. «restituisce 0 per ordini sotto la soglia» è meglio di «test_sconto_importo_basso».
  • Evitate stato mutabile condiviso fra i test. Ogni test prepara i propri dati e pulisce dopo di sé.
  • Tenete i valori semplici e significativi. Usate dati realistici, fedeli agli oggetti reali del dominio.

Test di integrazione con dipendenze reali

I test di integrazione stanno fra unitari ed end-to-end per velocità e portata. Verificano come più unità collaborano: un repository che interroga una base di dati reale, un servizio che chiama un endpoint concreto, o un componente reso con uno store vero. La differenza chiave rispetto agli unitari è che usano dipendenze reali o containerizzate invece di simulazioni.

I più preziosi verificano che il vostro codice interagisca correttamente con i sistemi esterni. Un test unitario che simula lo strato dati vi dice se la logica di query è corretta in isolamento, ma non se la query SQL reale gira davvero contro lo schema reale. I test di integrazione con Testcontainers o una base dedicata intercettano disallineamenti di schema, vincoli violati, errori ai confini delle transazioni e problemi di serializzazione che sfuggono del tutto agli unitari.

Danno il meglio su strati di repository, gestori di rotte API, consumatori di code di messaggi, migrazioni e su qualunque codice che serializzi o deserializzi dati attraverso un confine. Il prezzo è la velocità — un test che avvia un contenitore Postgres impiega secondi invece di millisecondi — ma i bug che intercettano costano proporzionalmente di più se emergono in produzione.

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createDatabase, teardownDatabase } from "./test-utils";
import { OrderRepository } from "./order-repository";

describe("OrderRepository", () => {
  let db: Awaited<ReturnType<typeof createDatabase>>;
  let repo: OrderRepository;

  beforeAll(async () => {
    db = await createDatabase();
    repo = new OrderRepository(db);
  });

  afterAll(async () => {
    await teardownDatabase(db);
  });

  it("persists and retrieves an order", async () => {
    const order = {
      id: "ord_001",
      customerId: "cus_001",
      total: 1250,
      status: "pending" as const,
      createdAt: new Date("2025-06-01"),
    };

    await repo.save(order);
    const retrieved = await repo.findById("ord_001");

    expect(retrieved).toEqual(order);
  });

  it("returns null for a non-existent order", async () => {
    const result = await repo.findById("ord_nonexistent");
    expect(result).toBeNull();
  });

  it("updates order status", async () => {
    await repo.save({
      id: "ord_002",
      customerId: "cus_001",
      total: 500,
      status: "pending",
      createdAt: new Date(),
    });

    await repo.updateStatus("ord_002", "shipped");
    const updated = await repo.findById("ord_002");

    expect(updated?.status).toBe("shipped");
  });
});

La regola fondamentale è tenere isolato lo stato della base fra un'esecuzione e l'altra. Ogni suite dovrebbe creare il proprio schema o la propria base, applicare le migrazioni, eseguire i test e poi smontare tutto. Lavorare su una base condivisa produce test instabili, dove i dati di uno filtrano nelle verifiche di un altro. Usate Testcontainers, Docker Compose o il supporto per basi di test del vostro framework, così ogni esecuzione parte da uno stato noto.

Un test di integrazione con una base di dati reale vale cento test unitari che la simulano. La simulazione vi dice che cosa vi aspettate dalla base. La base reale vi dice che cosa fa davvero. E dopo la prima migrazione di schema le due cose coincidono di rado.

Test end-to-end con Playwright

I test end-to-end simulano interazioni reali attraverso l'intero stack: browser, frontend, API, base di dati e servizi esterni. Sono i più lenti e costosi, ma intercettano i bug più realistici: navigazione rotta, validazione dei moduli mancante, risposte API mostrate male, fallimenti nel flusso di autenticazione e differenze di resa fra browser.

Playwright si è imposto grazie al supporto multi-browser, alle asserzioni con attesa automatica, all'intercettazione di rete e all'esperienza d'uso. A differenza degli strumenti precedenti, che richiedevano attese e tentativi espliciti, Playwright aspetta che un elemento sia davvero azionabile prima di interagirci, eliminando la causa più comune di test instabili.

import { test, expect } from "@playwright/test";

test("user completes a purchase flow", async ({ page }) => {
  await page.goto("/products");

  await page.getByRole("link", { name: "Wireless Headphones" }).click();
  await page.getByRole("button", { name: "Add to Cart" }).click();
  await page.getByRole("button", { name: "View Cart" }).click();

  await expect(page.getByText("Wireless Headphones")).toBeVisible();
  await expect(page.getByText("$89.99")).toBeVisible();

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

  await page.getByLabel("Email").fill("test@example.com");
  await page.getByLabel("Card Number").fill("4242424242424242");
  await page.getByLabel("Expiry").fill("12/28");
  await page.getByLabel("CVC").fill("123");
  await page.getByRole("button", { name: "Pay Now" }).click();

  await expect(page.getByText("Order confirmed")).toBeVisible();
  await expect(page.getByText("ord_001")).toBeVisible();
});

L'errore più grave è scriverne troppi. Ogni test end-to-end aggiunge minuti alla catena di integrazione, e una suite di 200 può facilmente arrivare a mezz'ora. La loro economia impone selettività. Concentratevi sui percorsi decisivi — registrazione, accesso, acquisto, ricerca, pagamento — e lasciate ai livelli inferiori i casi limite e gli stati di errore.

  • Date priorità ai flussi che toccano i ricavi: pagamento, passaggi di abbonamento, elaborazione degli incassi.
  • Mantenete i test indipendenti. Ciascuno crea i propri dati e pulisce dopo di sé.
  • Nella preparazione usate chiamate API invece dell'interfaccia, così raggiungete prima lo stato di partenza.
  • Eseguiteli su un ambiente di staging o di anteprima, non in produzione. Controllate la visibilità dei dati di prova con indicatori di funzionalità.
  • Investite in osservabilità: schermate, tracce e registrazioni video dei fallimenti fanno risparmiare ore di analisi.

Sviluppo guidato dai test nella pratica

Lo sviluppo guidato dai test non parla di test, ma di progettazione. Il ciclo rosso, verde, rifattorizzazione vi costringe a pensare all'interfaccia del codice prima di scriverlo. Scrivere prima il test vi obbliga a rispondere: che cosa deve accettare questa funzione e che cosa deve restituire? Questo vincolo porta ad API migliori, accoppiamento più lasco e codice più modulare.

La resistenza nasce di solito da due cose. O qualcuno ci ha provato su un problema poco adatto (codice di interfaccia, lavoro esplorativo), oppure l'ha applicato in modo dogmatico, passando più tempo a combattere con lo strumento che a scrivere codice utile. È uno strumento, non una religione. Funziona meglio su logica algoritmica, trasformazioni di dati, progettazione di API e ogni codice il cui contratto fra ingresso e uscita è chiaro fin dall'inizio.

Un flusso pratico è questo: scrivete un solo test che descriva il prossimo comportamento desiderato. Eseguitelo e guardatelo fallire: conferma che è valido e che rileva l'assenza della funzionalità. Scrivete il minimo codice per farlo passare, senza sovraingegnerizzare: l'implementazione più semplice che lo soddisfa è quella giusta. Poi migliorate la struttura senza cambiare il comportamento, contando sui test che passano per cogliere le regressioni.

Lo sviluppo guidato dai test non vi fa scrivere più test. Vi fa scrivere codice migliore. Il test è un effetto collaterale della progettazione, non l'obiettivo. Se scrivete i test dopo l'implementazione, state testando. Se li scrivete prima, state progettando.

È particolarmente efficace per le correzioni. Quando viene segnalato un bug, scrivete un test che lo riproduca (rosso), correggete il codice (verde) e verificate che la correzione non rompa nulla (tutti i test passano ancora). Quel test diventa una protezione permanente contro le ricadute. Col tempo nasce una suite che riflette la storia reale dei bug del progetto, molto più preziosa di una costruita per centrare un obiettivo di copertura.

Test basati su proprietà e fuzzing

I test tradizionali per esempi verificano ingressi specifici contro uscite attese. I test basati su proprietà fanno altro: definiscono una proprietà che deve valere per tutti gli ingressi e generano poi centinaia o migliaia di ingressi casuali per verificarla. Questa tecnica intercetta casi limite che nessuno penserebbe di scrivere come esempio.

Per esempio, invece di scrivere cinque casi specifici per una funzione di ordinamento, definite una proprietà: per qualunque lista di elementi confrontabili, il risultato deve contenere gli stessi elementi in ordine non decrescente. Il framework genera liste casuali di dimensioni varie, con duplicati, liste vuote e valori estremi, e verifica la proprietà su ciascuna.

import { describe, it, expect } from "vitest";
import { faker } from "@faker-js/faker";

describe("sortByPrice", () => {
  it("returns items in ascending price order for any input", () => {
    for (let i = 0; i < 100; i++) {
      const items = Array.from(
        { length: faker.number.int({ min: 0, max: 50 }) },
        () => ({
          name: faker.commerce.productName(),
          price: faker.number.int({ min: 1, max: 1000 }),
        })
      );

      const sorted = sortByPrice(items);

      for (let j = 1; j < sorted.length; j++) {
        expect(sorted[j - 1].price).toBeLessThanOrEqual(sorted[j].price);
      }
    }
  });

  it("preserves all original elements", () => {
    for (let i = 0; i < 100; i++) {
      const items = Array.from(
        { length: faker.number.int({ min: 0, max: 50 }) },
        () => ({
          name: faker.commerce.productName(),
          price: faker.number.int({ min: 1, max: 1000 }),
        })
      );

      const sorted = sortByPrice(items);

      expect(sorted).toHaveLength(items.length);
      expect(sorted.map((i) => i.name).sort()).toEqual(
        items.map((i) => i.name).sort()
      );
    }
  });
});

Fast-check è una libreria diffusa per test basati su proprietà in TypeScript, integrata con Vitest e Jest. Offre generatori per i tipi comuni, combinatori per strutture complesse e riduzione automatica: quando trova un caso che fallisce, prova a ridurlo all'ingresso minimo che continua a fallire, semplificando molto l'analisi.

L'approccio vale soprattutto per serializzazione e deserializzazione, algoritmi di ordinamento e filtro, regole di validazione, transizioni di stato e qualunque funzione con uno spazio di ingressi ampio. Unito a tecniche di fuzzing che iniettano dati malformati o inattesi, espone vulnerabilità e crash che altrimenti emergerebbero solo in produzione.

Test di regressione visiva

I test funzionali verificano che l'applicazione si comporti bene. Quelli di regressione visiva verificano che appaia bene. Modifiche al CSS, aggiornamenti di dipendenze e componenti rifattorizzati possono introdurre spostamenti sottili, scarti di colore, cambi di carattere o problemi ai punti di rottura che nessun test funzionale coglie. La regressione visiva cattura immagini di componenti o pagine, le confronta con riferimenti e segnala ogni differenza per la revisione umana.

Strumenti come Chromatic, Percy e la cattura integrata di Playwright realizzano questo a livelli diversi. Chromatic si integra con Storybook e confronta pixel per pixel con una logica che ignora l'antialiasing e le variazioni di subpixel. Percy offre capacità simili con supporto più ampio ai framework. L'asserzione toHaveScreenshot di Playwright permette verifiche visive direttamente nei test end-to-end, senza strumenti aggiuntivi.

import { test, expect } from "@playwright/test";

test("product page renders correctly", async ({ page }) => {
  await page.goto("/products/wireless-headphones");

  await expect(page).toHaveScreenshot("product-page.png", {
    maxDiffPixelRatio: 0.01,
    threshold: 0.2,
    fullPage: true,
  });
});

test("checkout form is visually stable across states", async ({ page }) => {
  await page.goto("/checkout");

  await expect(page).toHaveScreenshot("checkout-empty.png");

  await page.getByLabel("Email").fill("invalid-email");
  await page.getByRole("button", { name: "Continue" }).click();

  await expect(page).toHaveScreenshot("checkout-validation-error.png");
});

La difficoltà principale è gestire le immagini di riferimento. Ogni cambiamento visivo voluto impone di aggiornarle, e il processo di approvazione può diventare un collo di bottiglia in squadre che toccano spesso l'interfaccia. La soluzione è trattare l'aggiornamento come parte del lavoro: approvare le differenze visive nello stesso ciclo di revisione del codice, con una piattaforma integrata nel flusso delle richieste di merge.

  • Partite dalle pagine critiche: pagina iniziale, pagamento, accesso, scheda prodotto e ogni pagina con impaginazione complessa.
  • Impostate soglie adeguate per ignorare l'antialiasing e le differenze di resa dei caratteri fra sistemi operativi.
  • Usate schermate a livello di componente (via Storybook) per una copertura mirata, invece di schermate a pagina intera sensibili a ogni modifica.
  • Integrate la revisione visiva nel flusso delle richieste di merge. Richiedete approvazione umana per le differenze sulle pagine modificate.
  • Eseguite i test visivi in un ambiente uniforme (Docker) per eliminare le differenze di resa fra le macchine.

Testare i componenti server di React e Next.js

I componenti server di React hanno cambiato il panorama dei test. Girano esclusivamente sul server e non inviano JavaScript al client. Possono accedere direttamente a basi di dati, file system e servizi di back-end senza esporre nulla al browser. Questo permette di testarli con un approccio diverso da quello dei componenti client.

In sostanza sono funzioni asincrone che restituiscono JSX. Potete testarli come qualunque funzione asincrona: chiamarli con le proprietà, attendere il risultato e verificare l'output. Nessun ambiente browser e nessuna simulazione di fetch o di chiamate alla base, se lavorate con una base di test reale. Questa semplicità è uno dei vantaggi meno riconosciuti dei componenti server: la preparazione è molto più leggera di quella dei componenti client, che richiedono un DOM.

import { describe, it, expect } from "vitest";
import { ProductList } from "./product-list";

describe("ProductList (Server Component)", () => {
  it("renders products from the database", async () => {
    // No mocking needed - the component calls the database directly
    const { props } = await ProductList({ category: "electronics" });

    expect(props.products.length).toBeGreaterThan(0);
    expect(props.products.every((p) => p.category === "electronics")).toBe(
      true
    );
  });

  it("shows empty state when no products match", async () => {
    const { props } = await ProductList({ category: "nonexistent" });

    expect(props.products).toHaveLength(0);
  });
});

I componenti client che usano useState, useEffect, API del browser o gestori di eventi richiedono un ambiente DOM di test. Vitest mette a disposizione happy-dom o jsdom per simulare un browser. Testing Library offre strumenti per interrogare l'output e simulare le interazioni. La differenza rispetto ai componenti server è che dovete renderizzare, attendere l'esecuzione degli effetti e verificare lo stato del DOM risultante.

Le applicazioni Next.js aggiungono routing, recupero dati e azioni server, che richiedono attenzioni ulteriori. Per le pagine che usano getServerSideProps o generateStaticParams, testate le funzioni di recupero separatamente dal componente. Testate le azioni server come normali funzioni asincrone: non sono altro. Per layout e pagine dell'App Router, testate lo strato dati separatamente da quello di presentazione, per non legare i test agli interni di rendering del framework.

import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CheckoutForm } from "./checkout-form";

describe("CheckoutForm", () => {
  it("shows validation errors for empty required fields", async () => {
    const user = userEvent.setup();

    render(<CheckoutForm />);

    await user.click(screen.getByRole("button", { name: "Submit" }));

    expect(screen.getByText("Email is required")).toBeVisible();
    expect(screen.getByText("Card number is required")).toBeVisible();
  });

  it("submits the form with valid data", async () => {
    const onSubmit = vi.fn();
    const user = userEvent.setup();

    render(<CheckoutForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText("Email"), "test@example.com");
    await user.type(screen.getByLabelText("Card Number"), "4242424242424242");
    await user.type(screen.getByLabelText("Expiry"), "12/28");
    await user.type(screen.getByLabelText("CVC"), "123");

    await user.click(screen.getByRole("button", { name: "Submit" }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: "test@example.com",
      cardNumber: "4242424242424242",
      expiry: "12/28",
      cvc: "123",
    });
  });
});

La distinzione più importante nel nuovo modello di React è separare i test dei componenti server da quelli dei componenti client. I primi sono funzioni asincrone pure: testateli senza DOM. I secondi richiedono la simulazione dell'interazione: testateli con Testing Library e userEvent. Mescolare le due cose rende i test più lenti e complicati del necessario.

Strategie di simulazione e quando evitarle

La simulazione è uno dei temi più dibattuti. I sostituti prendono il posto delle dipendenze reali e permettono di testare il codice in isolamento senza allestire basi di dati, chiamare API esterne o attendere timer. In cambio, verificate il codice contro una versione simulata della dipendenza, non contro quella vera. Se il sostituto non riflette fedelmente il comportamento reale, il test passa mentre il codice fallisce.

La regola generale è simulare al confine del sistema. Sostituite i servizi esterni che non controllate (API di terzi, gestori di pagamento, invio di posta) e l'infrastruttura costosa o non deterministica (basi di dati, file system, orologi). Non simulate moduli interni, funzioni di utilità o i vostri oggetti di dominio: quelli vanno testati con le implementazioni reali, nei test di integrazione.

Vitest offre vi.mock, che porta le dichiarazioni in cima al file e sostituisce le implementazioni dei moduli prima dell'esecuzione. È utile per variabili d'ambiente, SDK di terzi e oggetti globali. Il rischio è che la sostituzione a livello di modulo sia invisibile e produca test che passano isolati ma falliscono insieme per interferenze reciproche.

import { describe, it, expect, vi } from "vitest";
import { PaymentService } from "./payment-service";

const mockStripe = {
  charges: {
    create: vi.fn(),
  },
};

vi.mock("stripe", () => ({
  default: vi.fn(() => mockStripe),
}));

describe("PaymentService", () => {
  it("creates a charge successfully", async () => {
    mockStripe.charges.create.mockResolvedValue({
      id: "ch_001",
      status: "succeeded",
    });

    const service = new PaymentService();
    const result = await service.charge(5000, "tok_visa");

    expect(result.status).toBe("succeeded");
    expect(mockStripe.charges.create).toHaveBeenCalledWith({
      amount: 5000,
      currency: "usd",
      source: "tok_visa",
    });
  });

  it("throws on failed payment", async () => {
    mockStripe.charges.create.mockRejectedValue(
      new Error("card_declined")
    );

    const service = new PaymentService();

    await expect(service.charge(5000, "tok_visa")).rejects.toThrow(
      "card_declined"
    );
  });
});

Un'alternativa più affidabile sono i doppi di prova che implementano la stessa interfaccia della dipendenza reale, ma in memoria e in forma semplificata. Invece di simulare il modulo della base di dati, scrivete una versione in memoria del vostro repository che tiene i dati in una Map. Questo doppio resta leggero ma fedele e percorre gli stessi cammini di codice della base reale, senza il costo di allestimento.

Ogni sostituto apre una distanza fra i vostri test e la realtà. È in quella distanza che si nascondono i bug. Prima di ricorrervi, chiedetevi: posso testare questo con la dipendenza reale in un ambiente controllato? Se sì, usate quella vera. Se no — troppo lenta, troppo costosa, non deterministica — allora simulate, ma strettamente al confine del sistema.

  • Simulate ai confini: API di terzi, SDK, gateway di pagamento, servizi esterni che non potete eseguire in locale.
  • Usate doppi in memoria per le vostre astrazioni (repository, cache, code) invece di simularle.
  • Non simulate ciò che non vi appartiene: simulare gli interni di una libreria lega i test a dettagli implementativi.
  • Preferite l'iniezione delle dipendenze alla sostituzione a livello di modulo. Passarle esplicitamente rende trasparente la preparazione.
  • Limitate l'ambito di ogni sostituto al test che ne ha bisogno. Azzerate tutti i sostituti fra un test e l'altro.

Testare nelle catene CI/CD

Una suite di test vale solo se gira in modo coerente e dà riscontro rapido. Test lenti, instabili o eseguiti solo sulle macchine degli sviluppatori sono un peso. Integrarli nella catena CI/CD garantisce che girino a ogni richiesta di merge, a ogni integrazione nel ramo principale e, per i percorsi critici, prima di ogni rilascio.

La piramide si traduce naturalmente in fasi della catena. I test unitari girano per primi, a ogni push, perché sono veloci e intercettano errori di logica elementari. Seguono i test di integrazione, in parallelo dove possibile, con dipendenze containerizzate. Gli end-to-end arrivano per ultimi, solo sulle richieste di merge e prima dei rilasci, perché lenti e costosi. I test visivi girano accanto a loro, confrontando le schermate con il riferimento del ramo principale.

La durata conta. Una suite da 45 minuti spinge a non testare più in locale e a spingere codice rotto. Alcune strategie: eseguire solo i test toccati dalla modifica corrente, distribuire le suite su esecutori paralleli, mettere in cache node_modules e i browser di Playwright fra le esecuzioni, e pianificare i test end-to-end lenti invece di lanciarli a ogni commit.

# .github/workflows/test.yml - example pipeline structure
name: test
on: [push, pull_request]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx vitest run --project unit

  integration:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: test
          POSTGRES_PASSWORD: test
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx vitest run --project integration

  e2e:
    needs: [unit, integration]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test

I test instabili sono la minaccia maggiore per la fiducia nella catena. Un test che fallisce a intermittenza, senza relazione con le modifiche al codice, erode la fiducia nell'intero impianto. La squadra comincia a ignorare i fallimenti, a fondere col rosso, e la suite finisce per diventare rumore. Investite nel rilevarli: marcateli, metteteli in quarantena e correggeteli o rimuoveteli in via prioritaria. Un test di cui non ci si può fidare è peggio di nessun test, perché abitua a ignorare i fallimenti.

Anche la reportistica è sottovalutata. Un output che dice «3 test falliti» senza contesto costringe a scorrere i registri in cerca del messaggio d'errore. Un buon rapporto mostra l'asserzione fallita, i valori attesi e ottenuti, il file e la riga, e il contesto utile come schermate o tracce di chiamata. I rapporti HTML di Vitest e Playwright, o le annotazioni di GitHub Actions, rendono visibili i risultati dentro il flusso delle richieste di merge.

Il dibattito fra trofeo e piramide

La piramide classica — test unitari alla base, integrazione al centro, end-to-end in cima — guida la strategia di test da vent'anni. Trasmette un'idea semplice: molti test rapidi e isolati, meno test di integrazione più lenti e pochissimi end-to-end. Il modello funziona bene per servizi di back-end, dove l'unità di rilascio è una funzione o una classe con confini netti fra ingresso e uscita.

Come alternativa è stato proposto il trofeo, che riflette meglio lo sviluppo frontend moderno. Rimodella la piramide in un rombo, dove i test di integrazione formano lo strato più ampio, con meno unitari e meno end-to-end. L'argomento è che nel frontend il valore maggiore viene dal verificare come i componenti collaborano — esattamente ciò che fanno i test di integrazione — e non dal testare funzioni isolate o percorsi completi.

Il trofeo riflette una realtà pratica: i test più preziosi di un'applicazione React sono quelli che renderizzano un componente, ci interagiscono e verificano il risultato. Sollecitano insieme il componente, i suoi figli, i suoi hook, la gestione dello stato e le chiamate API. Sono più rapidi degli end-to-end e più realistici degli unitari. Una suite di buoni test di integrazione dà più fiducia per singolo test di quanto non facciano gli altri due in un contesto frontend.

In realtà entrambi i modelli semplificano una verità più complessa. La strategia giusta dipende dall'architettura. Un microservizio con logica di dominio pesante beneficia della piramide: copertura unitaria profonda del dominio, meno test di integrazione per lo strato API e pochi test di contratto per i servizi esterni. Un'applicazione frontend con interazioni complesse beneficia del trofeo: ampia copertura di integrazione su componenti e pagine, test unitari mirati per le utilità ed end-to-end sui percorsi critici.

La piramide e il trofeo sbagliano entrambi, se li seguite in modo dogmatico. La strategia giusta è quella che intercetta i vostri bug, gira abbastanza in fretta per la vostra squadra e sopravvive alla vostra architettura. Può somigliare a una piramide, a un trofeo o a una forma che nessuno ha ancora battezzato. Smettete di discutere sulla forma e guardate che cosa intercettano davvero i vostri test.

L'idea importante dietro entrambi i modelli è che ogni livello ha un proprio profilo di costi e benefici. Un test unitario costa millisecondi da scrivere ed eseguire. Uno di integrazione costa secondi. Uno end-to-end costa minuti. Distribuite il budget in proporzione al valore che ciascun livello porta alla vostra applicazione. Se è un cruscotto ricco di logica di dominio, investite su unitari e integrazione. Se è un sito di contenuti con poca interattività, gli end-to-end sui flussi critici e la regressione visiva per l'impaginazione saranno l'investimento migliore.

Costruire una cultura del test

La strategia migliore fallisce se la squadra non crede nei test. La cultura del test non si impone con soglie di copertura né con TDD obbligatorio. Nasce quando scrivere test è la via naturale di minore resistenza. Si scrivono test quando sono facili da scrivere, rapidi da eseguire e chiaramente utili. Quando sono fragili, lenti e pieni di falsi fallimenti, si trovano modi per evitarli.

Cominciate col rendere eccellente l'esperienza. Investite nell'infrastruttura: esecutori rapidi, basi di test affidabili, rilevamento dei test instabili. Scrivete utilità che rendano concise le verifiche ricorrenti. Documentate gli schemi in una guida di squadra, così chiunque sappia come testare un nuovo endpoint, un componente React o una migrazione senza ripartire da zero. Costruire una buona infrastruttura una volta costa molto meno della lotta quotidiana contro test scadenti.

Le revisioni del codice devono includere la revisione dei test. Chi rivede dovrebbe chiedersi: questo test copre davvero il comportamento che dichiara? Potrebbe passare anche con un'implementazione sbagliata? Sta testando la cosa giusta al livello giusto? È leggibile e manutenibile? Trattate i test come codice di prima classe, soggetto agli stessi standard di revisione, stile e qualità del codice di produzione.

Le metriche di copertura sono un buon strumento diagnostico e un pessimo obiettivo. Se fissate il 90 per cento, otterrete il 90 per cento, non test migliori. Si scriveranno test banali su metodi di accesso e costruttori vuoti per raggiungere la soglia, senza rinforzare la rete di sicurezza. Usate i rapporti per trovare cammini non testati, non per imporre soglie arbitrarie. Un 70 per cento con test di integrazione ben piazzati vale più di un 95 per cento di unitari superficiali.

  • Rendete facile scrivere test: utilità, fabbriche e aiutanti che riducano il codice ripetitivo in ogni file.
  • Celebrate i miglioramenti dei test come le funzionalità rilasciate. Un test che elimina una classe di bug è una funzionalità.
  • Fate ruotare la responsabilità dell'infrastruttura di test, così la conoscenza si distribuisce invece di concentrarsi su una persona.
  • Tenete una revisione periodica dei test instabili. Seguitene il numero come indicatore di salute della squadra e correggete in via prioritaria.
  • Scrivete un manifesto di squadra sui test, che definisca cosa testare a ogni livello, cosa non testare e i compromessi concordati.

Lo scopo ultimo di una cultura del test non è un numero di copertura né una catena perfetta. È la fiducia. Fiducia che una rifattorizzazione non romperà la produzione. Fiducia che rilasciare un venerdì pomeriggio sia sicuro. Fiducia che un bug segnalato, una volta corretto, resti corretto. Una strategia che dà alla squadra questa fiducia è una buona strategia, che somigli a una piramide, a un trofeo o a qualcosa di intermedio.

Mettendo tutto insieme

Costruire una strategia efficace non significa scegliere un approccio contro un altro, ma comprendere i compromessi e applicare lo strumento adatto a ogni situazione. Gli unitari intercettano errori di logica in millisecondi. Quelli di integrazione intercettano problemi di interazione con dipendenze reali. Gli end-to-end intercettano guasti visibili all'utente su tutto lo stack. Quelli basati su proprietà intercettano casi limite di cui ignoravate l'esistenza. La regressione visiva intercetta cambiamenti d'aspetto non voluti.

Le squadre che rilasciano in modo affidabile non sono quelle con la copertura più alta. Sono quelle che hanno riflettuto su cosa offre ciascun livello, allineato l'investimento al proprio profilo di rischio e costruito una cultura in cui i test si scrivono perché aggiungono valore, non perché una politica lo impone. Cominciate con una ricognizione della suite attuale. Per ogni test chiedetevi: giustifica la propria esistenza? Se la risposta non è un sì netto, rimuovetelo o sostituitelo. La suite migliore non è la più grande: è quella che dà più fiducia per minuto di esecuzione.

See what your own repository can account for.

Thirty minutes on a repository you choose, including the part the record cannot attribute.