Test di Unità nel 2026: Nessuna Scusa Rimasta
Gli strumenti si sono evoluti. L'IA scrive gli scheletri dei test, i framework sono veloci e non c'è più una buona ragione per rilasciare codice non testato.

Tre anni fa, le scuse per non scrivere test erano almeno plausibili. I framework di test erano lenti. Configurare i mock era noioso. Scrivere test richiedeva più tempo che scrivere il codice. Il ROI non era ovvio per una startup di 3 persone che correva verso il product-market fit. Non ero d'accordo con quelle scuse, ma le capivo.
Nel 2026, quelle scuse sono sparite. Vitest esegue una suite di test completa nel tempo che Jest impiegava solo per avviarsi. Gli strumenti AI creano file di test completi da una firma di funzione. TypeScript rileva intere categorie di bug in fase di compilazione, restringendo ciò che necessita effettivamente di test. Docker Compose avvia database reali per i test di integrazione in pochi secondi. Il divario tra "nessun test" e "ben testato" non è mai stato così piccolo.
Il Panorama Moderno dei Test
L'ecosistema dei test si è consolidato attorno a pochi strumenti eccellenti, e la frammentazione che un tempo rendeva la configurazione dei test un progetto di ricerca è in gran parte scomparsa.
Vitest vs Jest: La Decisione è Presa
Per qualsiasi nuovo progetto, Vitest è la scelta predefinita. Non perché Jest sia cattivo — Jest è un framework solido e collaudato — ma perché Vitest è misurabilmente migliore in ogni dimensione che conta per lo sviluppo moderno.
Velocità: Vitest utilizza esbuild per la trasformazione ed esegue i test in worker thread con supporto nativo ESM. Nei miei progetti, la stessa suite di test viene eseguita 3-5 volte più velocemente con Vitest rispetto a Jest. Una suite che richiedeva 45 secondi in Jest si completa in 12 secondi in Vitest.
Configurazione: Vitest riutilizza la tua configurazione Vite. Se stai già usando Vite per la tua build (e nel 2026, la maggior parte dei progetti lo fa), non c'è alcuna configurazione aggiuntiva. Confrontalo con moduleNameMapper, transform, transformIgnorePatterns di Jest e l'inevitabile risoluzione dei problemi sul perché alcuni pacchetti ESM non funzionano.
Compatibilità: Vitest implementa un'API compatibile con Jest. describe, it, expect, beforeEach, afterEach, vi.fn(), vi.mock() — funzionano tutti come previsto. La migrazione da Jest a Vitest è principalmente un'operazione di ricerca e sostituzione di jest.fn() con vi.fn().
// vitest.config.ts — this is often all you need
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/', '**/*.d.ts', '**/*.config.*'],
},
},
});
Quando rimanere con Jest: Se hai una suite di test Jest esistente di grandi dimensioni (più di 500 test) e nessun problema immediato, la migrazione non è urgente. Jest continua a ricevere aggiornamenti e funziona bene. Ma per i nuovi progetti o per i progetti con meno di 100 test, Vitest è la scelta ovvia.
La Piramide dei Test in Pratica
La tradizionale piramide dei test (molti test di unità, meno test di integrazione, pochi test E2E) rimane corretta in linea di principio, ma i confini si sono spostati.
Test di unità verificano singole funzioni, metodi di utilità e logica di business pura. Dovrebbero essere veloci (meno di 10ms ciascuno), non avere dipendenze esterne e testare il comportamento piuttosto che l'implementazione.
Test di integrazione verificano che i componenti funzionino insieme — route API con query di database, metodi di servizio con chiamate API esterne, componenti React con la loro gestione dello stato. Questi sono più lenti (100-500ms ciascuno) ma rilevano bug che i test di unità non riescono a trovare.
Test E2E verificano flussi di lavoro utente completi attraverso l'applicazione reale. Questi sono i più lenti (5-30 secondi ciascuno) e i più fragili, ma rilevano bug che nient'altro fa. Strumenti come Playwright hanno reso i test E2E significativamente più affidabili rispetto all'era di Selenium.
Il rapporto che miro è: 70% unità, 20% integrazione, 10% E2E. I numeri esatti contano meno che avere rappresentazione a ogni livello.
Cosa Testare e Cosa Saltare
Non tutto il codice richiede lo stesso rigore di test. Sapere dove investire lo sforzo di test è tanto importante quanto sapere come scrivere i test.
Testare Sempre
Logica di business e regole di dominio. Se il codice implementa una regola di business — calcolo dei prezzi, controllo dei permessi, validazione dei dati, transizione di una macchina a stati — necessita di test. Queste sono le regole che, se sbagliate, costano denaro o creano vulnerabilità di sicurezza.
// pricing.ts
export function calculateOrderTotal(items: OrderItem[], discount?: Discount): number {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
if (!discount) return subtotal;
if (discount.type === 'percentage') {
return subtotal * (1 - discount.value / 100);
}
if (discount.type === 'fixed') {
return Math.max(0, subtotal - discount.value);
}
return subtotal;
}
// pricing.test.ts
import { describe, it, expect } from 'vitest';
import { calculateOrderTotal } from './pricing';
describe('calculateOrderTotal', () => {
const items: OrderItem[] = [
{ id: '1', name: 'Burger', price: 12.99, quantity: 2 },
{ id: '2', name: 'Fries', price: 4.99, quantity: 1 },
];
it('calculates subtotal without discount', () => {
expect(calculateOrderTotal(items)).toBe(30.97);
});
it('applies percentage discount', () => {
const discount = { type: 'percentage' as const, value: 10 };
expect(calculateOrderTotal(items, discount)).toBeCloseTo(27.873);
});
it('applies fixed discount', () => {
const discount = { type: 'fixed' as const, value: 5 };
expect(calculateOrderTotal(items, discount)).toBe(25.97);
});
it('does not go below zero with fixed discount', () => {
const discount = { type: 'fixed' as const, value: 50 };
expect(calculateOrderTotal(items, discount)).toBe(0);
});
it('handles empty items array', () => {
expect(calculateOrderTotal([])).toBe(0);
});
});
Funzioni di trasformazione dei dati. Qualsiasi funzione che trasforma i dati da una forma all'altra — mappatori di risposte API, serializzatori di dati di form, parser CSV — necessita di test con input rappresentativi e casi limite.
Percorsi di gestione degli errori. Cosa succede quando l'API restituisce un 500? Quando l'input è nullo? Quando il file non esiste? I percorsi di errore sono dove si nascondono i bug perché gli sviluppatori testano manualmente il percorso felice e presumono che il percorso di errore funzioni.
Funzioni di utilità. Formattatori di stringhe, helper per date, utilità per array, funzioni di validazione. Questi sono usati ovunque, e un bug in una funzione di utilità si moltiplica attraverso la codebase.
Saltare o Testare Leggermente
Wrapper diretti di framework. Se la tua funzione è un sottile wrapper attorno a un metodo di framework ben testato, testarla significa testare il framework, non il tuo codice. Un componente React che renderizza <h1>{title}</h1> non ha bisogno di un test che verifichi che l'h1 venga renderizzato.
File di configurazione. Configurazione statica, costanti, definizioni di tipo — questi non necessitano di test. TypeScript li valida già in fase di compilazione.
Codice "colla" per l'integrazione di librerie di terze parti. Il codice che chiama stripe.charges.create() con parametri dal tuo modello di dati non ha bisogno di un test di unità che simuli Stripe e verifichi che tu l'abbia chiamato. Ha bisogno di un test di integrazione che verifichi il flusso di addebito end-to-end.
Strategie di Mocking
Il mocking è il punto in cui i test passano dal "verificare il comportamento" al "testare i dettagli di implementazione". L'obiettivo è simulare il meno possibile mantenendo i test veloci e deterministici.
L'Approccio dell'Iniezione di Dipendenza
Invece di simulare moduli, passa le dipendenze come parametri. Questo rende i test naturali ed evita la magia di vi.mock() che accoppia i test alla struttura del modulo.
// user-service.ts
export function createUserService(db: Database, emailClient: EmailClient) {
return {
async createUser(data: CreateUserInput): Promise<User> {
const user = await db.users.create({ data });
await emailClient.send({
to: user.email,
template: 'welcome',
data: { name: user.name },
});
return user;
},
};
}
// user-service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createUserService } from './user-service';
describe('createUserService', () => {
it('creates user and sends welcome email', async () => {
const mockUser = { id: '1', name: 'Jane', email: 'jane@example.com' };
const db = {
users: { create: vi.fn().mockResolvedValue(mockUser) },
};
const emailClient = {
send: vi.fn().mockResolvedValue(undefined),
};
const service = createUserService(db as any, emailClient as any);
const result = await service.createUser({ name: 'Jane', email: 'jane@example.com' });
expect(result).toEqual(mockUser);
expect(emailClient.send).toHaveBeenCalledWith({
to: 'jane@example.com',
template: 'welcome',
data: { name: 'Jane' },
});
});
});
Nessuna chiamata a vi.mock(), nessuna stringa di percorso del modulo, nessuna configurazione dipendente dall'ordine. Il test è esplicito su ciò che è reale e ciò che è finto.
Quando Usare vi.mock()
Il mocking a livello di modulo è appropriato quando non puoi controllare l'iniezione di dipendenza — tipicamente quando testi componenti React che importano moduli direttamente o quando testi codice che utilizza globali specifici dell'ambiente.
// When you genuinely need module mocking
vi.mock('./api-client', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: '1', name: 'Test User' }),
}));
MSW per il Mocking delle API
Per i test che effettuano richieste HTTP, Mock Service Worker (MSW) intercetta le richieste di rete a livello di service worker. Questo è superiore al mocking di fetch o axios perché testa il tuo codice client HTTP effettivo.
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('https://api.example.com/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Test User',
email: 'test@example.com',
});
}),
http.post('https://api.example.com/orders', async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ id: 'order-1', ...body, status: 'created' },
{ status: 201 }
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Generazione di Test Assistita dall'IA
Gli strumenti AI hanno realmente cambiato l'economia della scrittura dei test. Generare lo scheletro iniziale del test — il boilerplate, i casi base del percorso felice, i casi limite standard — è esattamente il tipo di lavoro ripetitivo e basato su schemi che i LLM gestiscono bene.
Cosa Fa Bene l'IA
- Generare file di test da firme di funzione e definizioni di tipo
- Suggerire casi limite a cui potresti non pensare (array vuoti, valori nulli, numeri limite)
- Scrivere boilerplate ripetitivo di setup/teardown
- Creare oggetti mock che corrispondono alle forme delle interfacce
- Generare casi di test parametrizzati per funzioni con molte combinazioni di input
Cosa Fa Male l'IA
- Comprendere il contesto di business ("questo dovrebbe fallire perché gli utenti non possono ordinare dopo mezzanotte" è conoscenza di dominio)
- Testare interazioni di stato complesse attraverso più chiamate di funzione
- Scrivere test di integrazione significativi che esercitano i veri confini del sistema
- Decidere cosa testare e cosa non testare
- Scrivere test che verificano il comportamento piuttosto che l'implementazione
Il Workflow Pratico
Il mio workflow con i test assistiti dall'IA:
- Scrivere la funzione o il modulo.
- Chiedere all'IA di generare un file di test con casi base.
- Rivedere e correggere i test generati — i test generati dall'IA spesso testano i dettagli di implementazione piuttosto che il comportamento.
- Aggiungere casi specifici del dominio che l'IA ha tralasciato.
- Eseguire i test e verificare che catturino effettivamente i bug rompendo temporaneamente il codice.
L'IA esegue il passaggio 2 (che un tempo era la parte noiosa) e io mi concentro sui passaggi 3-5 (che richiedono giudizio). Questo riduce il tempo di scrittura dei test di circa il 50-60% per il codice di utilità e di servizio standard.
Testare i Componenti React
Il testing dei componenti si è evoluto significativamente. Il passaggio dal rendering superficiale di Enzyme alla filosofia di testing incentrata sull'utente di React Testing Library ha cambiato il mio modo di pensare ai test dei componenti.
Cosa Testare nei Componenti
- Il componente viene renderizzato correttamente con diverse props?
- Le interazioni dell'utente (click, digitazione, selezione) attivano il comportamento atteso?
- Il componente gestisce gli stati di caricamento, errore e vuoto?
- Il rendering condizionale funziona correttamente?
Cosa Non Testare nei Componenti
- Valori di stato interni (testare ciò che l'utente vede, non ciò che React traccia)
- Dettagli di implementazione (quale funzione è stata chiamata, quanti re-render sono avvenuti)
- Stile (usare il visual regression testing per questo)
// OrderSummary.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { OrderSummary } from './OrderSummary';
describe('OrderSummary', () => {
const defaultProps = {
items: [
{ id: '1', name: 'Burger', price: 12.99, quantity: 2 },
{ id: '2', name: 'Fries', price: 4.99, quantity: 1 },
],
onCheckout: vi.fn(),
};
it('displays order items with prices', () => {
render(<OrderSummary {...defaultProps} />);
expect(screen.getByText('Burger')).toBeInTheDocument();
expect(screen.getByText('$25.98')).toBeInTheDocument(); // 12.99 * 2
expect(screen.getByText('Fries')).toBeInTheDocument();
expect(screen.getByText('$4.99')).toBeInTheDocument();
});
it('displays total', () => {
render(<OrderSummary {...defaultProps} />);
expect(screen.getByText('Total: $30.97')).toBeInTheDocument();
});
it('calls onCheckout when checkout button is clicked', async () => {
const user = userEvent.setup();
render(<OrderSummary {...defaultProps} />);
await user.click(screen.getByRole('button', { name: /checkout/i }));
expect(defaultProps.onCheckout).toHaveBeenCalledOnce();
});
it('shows empty state when no items', () => {
render(<OrderSummary {...defaultProps} items={[]} />);
expect(screen.getByText(/your cart is empty/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /checkout/i })).not.toBeInTheDocument();
});
it('disables checkout button during loading', () => {
render(<OrderSummary {...defaultProps} isLoading={true} />);
expect(screen.getByRole('button', { name: /checkout/i })).toBeDisabled();
});
});
Nota: nessun getByTestId a meno che non sia necessario, nessun controllo dello stato interno, nessuna verifica delle classi CSS. I test si leggono come una descrizione di ciò che un utente vede e fa.
Testare le Route API
I test delle route API sono test di integrazione che verificano che il tuo gestore HTTP funzioni correttamente con il suo middleware, la validazione e la formattazione della risposta.
// Using supertest with an Express app
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../app';
import { db } from '../database';
describe('POST /api/orders', () => {
let authToken: string;
beforeAll(async () => {
await db.migrate.latest();
await db.seed.run();
// Get auth token for test user
const loginResponse = await request(app)
.post('/api/auth/login')
.send({ email: 'test@example.com', password: 'testpassword' });
authToken = loginResponse.body.token;
});
afterAll(async () => {
await db.destroy();
});
it('creates an order with valid data', async () => {
const response = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({
items: [{ productId: 'prod-1', quantity: 2 }],
deliveryAddress: '123 Main St',
});
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
id: expect.any(String),
status: 'pending',
items: expect.arrayContaining([
expect.objectContaining({ productId: 'prod-1', quantity: 2 }),
]),
});
});
it('returns 401 without auth token', async () => {
const response = await request(app)
.post('/api/orders')
.send({ items: [{ productId: 'prod-1', quantity: 2 }] });
expect(response.status).toBe(401);
});
it('returns 400 with invalid data', async () => {
const response = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({ items: [] });
expect(response.status).toBe(400);
expect(response.body.errors).toBeDefined();
});
it('returns 400 when product does not exist', async () => {
const response = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({
items: [{ productId: 'nonexistent', quantity: 1 }],
deliveryAddress: '123 Main St',
});
expect(response.status).toBe(400);
expect(response.body.message).toContain('not found');
});
});
Testare le Operazioni del Database
Testare le operazioni del database richiede un database reale. Il mocking delle query SQL testa il mock, non la query. Usa un database di test che viene ricreato per ogni esecuzione di test.
// database.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient({
datasources: { db: { url: process.env.TEST_DATABASE_URL } },
});
beforeEach(async () => {
// Clean tables in dependency order
await prisma.orderItem.deleteMany();
await prisma.order.deleteMany();
await prisma.user.deleteMany();
});
describe('Order repository', () => {
it('creates order with items and calculates total', async () => {
const user = await prisma.user.create({
data: { email: 'test@example.com', name: 'Test User' },
});
const order = await prisma.order.create({
data: {
userId: user.id,
items: {
create: [
{ productName: 'Burger', price: 12.99, quantity: 2 },
{ productName: 'Fries', price: 4.99, quantity: 1 },
],
},
total: 30.97,
},
include: { items: true },
});
expect(order.items).toHaveLength(2);
expect(order.total).toBe(30.97);
expect(order.userId).toBe(user.id);
});
it('enforces unique email constraint', async () => {
await prisma.user.create({
data: { email: 'duplicate@example.com', name: 'User 1' },
});
await expect(
prisma.user.create({
data: { email: 'duplicate@example.com', name: 'User 2' },
})
).rejects.toThrow();
});
});
Configurazione del Database di Test in CI
# In GitHub Actions
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db
ports:
- 5432:5432
Con Prisma, esegui npx prisma migrate deploy prima dei test per applicare lo schema. Con SQL puro, esegui gli script di migrazione. Il database di test si avvia da zero per ogni esecuzione CI.
Metriche di Copertura Che Contano
La copertura del codice è un segnale utile, ma è un obiettivo terribile.
La Trappola della Copertura
Ottimizzare per il 100% di copertura porta a test che esistono per raggiungere linee non coperte piuttosto che per verificare il comportamento. Ho visto test come questo:
// This test exists purely for coverage
it('should have a default export', () => {
expect(module).toBeDefined();
});
Questo test non verifica nulla di significativo. Aggiunge al numero di copertura pur aggiungendo zero fiducia.
Obiettivi di Copertura Significativi
Invece di una soglia di copertura generica, utilizzo obiettivi diversi per diverse categorie di codice:
| Categoria | Obiettivo | Motivazione |
|---|---|---|
| Logica di business / Dominio | 90%+ | I bug qui costano denaro |
| Route API / Controller | 80%+ | Confini di integrazione |
| Funzioni di utilità | 95%+ | Ampiamente usate, alto impatto |
| Componenti UI | 70%+ | Bug visivi catturati da E2E |
| Configurazione / Setup | Nessun obiettivo | Statico, raramente si rompe |
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
thresholds: {
'src/domain/**': { statements: 90, branches: 85 },
'src/api/**': { statements: 80, branches: 75 },
'src/utils/**': { statements: 95, branches: 90 },
},
},
},
});
L'Alternativa del Mutation Testing
Se i numeri di copertura sembrano vuoti, il mutation testing fornisce una valutazione più onesta. Strumenti come Stryker introducono piccole modifiche (mutazioni) al tuo codice e verificano che i tuoi test le rilevino. Una mutazione che sopravvive (i test passano ancora) indica una lacuna nella tua suite di test.
npx stryker run
Il mutation testing è lento (esegue l'intera suite di test per ogni mutazione) ma rivelatore. Lo eseguo mensilmente piuttosto che ad ogni commit.
Sviluppo Guidato dai Test in Pratica
Pratico il TDD selettivamente, non dogmaticamente. Funziona eccezionalmente bene per alcuni tipi di codice e aggiunge attrito ad altri.
Dove il TDD Eccelle
Funzioni pure con specifiche chiare. Se conosci gli input e gli output attesi prima di scrivere il codice, scrivere prima il test è naturale e produttivo.
Correzioni di bug. Prima di correggere un bug, scrivi un test che lo riproduca. Poi correggi il codice finché il test non passa. Questo garantisce che il bug rimanga corretto.
Refactoring. Scrivi test per il comportamento esistente prima del refactoring. I test fungono da rete di sicurezza, verificando che il codice refactorizzato produca risultati identici.
Dove il TDD Aggiunge Attrito
Codice esplorativo. Quando stai cercando di capire come qualcosa dovrebbe funzionare — sperimentando con un'API, prototipando un'interfaccia utente, esplorando una struttura dati — scrivere prima i test ti rallenta. Scrivi il codice, stabilizza l'interfaccia, poi aggiungi i test.
Componenti UI. I test di Testing Library per i componenti React sono meglio scritti dopo che il componente esiste, perché l'output renderizzato del componente (su cui i test asseriscono) non è noto finché non lo si costruisce.
Il Ciclo Rosso-Verde-Refactor
Quando pratico il TDD, seguo strettamente il ciclo classico:
- Rosso: Scrivi un test che fallisce (perché il codice non esiste ancora).
- Verde: Scrivi il codice minimo per far passare il test. Non ottimizzare.
- Refactor: Pulisci il codice mantenendo tutti i test verdi.
La disciplina del passaggio 2 — scrivere il codice minimo — è la più importante e la più frequentemente violata. La tentazione di scrivere immediatamente "l'implementazione reale" vanifica lo scopo del TDD, che è quello di lasciare che i test guidino il design in modo incrementale.
L'Investimento Paga Interessi Composti
Ogni test che scrivi ha una durata di vita misurata in anni. Un test scritto oggi catturerà regressioni nel refactoring della prossima settimana, nell'aggiunta di funzionalità del prossimo mese e nell'aggiornamento del framework del prossimo anno. I 10 minuti che dedichi a scrivere un test ti fanno risparmiare 2 ore di debug tra sei mesi, ma non vedi mai esplicitamente quel risparmio — semplicemente non hai mai il bug.
La parte più difficile del testing non sono gli strumenti o le tecniche. È la disciplina di scrivere il test ora, quando il codice è fresco nella tua mente, piuttosto che aggiungerlo al backlog dove muore silenziosamente. La scusa degli strumenti è sparita. La scusa della velocità è sparita. L'unica scusa rimasta è la prioritizzazione, e rilasciare codice testato non dovrebbe essere negoziabile.