20 component tests covering Button (variants, disabled state, event handlers) and ProductCard (rendering, price formatting, sale badge, image fallback). README documents the full suite: 151 tests across 10 files, how to run, mock patterns, and what's missing by priority (checkout flow, admin routes, more components).
Suite di Test
Tutti i test automatizzati del progetto stanno qui, separati da app/ per una scelta precisa: il codice di produzione rimane pulito, la configurazione Vitest è indipendente dal build di Next.js, e i test girano senza avviare Docker o il database.
Installazione e comandi
I comandi vanno lanciati dalla cartella app/:
cd ~/ecommerce-platform/app
npm install # installa le dipendenze (incluse quelle di test)
npm run test # esegui tutti i test una volta (modalità CI)
npm run test:watch # riesegui automaticamente al salvataggio (sviluppo)
npm run test:coverage # genera il report HTML in app/coverage/index.html
Stato attuale: 151 test, 10 file
| File | Area | Test |
|---|---|---|
unit/lib/validate.test.ts |
10 schemi Zod (login, register, prodotti, ecc.) | 49 |
unit/lib/auth.test.ts |
hashToken, hashPassword, sessioni, getCurrentUser | 26 |
unit/lib/storage.test.ts |
magic bytes JPEG/PNG/WebP/ICO, saveImage, deleteImageFile | 11 |
unit/lib/email.test.ts |
sendEmail, sendOrderConfirmationEmail, sendPasswordResetEmail | 9 |
unit/lib/rate-limit.test.ts |
checkRateLimit (finestra 15 min), recordAttempt | 7 |
integration/api/auth/login.test.ts |
POST /api/auth/login — rate limit, validazione, credenziali | 9 |
integration/api/auth/register.test.ts |
POST /api/auth/register — duplicati, password, sessione | 9 |
integration/api/webhooks/stripe.test.ts |
checkout.session.completed, payment_intent, firma mancante | 11 |
components/ui/Button.test.tsx |
loading, disabled, varianti, onClick | 10 |
components/storefront/ProductCard.test.tsx |
prezzo, slug, immagine, placeholder "No image" | 10 |
Struttura
test/
├── vitest.config.ts # ambiente happy-dom, alias @/, soglie copertura 70%
├── setup.ts # env vars mock, mock globale next/headers e next/navigation
├── tsconfig.json # alias @/ per il type-checker dell'IDE
│
├── unit/lib/ # funzioni pure di app/src/lib/ (nessun DB reale)
├── integration/api/ # API route Next.js 14 App Router (Prisma mockato)
│ ├── auth/
│ └── webhooks/
├── components/ # componenti React con @testing-library/react
│ ├── ui/
│ └── storefront/
├── __mocks__/
│ └── prisma.ts # mock centralizzato del Prisma client (tutti i vi.fn())
└── fixtures/
├── users.ts # mockUser, mockAdmin, mockSession
└── orders.ts # mockOrder, mockPayment, eventi Stripe
Come funziona il mock di Prisma
Il file test/__mocks__/prisma.ts esporta un oggetto con vi.fn() per ogni metodo Prisma usato nel progetto. Nei test di integrazione basta importarlo all'inizio del file — il vi.mock('@/lib/prisma') è già incluso dentro.
import '../../../__mocks__/prisma' // monta il mock E chiama vi.mock internamente
import { prisma } from '@/lib/prisma'
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any)
})
Nota sui percorsi: i test di integrazione si trovano 3 livelli sotto
test/(integration/api/auth/), quindi il percorso relativo verso__mocks__/è../../../__mocks__/prisma. I test unitari inunit/lib/usano invece../../__mocks__/prisma.
Come testare una API route
Le route App Router sono funzioni TypeScript che accettano NextRequest → NextResponse. Si importano e si chiamano direttamente, senza un server HTTP.
import { NextRequest } from 'next/server'
import { POST } from '@/app/api/auth/login/route'
it('restituisce 401 per password errata', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any)
vi.mocked(verifyPassword).mockResolvedValue(false)
const req = new NextRequest(
new Request('http://localhost/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '1.2.3.4' },
body: JSON.stringify({ email: 'test@example.com', password: 'WrongPass1!' }),
})
)
const res = await POST(req)
expect(res.status).toBe(401)
expect((await res.json()).error).toBe('Invalid email or password')
})
Per route con parametri dinamici (es. /api/admin/products/[id]):
import { GET } from '@/app/api/admin/products/[id]/route'
const res = await GET(req, { params: { id: 'prod-123' } })
Aggiungere nuovi test
Test unitario (per una nuova funzione in lib/): copia la struttura di unit/lib/email.test.ts. Mocka le dipendenze esterne in cima con vi.mock(...), poi testa solo la logica della funzione.
Test di integrazione (per una nuova API route):
- Crea il file nella cartella corrispondente sotto
integration/api/ - Importa
'../../../__mocks__/prisma'(o'../../__mocks__/prisma'a seconda della profondità) - Mocka lib esterne (
@/lib/auth,@/lib/stripe, ecc.) convi.mock - Prepara i mock nel
beforeEachconvi.clearAllMocks()+ valori di ritorno
Test di componente: copia components/ui/Button.test.tsx. Usa render() e screen di @testing-library/react. Testa comportamento visibile, non implementazione interna.
Copertura
La soglia minima configurata in vitest.config.ts è 70% di linee e funzioni. Il comando npm run test:coverage fallisce se non viene rispettata. La copertura attuale è circa 11% di linee e 33% di funzioni — la soglia va abbassata temporaneamente o la suite va estesa.
Cosa manca (per priorità)
Alta priorità — logica di business critica
| File | Cosa testare |
|---|---|
lib/stripe.ts |
createCheckoutSession: parametri passati a Stripe, metadati orderId |
api/auth/logout/route.ts |
cancellazione cookie, chiamata a deleteSession |
api/auth/me/route.ts |
utente autenticato → 200, non autenticato → 401 |
api/auth/change-password/route.ts |
password errata → 401, nuova password debole → 400, successo → 200 |
api/checkout/route.ts |
utente non autenticato → 401, prodotto inesistente → 400, creazione ordine + sessione Stripe |
Media priorità — route admin (CRUD)
| File | Cosa testare |
|---|---|
api/admin/products/route.ts |
GET con paginazione/filtri, POST crea prodotto, accesso CUSTOMER → 403 |
api/admin/products/[id]/route.ts |
GET, PATCH, DELETE — prodotto inesistente → 404 |
api/admin/orders/route.ts |
lista ordini con filtri di stato |
api/admin/orders/[id]/route.ts |
aggiornamento stato ordine |
api/admin/categories/route.ts |
CRUD categorie, slug duplicato → 409 |
api/admin/settings/route.ts |
lettura e scrittura impostazioni sito |
api/admin/users/route.ts |
creazione admin, email duplicata → 409 |
Bassa priorità — componenti UI
| File | Cosa testare |
|---|---|
components/ui/Input.tsx |
rendering label, messaggio di errore, stato disabled |
components/ui/Alert.tsx |
varianti (success, error, warning, info) |
components/ui/Badge.tsx |
varianti colore, testo |
components/ui/Card.tsx |
rendering children |
Come implementare i test mancanti
unit/lib/stripe.test.ts
Crea il file in test/unit/lib/. Mock dell'intero modulo stripe con vi.hoisted (necessario perché stripe.ts istanzia new Stripe(...) a module load time):
const { mockCreate, mockConstructEvent } = vi.hoisted(() => ({
mockCreate: vi.fn().mockResolvedValue({ id: 'cs_test', url: 'https://stripe.com/pay/cs_test' }),
mockConstructEvent: vi.fn(),
}))
vi.mock('stripe', () => ({
default: vi.fn().mockImplementation(() => ({
checkout: { sessions: { create: mockCreate } },
webhooks: { constructEvent: mockConstructEvent },
})),
}))
import { createCheckoutSession, constructWebhookEvent } from '@/lib/stripe'
it('passa orderId nei metadata', async () => {
await createCheckoutSession({
orderId: 'order-1',
lineItems: [{ price_data: { currency: 'eur', unit_amount: 1000, product_data: { name: 'T-shirt' } }, quantity: 1 }],
customerEmail: 'user@example.com',
successUrl: 'http://localhost/success',
cancelUrl: 'http://localhost/cancel',
})
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({
metadata: { orderId: 'order-1' },
}))
})
integration/api/auth/logout.test.ts
Crea in test/integration/api/auth/. La route logout legge il cookie, cancella la sessione e pulisce il cookie:
import '../../../__mocks__/prisma'
import { NextRequest } from 'next/server'
import { POST } from '@/app/api/auth/logout/route'
import { cookies } from 'next/headers'
vi.mock('@/lib/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth')>()
return { ...actual, deleteSession: vi.fn(), clearSessionCookie: vi.fn() }
})
import { deleteSession, clearSessionCookie } from '@/lib/auth'
it('cancella la sessione e il cookie se il token esiste', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'mytoken' }), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
const res = await POST()
expect(res.status).toBe(200)
expect(deleteSession).toHaveBeenCalledWith('mytoken')
expect(clearSessionCookie).toHaveBeenCalled()
})
it('risponde 200 anche senza token (utente già sloggato)', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue(undefined), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
const res = await POST()
expect(res.status).toBe(200)
expect(deleteSession).not.toHaveBeenCalled()
})
integration/api/auth/me.test.ts
La route me chiama getCurrentUser() — mocka solo @/lib/auth:
vi.mock('@/lib/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth')>()
return { ...actual, getCurrentUser: vi.fn() }
})
import { getCurrentUser } from '@/lib/auth'
it('restituisce i dati utente se autenticato', async () => {
vi.mocked(getCurrentUser).mockResolvedValue(mockUser as any)
const res = await GET()
expect(res.status).toBe(200)
expect((await res.json()).user.email).toBe(mockUser.email)
expect((await res.json()).user).not.toHaveProperty('passwordHash')
})
it('restituisce 401 se non autenticato', async () => {
vi.mocked(getCurrentUser).mockResolvedValue(null)
const res = await GET()
expect(res.status).toBe(401)
})
integration/api/auth/change-password.test.ts
La route richiede getCurrentUser, verifyPassword, hashPassword e prisma.user.update:
import '../../../__mocks__/prisma'
import { prisma } from '@/lib/prisma'
vi.mock('@/lib/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth')>()
return { ...actual, getCurrentUser: vi.fn(), verifyPassword: vi.fn(), hashPassword: vi.fn().mockResolvedValue('newhash') }
})
import { getCurrentUser, verifyPassword } from '@/lib/auth'
// beforeEach: clearAllMocks, loginAttempt.count → 0, user.update → mockUser
it('restituisce 401 se non autenticato', async () => {
vi.mocked(getCurrentUser).mockResolvedValue(null)
const res = await POST(makeRequest({ currentPassword: 'Old1!', newPassword: 'NewPass123!' }))
expect(res.status).toBe(401)
})
it('restituisce 400 se la password attuale è errata', async () => {
vi.mocked(getCurrentUser).mockResolvedValue({ ...mockUser, passwordHash: 'hash' } as any)
vi.mocked(verifyPassword).mockResolvedValue(false)
const res = await POST(makeRequest({ currentPassword: 'Wrong1!', newPassword: 'NewPass123!' }))
expect(res.status).toBe(400)
expect((await res.json()).error).toMatch(/incorrect/)
})
it('aggiorna la password e azzera mustChangePassword', async () => {
vi.mocked(getCurrentUser).mockResolvedValue({ ...mockUser, passwordHash: 'hash' } as any)
vi.mocked(verifyPassword).mockResolvedValue(true)
vi.mocked(prisma.user.update).mockResolvedValue(mockUser as any)
const res = await POST(makeRequest({ currentPassword: 'OldPass1!', newPassword: 'NewPass123!' }))
expect(res.status).toBe(200)
expect(prisma.user.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ mustChangePassword: false }),
}))
})
integration/api/admin/products.test.ts
Le route admin verificano il ruolo prima di tutto. Pattern chiave da testare: accesso negato, paginazione, creazione con audit log:
import '../../../__mocks__/prisma'
import { prisma } from '@/lib/prisma'
import { mockAdmin, mockUser } from '../../../fixtures/users'
vi.mock('@/lib/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth')>()
return { ...actual, getCurrentUser: vi.fn() }
})
import { getCurrentUser } from '@/lib/auth'
// Test GET
it('restituisce 403 per utente CUSTOMER', async () => {
vi.mocked(getCurrentUser).mockResolvedValue(mockUser as any) // role: CUSTOMER
const res = await GET(makeRequest())
expect(res.status).toBe(403)
})
it('restituisce la lista prodotti paginata per ADMIN', async () => {
vi.mocked(getCurrentUser).mockResolvedValue(mockAdmin as any)
vi.mocked(prisma.product.findMany).mockResolvedValue([])
vi.mocked(prisma.product.count).mockResolvedValue(0)
const res = await GET(makeRequest())
expect(res.status).toBe(200)
const data = await res.json()
expect(data).toHaveProperty('pagination')
})
// Test POST
it('crea il prodotto e scrive l\'audit log', async () => {
vi.mocked(getCurrentUser).mockResolvedValue(mockAdmin as any)
vi.mocked(prisma.product.create).mockResolvedValue(mockProduct as any)
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as any)
const res = await POST(makeRequest(validProduct))
expect(res.status).toBe(201)
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'CREATE', entity: 'Product' }) })
)
})
components/ui/Input.test.tsx, Alert.test.tsx, Badge.test.tsx
Stessa struttura di Button.test.tsx. Leggi prima il file sorgente del componente, poi testa solo ciò che è visibile:
import { render, screen } from '@testing-library/react'
import { Input } from '@/components/ui/Input'
it('mostra il messaggio di errore', () => {
render(<Input label="Email" error="Campo obbligatorio" />)
expect(screen.getByText('Campo obbligatorio')).toBeTruthy()
})
it('ha attributo disabled quando disabled=true', () => {
render(<Input label="Email" disabled />)
expect(screen.getByRole('textbox')).toBeDisabled()
})
Da non testare
- Le 23 pagine
page.tsx— Server Components Next.js con fetch dati, richiedono il runtime Next.js completo per essere testati in modo significativo UserContext.tsx— context React client-side, coperto implicitamente dai test di integrazionemiddleware.ts— header di sicurezza statici, nessuna logica da verificare