Introductie
Laten we eerlijk zijn: testen is allang geen "nice-to-have" meer. Het is een absolute noodzaak voor elke serieuze Next.js-applicatie. En met de komst van de App Router in Next.js 13 — en de verdere verfijningen in versie 14 en 15 — is het testlandschap behoorlijk op z'n kop gezet. Server Components, Server Actions, streaming, een compleet nieuwe manier van data-fetching... het brengt allemaal unieke uitdagingen met zich mee die een doordachte teststrategie vereisen.
Waar we voorheen vrijwel uitsluitend met Client Components werkten, draaien componenten nu standaard op de server. Dat klinkt simpel, maar de gevolgen voor je tests zijn groot. Traditionele testmethoden — zoals het renderen van een component in jsdom — volstaan niet altijd meer. Asynchrone Server Components kun je bijvoorbeeld niet zomaar met React Testing Library testen, simpelweg omdat jsdom geen async component rendering ondersteunt.
In dit artikel bouwen we stap voor stap een complete teststrategie op voor een Next.js App Router-project. We pakken Vitest erbij voor snelle unit- en integratietests, en Playwright voor betrouwbare end-to-end tests. Je leert hoe je Client Components, synchrone Server Components en Server Actions test met Vitest, en hoe je Playwright inzet voor alles wat een echte browser nodig heeft — van asynchrone Server Components tot formulieren en dynamische routes.
De Testpiramide voor Next.js
De klassieke testpiramide is ook in het Next.js-tijdperk nog steeds relevant, al verschuift de invulling wel behoorlijk. Laten we eens kijken hoe de drie lagen zich verhouden tot een App Router-project.
Unit Tests
Unit tests zijn de basis. Ze zijn snel, geïsoleerd en testen individuele functies of componenten. In een Next.js-context gebruik je ze voor:
- Utility-functies en helpers
- Client Components (interactie, state, rendering)
- Synchrone Server Components (statische rendering)
- Server Actions (met gemockte dependencies)
- Validatielogica en data-transformaties
Integratietests
Integratietests verifiëren dat meerdere onderdelen correct samenwerken. Denk aan het testen van een formuliercomponent die een Server Action aanroept, of een pagina die data ophaalt en rendert. Ze zijn iets trager dan unit tests, maar geven je wel veel meer vertrouwen dat het geheel daadwerkelijk werkt.
End-to-End (E2E) Tests
E2E-tests simuleren echte gebruikersinteracties in een browser. Eerlijk gezegd zijn ze onmisbaar voor het testen van:
- Asynchrone Server Components die data fetchen
- Volledige gebruikersflows (registratie, inloggen, bestellen)
- Navigatie tussen pagina's en dynamische routes
- Formulieren met Server Actions in een echte browseromgeving
- Streaming en Suspense-gedrag
Wanneer welk type test?
Om het overzichtelijk te houden, hier een handig overzicht:
| Wat je test | Aanbevolen tool | Type test |
|---|---|---|
| Utility-functies | Vitest | Unit |
| Client Components | Vitest + RTL | Unit / Integratie |
| Synchrone Server Components | Vitest + RTL | Unit |
| Async Server Components | Playwright | E2E |
| Server Actions (geïsoleerd) | Vitest | Unit |
| Server Actions (volledig) | Playwright | E2E |
| Navigatie en routing | Playwright | E2E |
| API Routes | Vitest of Playwright | Integratie / E2E |
Vitest Opzetten in Next.js 15
Vitest is wat mij betreft dé testrunner voor Next.js-projecten op dit moment. Het is razendsnel, heeft native TypeScript-ondersteuning en speelt perfect samen met het Vite-ecosysteem. Laten we de configuratie stap voor stap doorlopen.
Installatie
Eerst installeer je alle benodigde dependencies als devDependencies:
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event vite-tsconfig-paths
Even een overzicht van wat elke package doet:
- vitest — De testrunner zelf
- @vitejs/plugin-react — JSX-transformatie voor React-componenten
- jsdom — Gesimuleerde DOM-omgeving voor tests
- @testing-library/react — Utilities voor het testen van React-componenten
- @testing-library/dom — DOM-query utilities
- @testing-library/jest-dom — Extra matchers zoals
toBeInTheDocument() - @testing-library/user-event — Realistische gebruikersinteracties simuleren
- vite-tsconfig-paths — Ondersteuning voor TypeScript path-aliassen (zoals
@/)
Vitest Configuratie
Maak een vitest.config.mts bestand aan in de root van je project:
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
plugins: [tsconfigPaths(), react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './test/vitest.setup.ts',
include: ['**/*.test.{ts,tsx}'],
exclude: ['node_modules', '.next', 'e2e'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'.next/',
'e2e/',
'**/*.config.*',
'**/types/**',
],
},
},
})
Let even op de exclude-optie: we sluiten de e2e-map uit omdat Playwright z'n eigen testrunner heeft. De tsconfigPaths()-plugin zorgt ervoor dat je path-aliassen uit tsconfig.json ook in je tests werken — superhandig.
Setup-bestand
Maak het setup-bestand test/vitest.setup.ts aan:
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
// Automatische cleanup na elke test
afterEach(() => {
cleanup()
})
Dit bestand importeert de jest-dom matchers zodat je methoden als toBeInTheDocument(), toHaveTextContent() en toBeVisible() kunt gebruiken. De cleanup() na elke test voorkomt dat DOM-elementen uit eerdere tests je resultaten vervuilen.
Scripts toevoegen aan package.json
Voeg de volgende scripts toe aan je package.json:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test"
}
}
Met npm test start je Vitest in watch-modus — ideaal tijdens het ontwikkelen. npm run test:run draait alle tests eenmalig, wat je wilt voor CI/CD.
Client Components Testen met Vitest + React Testing Library
Client Components zijn veruit het makkelijkst te testen. Ze draaien in de browser (of in ons geval, in jsdom) en ondersteunen hooks, state en event handlers. Laten we meteen aan de slag gaan met een praktisch voorbeeld.
Een Counter Component
We beginnen met een klassiek voorbeeld — een simpele teller-component:
// src/components/Counter.tsx
'use client'
import { useState } from 'react'
interface CounterProps {
initialCount?: number
}
export function Counter({ initialCount = 0 }: CounterProps) {
const [count, setCount] = useState(initialCount)
return (
<div>
<p data-testid="count-display">Teller: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Verhogen</button>
<button onClick={() => setCount(c => c - 1)}>Verlagen</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
)
}
En de bijbehorende test:
// src/components/Counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, test } from 'vitest'
import { Counter } from './Counter'
describe('Counter', () => {
test('toont de initiële waarde', () => {
render(<Counter />)
expect(screen.getByTestId('count-display')).toHaveTextContent('Teller: 0')
})
test('accepteert een aangepaste startwaarde', () => {
render(<Counter initialCount={10} />)
expect(screen.getByTestId('count-display')).toHaveTextContent('Teller: 10')
})
test('verhoogt de teller bij klik op Verhogen', async () => {
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button', { name: 'Verhogen' }))
expect(screen.getByTestId('count-display')).toHaveTextContent('Teller: 1')
await user.click(screen.getByRole('button', { name: 'Verhogen' }))
expect(screen.getByTestId('count-display')).toHaveTextContent('Teller: 2')
})
test('verlaagt de teller bij klik op Verlagen', async () => {
const user = userEvent.setup()
render(<Counter initialCount={5} />)
await user.click(screen.getByRole('button', { name: 'Verlagen' }))
expect(screen.getByTestId('count-display')).toHaveTextContent('Teller: 4')
})
test('reset de teller naar 0', async () => {
const user = userEvent.setup()
render(<Counter initialCount={42} />)
await user.click(screen.getByRole('button', { name: 'Reset' }))
expect(screen.getByTestId('count-display')).toHaveTextContent('Teller: 0')
})
})
Een Zoekformulier Testen
Nu een iets uitdagender voorbeeld — een zoekcomponent met debouncing. Dit soort componenten kom je in de praktijk constant tegen:
// src/components/SearchInput.tsx
'use client'
import { useState, useEffect } from 'react'
interface SearchInputProps {
onSearch: (query: string) => void
placeholder?: string
}
export function SearchInput({ onSearch, placeholder = 'Zoeken...' }: SearchInputProps) {
const [query, setQuery] = useState('')
useEffect(() => {
const timer = setTimeout(() => {
if (query.length >= 2) {
onSearch(query)
}
}, 300)
return () => clearTimeout(timer)
}, [query, onSearch])
return (
<div role="search">
<label htmlFor="search-input">Zoeken</label>
<input
id="search-input"
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
/>
{query.length > 0 && query.length < 2 && (
<p role="status">Typ minimaal 2 karakters</p>
)}
</div>
)
}
// src/components/SearchInput.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, test, vi } from 'vitest'
import { SearchInput } from './SearchInput'
describe('SearchInput', () => {
test('toont een validatiemelding bij minder dan 2 karakters', async () => {
const user = userEvent.setup()
const onSearch = vi.fn()
render(<SearchInput onSearch={onSearch} />)
await user.type(screen.getByRole('textbox'), 'a')
expect(screen.getByRole('status')).toHaveTextContent('Typ minimaal 2 karakters')
})
test('roept onSearch aan na debounce', async () => {
const user = userEvent.setup()
const onSearch = vi.fn()
vi.useFakeTimers()
render(<SearchInput onSearch={onSearch} />)
await user.type(screen.getByRole('textbox'), 'Next.js')
// onSearch is nog niet aangeroepen vanwege debounce
expect(onSearch).not.toHaveBeenCalled()
// Verplaats de tijd 300ms vooruit
vi.advanceTimersByTime(300)
expect(onSearch).toHaveBeenCalledWith('Next.js')
vi.useRealTimers()
})
test('toont aangepaste placeholder', () => {
render(<SearchInput onSearch={vi.fn()} placeholder="Zoek artikelen..." />)
expect(screen.getByPlaceholderText('Zoek artikelen...')).toBeInTheDocument()
})
})
Let op dat we userEvent.setup() gebruiken in plaats van fireEvent. Dat is een bewuste keuze: userEvent simuleert veel realistischere gebruikersinteracties (inclusief focus, toetsaanslagen en blur-events), terwijl fireEvent alleen synthetische DOM-events afvuurt. Mijn advies? Gebruik userEvent als standaard en val alleen terug op fireEvent wanneer je echt een specifiek low-level event wilt testen.
Synchrone Server Components Testen
Een vraag die ik vaak tegenkom: Kan ik Server Components testen met Vitest? Het antwoord is... het hangt ervan af. Synchrone Server Components kun je prima testen, maar asynchrone Server Components (die data fetchen of await gebruiken) niet — daarvoor heb je Playwright nodig.
Waarom werken async Server Components niet in Vitest?
De render()-functie van React Testing Library kan simpelweg geen async componenten renderen. Een async Server Component retourneert een Promise, en jsdom weet daar geen raad mee. Het is een fundamentele beperking van de huidige testtools — hopelijk verandert dat in de toekomst, maar voor nu is het wat het is.
Synchrone Server Components testen
Het goede nieuws: synchrone Server Components zijn in wezen gewone functies die JSX retourneren. Die kun je prima in Vitest testen:
// src/components/ProductCard.tsx
// Dit is een Server Component (geen 'use client' directive)
interface Product {
id: string
name: string
price: number
inStock: boolean
}
interface ProductCardProps {
product: Product
}
export function ProductCard({ product }: ProductCardProps) {
const formattedPrice = new Intl.NumberFormat('nl-NL', {
style: 'currency',
currency: 'EUR',
}).format(product.price)
return (
<article data-testid={`product-${product.id}`}>
<h3>{product.name}</h3>
<p>{formattedPrice}</p>
{product.inStock ? (
<span className="text-green-600">Op voorraad</span>
) : (
<span className="text-red-600">Uitverkocht</span>
)}
</article>
)
}
// src/components/ProductCard.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, expect, test } from 'vitest'
import { ProductCard } from './ProductCard'
const mockProduct = {
id: '1',
name: 'Next.js Cursus',
price: 49.99,
inStock: true,
}
describe('ProductCard', () => {
test('toont de productnaam', () => {
render(<ProductCard product={mockProduct} />)
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Next.js Cursus')
})
test('formatteert de prijs in het Nederlands', () => {
render(<ProductCard product={mockProduct} />)
expect(screen.getByText(/€\s?49,99/)).toBeInTheDocument()
})
test('toont "Op voorraad" wanneer product beschikbaar is', () => {
render(<ProductCard product={mockProduct} />)
expect(screen.getByText('Op voorraad')).toBeInTheDocument()
})
test('toont "Uitverkocht" wanneer product niet op voorraad is', () => {
const uitverkochtProduct = { ...mockProduct, inStock: false }
render(<ProductCard product={uitverkochtProduct} />)
expect(screen.getByText('Uitverkocht')).toBeInTheDocument()
})
})
Dit werkt omdat ProductCard een puur synchrone functie is. Het maakt eigenlijk niet uit dat het technisch een Server Component is — het ontvangt props en retourneert JSX, zonder async/await. Zo simpel kan het zijn.
Wanneer moet je Playwright gebruiken?
Zodra een Server Component er zo uitziet, is het tijd om Playwright erbij te pakken:
// Dit kan NIET met Vitest worden getest
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await fetch(`https://api.example.com/products/${params.id}`)
const data = await product.json()
return <ProductCard product={data} />
}
Die async keyword maakt het onmogelijk om dit component in jsdom te renderen. Hier heb je een echte browser voor nodig.
Server Actions Testen met Vitest
Server Actions zijn functies die op de server draaien en vaak gebruikmaken van Next.js-specifieke features als redirect(), revalidatePath() en cookies(). Het goede nieuws is dat je ze prima geïsoleerd kunt testen — mits je de juiste dependencies mockt.
Een Server Action voorbeeld
// src/app/actions/contact.ts
'use server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
interface ContactFormState {
success: boolean
errors?: Record<string, string>
}
export async function submitContactForm(
prevState: ContactFormState,
formData: FormData
): Promise<ContactFormState> {
const name = formData.get('name') as string
const email = formData.get('email') as string
const message = formData.get('message') as string
// Validatie
const errors: Record<string, string> = {}
if (!name || name.trim().length < 2) {
errors.name = 'Naam moet minimaal 2 karakters bevatten'
}
if (!email || !email.includes('@')) {
errors.email = 'Voer een geldig e-mailadres in'
}
if (!message || message.trim().length < 10) {
errors.message = 'Bericht moet minimaal 10 karakters bevatten'
}
if (Object.keys(errors).length > 0) {
return { success: false, errors }
}
// Simuleer het opslaan in een database
await saveToDatabase({ name, email, message })
revalidatePath('/contact')
redirect('/contact/bedankt')
}
async function saveToDatabase(data: {
name: string
email: string
message: string
}) {
// Database-operatie
console.log('Opgeslagen:', data)
}
De Server Action testen
// src/app/actions/contact.test.ts
import { describe, expect, test, vi, beforeEach } from 'vitest'
// Mock Next.js modules VOOR de import van de action
vi.mock('next/navigation', () => ({
redirect: vi.fn(),
}))
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
}))
import { submitContactForm } from './contact'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
function createFormData(data: Record<string, string>): FormData {
const formData = new FormData()
Object.entries(data).forEach(([key, value]) => {
formData.append(key, value)
})
return formData
}
describe('submitContactForm', () => {
const initialState = { success: false }
beforeEach(() => {
vi.clearAllMocks()
})
test('retourneert fouten bij lege velden', async () => {
const formData = createFormData({
name: '',
email: '',
message: '',
})
const result = await submitContactForm(initialState, formData)
expect(result.success).toBe(false)
expect(result.errors?.name).toBe('Naam moet minimaal 2 karakters bevatten')
expect(result.errors?.email).toBe('Voer een geldig e-mailadres in')
expect(result.errors?.message).toBe('Bericht moet minimaal 10 karakters bevatten')
})
test('retourneert fout bij ongeldig e-mailadres', async () => {
const formData = createFormData({
name: 'Jan Jansen',
email: 'ongeldig-email',
message: 'Dit is een testbericht voor het formulier.',
})
const result = await submitContactForm(initialState, formData)
expect(result.success).toBe(false)
expect(result.errors?.email).toBe('Voer een geldig e-mailadres in')
})
test('redirect naar bedankpagina bij succesvolle verzending', async () => {
const formData = createFormData({
name: 'Jan Jansen',
email: '[email protected]',
message: 'Dit is een testbericht voor het contactformulier.',
})
await submitContactForm(initialState, formData)
expect(revalidatePath).toHaveBeenCalledWith('/contact')
expect(redirect).toHaveBeenCalledWith('/contact/bedankt')
})
test('roept revalidatePath niet aan bij validatiefouten', async () => {
const formData = createFormData({
name: 'J',
email: '[email protected]',
message: 'Te kort',
})
await submitContactForm(initialState, formData)
expect(revalidatePath).not.toHaveBeenCalled()
expect(redirect).not.toHaveBeenCalled()
})
})
Het cruciale punt hier is de volgorde van je imports: vi.mock() moet voor de import van de te testen module staan. Vitest hijst de mock-aanroepen automatisch naar de bovenkant, maar het is gewoon een goede gewoonte om dit expliciet te doen. Scheelt een hoop verwarring later.
Meer complexe Server Actions mocken
Soms moet je ook cookies() of headers() mocken. Dat kan er zo uitzien:
// Voorbeeld: cookies mocken
vi.mock('next/headers', () => ({
cookies: vi.fn(() => ({
get: vi.fn((name: string) => {
if (name === 'session') return { value: 'mock-session-token' }
return undefined
}),
set: vi.fn(),
delete: vi.fn(),
})),
headers: vi.fn(() => new Map([
['x-forwarded-for', '127.0.0.1'],
['user-agent', 'vitest'],
])),
}))
Playwright Opzetten voor Next.js
Playwright is een krachtig framework voor end-to-end testen met ondersteuning voor meerdere browsers. Het is bijzonder geschikt voor Next.js omdat het je applicatie in een echte browser draait — Server Components, streaming, Server Actions en al.
Installatie
npm install -D @playwright/test
npx playwright install
Dat tweede commando installeert de browserbinaries voor Chromium, Firefox en WebKit. Dat kan even duren (afhankelijk van je internetverbinding), maar het hoeft maar één keer.
Playwright Configuratie
Maak een playwright.config.ts bestand aan in de root van je project:
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? 'github' : 'html',
timeout: 30_000,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: {
command: 'npm run build && npm run start',
port: 3000,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 5'] },
},
],
})
De webServer-configuratie is hier de sleutel: Playwright bouwt en start je Next.js-applicatie automatisch vóór de tests beginnen. Lokaal (reuseExistingServer: true) hergebruikt het je al draaiende dev-server, terwijl het in CI altijd een verse build maakt.
Een paar opties die het uitlichten waard zijn:
- fullyParallel — Tests draaien parallel voor maximale snelheid
- forbidOnly — Voorkomt dat
.onlyper ongeluk in CI terechtkomt (we've all been there) - retries — Twee herkansingen in CI voor die ene flaky test
- trace — Uitgebreide trace-informatie bij de eerste retry, onmisbaar voor debugging
- screenshot — Automatisch screenshots bij falende tests
E2E Tests met Playwright
Nu Playwright geconfigureerd is, kunnen we aan de slag met E2E-tests voor de scenario's die Vitest niet aankan: asynchrone Server Components, formulieren met Server Actions en navigatie.
Asynchrone Server Components Testen
Stel dat we een productenpagina hebben die data van een API ophaalt:
// src/app/producten/page.tsx
export default async function ProductenPage() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 },
})
const products = await res.json()
return (
<main>
<h1>Onze Producten</h1>
<div data-testid="product-grid">
{products.map((product: any) => (
<article key={product.id} data-testid={`product-${product.id}`}>
<h2>{product.name}</h2>
<p>{product.price}</p>
</article>
))}
</div>
</main>
)
}
De E2E-test hiervoor:
// e2e/producten.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Productenpagina', () => {
test('toont een lijst met producten', async ({ page }) => {
await page.goto('/producten')
// Wacht tot de producten geladen zijn
await expect(page.getByTestId('product-grid')).toBeVisible()
// Controleer dat er producten zichtbaar zijn
const products = page.locator('article')
await expect(products).toHaveCount(await products.count())
expect(await products.count()).toBeGreaterThan(0)
})
test('toont de paginatitel', async ({ page }) => {
await page.goto('/producten')
await expect(page.getByRole('heading', { level: 1 }))
.toHaveText('Onze Producten')
})
test('laadt producten met correcte structuur', async ({ page }) => {
await page.goto('/producten')
// Wacht op het eerste product
const firstProduct = page.getByTestId('product-grid').locator('article').first()
await expect(firstProduct).toBeVisible()
// Controleer dat elk product een naam en prijs heeft
await expect(firstProduct.getByRole('heading', { level: 2 })).toBeVisible()
await expect(firstProduct.locator('p')).toBeVisible()
})
})
Formulieren met Server Actions Testen
Dit is eerlijk gezegd een van de krachtigste use cases van Playwright — het testen van formulieren die Server Actions aanroepen in een echte browser:
// e2e/contact.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Contactformulier', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/contact')
})
test('toont validatiefouten bij leeg formulier', async ({ page }) => {
// Klik op verzenden zonder iets in te vullen
await page.getByRole('button', { name: 'Verzenden' }).click()
// Controleer dat foutmeldingen verschijnen
await expect(page.getByText('Naam moet minimaal 2 karakters bevatten'))
.toBeVisible()
await expect(page.getByText('Voer een geldig e-mailadres in'))
.toBeVisible()
})
test('verzendt het formulier succesvol', async ({ page }) => {
// Vul het formulier in
await page.getByLabel('Naam').fill('Jan Jansen')
await page.getByLabel('E-mail').fill('[email protected]')
await page.getByLabel('Bericht').fill('Dit is een testbericht voor het contactformulier.')
// Verzend het formulier
await page.getByRole('button', { name: 'Verzenden' }).click()
// Controleer redirect naar bedankpagina
await expect(page).toHaveURL('/contact/bedankt')
await expect(page.getByRole('heading', { level: 1 }))
.toHaveText('Bedankt voor uw bericht!')
})
test('behoudt ingevulde waarden bij validatiefouten', async ({ page }) => {
// Vul deels in
await page.getByLabel('Naam').fill('Jan Jansen')
await page.getByLabel('E-mail').fill('ongeldig')
await page.getByLabel('Bericht').fill('Te kort')
await page.getByRole('button', { name: 'Verzenden' }).click()
// Controleer dat waarden behouden zijn
await expect(page.getByLabel('Naam')).toHaveValue('Jan Jansen')
// Controleer foutmeldingen
await expect(page.getByText('Voer een geldig e-mailadres in')).toBeVisible()
})
})
Navigatie en Dynamische Routes Testen
// e2e/navigatie.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Navigatie', () => {
test('navigeert van de homepagina naar producten', async ({ page }) => {
await page.goto('/')
// Klik op navigatielink
await page.getByRole('link', { name: 'Producten' }).click()
// Controleer URL en paginainhoud
await expect(page).toHaveURL('/producten')
await expect(page.getByRole('heading', { level: 1 }))
.toHaveText('Onze Producten')
})
test('navigeert naar een productdetailpagina', async ({ page }) => {
await page.goto('/producten')
// Klik op het eerste product
const firstProductLink = page.getByRole('link').first()
const productName = await firstProductLink.textContent()
await firstProductLink.click()
// Controleer dat we op de detailpagina zijn
await expect(page.url()).toContain('/producten/')
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
})
test('toont een 404-pagina voor onbekende routes', async ({ page }) => {
const response = await page.goto('/deze-pagina-bestaat-niet')
expect(response?.status()).toBe(404)
await expect(page.getByText('Pagina niet gevonden')).toBeVisible()
})
})
Mobiele Tests en Responsive Design
Dankzij de projects-configuratie test Playwright automatisch in meerdere browsers. Maar je kunt ook heel specifiek mobiel gedrag testen:
// e2e/mobiel.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Mobiele navigatie', () => {
test.use({ viewport: { width: 375, height: 667 } })
test('toont hamburger-menu op mobiel', async ({ page }) => {
await page.goto('/')
// Desktop navigatie is verborgen
await expect(page.getByRole('navigation', { name: 'Desktop menu' }))
.toBeHidden()
// Hamburger-knop is zichtbaar
const menuButton = page.getByRole('button', { name: 'Menu openen' })
await expect(menuButton).toBeVisible()
// Open het menu
await menuButton.click()
// Mobiel menu is nu zichtbaar
await expect(page.getByRole('navigation', { name: 'Mobiel menu' }))
.toBeVisible()
})
})
Testcoverage Combineren
Een volledig beeld van je testcoverage krijg je pas als je resultaten uit zowel Vitest als Playwright samenvoegt. Dat is eerlijk gezegd niet heel triviaal, maar het kan wel.
Vitest Coverage
Vitest ondersteunt coverage out of the box via de V8- of Istanbul-provider:
npx vitest run --coverage
Dit genereert een coverage-rapport in de coverage/-map. Zorg dat je de provider en reporter hebt geconfigureerd in vitest.config.mts zoals we eerder besproken hebben.
Playwright Coverage
Playwright kan ook code coverage verzamelen via de V8 coverage API van Chromium. Daarvoor heb je een fixture nodig:
// e2e/fixtures.ts
import { test as base } from '@playwright/test'
export const test = base.extend({
page: async ({ page }, use) => {
// Start coverage voor deze test
await page.coverage.startJSCoverage()
await use(page)
// Verzamel coverage na de test
const coverage = await page.coverage.stopJSCoverage()
// Sla coverage op voor latere verwerking
// Dit vereist extra tooling om samen te voegen
},
})
Coverage Samenvoegen
Om coverage uit Vitest en Playwright samen te voegen, kun je nyc (de Istanbul CLI) gebruiken. Het idee: beide tools slaan hun coverage op in een standaardformaat, waarna je ze mergt:
# Genereer Vitest coverage in JSON-formaat
npx vitest run --coverage --coverage.reporter=json
# Voeg coverage bestanden samen met nyc
npx nyc merge ./coverage ./e2e-coverage --output merged-coverage.json
# Genereer een rapport
npx nyc report --reporter=html --reporter=text --temp-dir=./merged-coverage
In de praktijk kiezen veel teams ervoor om Vitest-coverage en Playwright-coverage als aparte, complementaire rapporten te behandelen in plaats van ze geforceerd samen te voegen. Dat werkt ook prima — zolang je maar een goed beeld hebt van wat er gedekt is.
CI/CD Integratie
Zonder een goede CI/CD-pipeline is al je testwerk eigenlijk voor niets. Hier is een complete GitHub Actions workflow die zowel Vitest als Playwright draait.
Basis GitHub Actions Workflow
# .github/workflows/tests.yml
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
unit-tests:
name: Unit & Integratietests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Vitest uitvoeren met coverage
run: npx vitest run --coverage
- name: Coverage rapport uploaden
uses: actions/upload-artifact@v4
if: always()
with:
name: vitest-coverage
path: coverage/
e2e-tests:
name: E2E Tests
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Playwright browsers installeren
run: npx playwright install --with-deps
- name: Playwright tests uitvoeren
run: npx playwright test
- name: Playwright rapport uploaden
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
Geavanceerde Workflow met Matrix en Caching
Voor grotere projecten kun je de workflow flink uitbreiden met een matrix-strategie en slimmere caching:
# .github/workflows/tests-advanced.yml
name: Tests (Geavanceerd)
on:
push:
branches: [main]
pull_request:
env:
CI: true
jobs:
lint-and-typecheck:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npx tsc --noEmit
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
needs: lint-and-typecheck
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx vitest run --coverage
- uses: actions/upload-artifact@v4
if: always()
with:
name: vitest-coverage
path: coverage/
e2e-tests:
name: E2E (${{ matrix.project }})
runs-on: ubuntu-latest
needs: lint-and-typecheck
strategy:
fail-fast: false
matrix:
project: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npx playwright install --with-deps ${{ matrix.project }}
- run: npx playwright test --project=${{ matrix.project }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-${{ matrix.project }}
path: playwright-report/
Wat doet deze geavanceerde workflow precies?
- Lint & Type Check — Checkt codestandaarden en TypeScript-fouten als allereerste
- Unit Tests — Draait Vitest met coverage, parallel aan de E2E-tests
- E2E Tests (matrix) — Draait Playwright-tests per browser apart, zodat ze parallel kunnen lopen en je sneller feedback hebt
De fail-fast: false optie is belangrijk: zo maken alle browsers hun tests af, ook als er eentje faalt. Dat geeft je een compleet beeld van de status per browser.
Best Practices en Conclusie
Best Practices voor Testen in Next.js
Nu je teststrategie staat, zijn hier de richtlijnen die ik je wil meegeven. Dit zijn dingen die ik in de loop der tijd heb geleerd (soms op de harde manier).
1. Houd tests dicht bij de broncode
Plaats unit tests naast de bestanden die ze testen. Gebruik de conventie ComponentName.test.tsx naast ComponentName.tsx. Zo zie je in één oogopslag welke bestanden getest zijn en welke niet.
src/
components/
Counter.tsx
Counter.test.tsx
SearchInput.tsx
SearchInput.test.tsx
app/
actions/
contact.ts
contact.test.ts
e2e/
contact.spec.ts
navigatie.spec.ts
producten.spec.ts
2. Test gedrag, niet implementatie
Focus op wat de gebruiker ziet en doet, niet op interne state of implementatiedetails. Gebruik bij voorkeur queries die een echte gebruiker ook zou herkennen:
// Goed: test vanuit gebruikersperspectief
screen.getByRole('button', { name: 'Verzenden' })
screen.getByLabelText('E-mail')
screen.getByText('Welkom terug')
// Vermijd: testen van implementatiedetails
screen.getByTestId('submit-btn-internal')
container.querySelector('.btn-primary')
Gebruik data-testid alleen als allerlaatste redmiddel, wanneer er echt geen semantisch alternatief is.
3. Gebruik page objects in Playwright
Voor grotere E2E-testsuites helpt het Page Object Model (POM)-patroon enorm bij het verminderen van duplicatie. Je maakt een class die de pagina representeert:
// e2e/pages/ContactPage.ts
import { type Page, type Locator } from '@playwright/test'
export class ContactPage {
readonly page: Page
readonly nameInput: Locator
readonly emailInput: Locator
readonly messageInput: Locator
readonly submitButton: Locator
constructor(page: Page) {
this.page = page
this.nameInput = page.getByLabel('Naam')
this.emailInput = page.getByLabel('E-mail')
this.messageInput = page.getByLabel('Bericht')
this.submitButton = page.getByRole('button', { name: 'Verzenden' })
}
async goto() {
await this.page.goto('/contact')
}
async fillForm(name: string, email: string, message: string) {
await this.nameInput.fill(name)
await this.emailInput.fill(email)
await this.messageInput.fill(message)
}
async submit() {
await this.submitButton.click()
}
}
// e2e/contact-pom.spec.ts
import { test, expect } from '@playwright/test'
import { ContactPage } from './pages/ContactPage'
test('verzendt het contactformulier succesvol', async ({ page }) => {
const contactPage = new ContactPage(page)
await contactPage.goto()
await contactPage.fillForm(
'Jan Jansen',
'[email protected]',
'Dit is een testbericht via het Page Object Model.'
)
await contactPage.submit()
await expect(page).toHaveURL('/contact/bedankt')
})
4. Mock externe services
Voor betrouwbare tests wil je niet afhankelijk zijn van externe API's die op elk moment down kunnen gaan. In Vitest gebruik je vi.mock(), en in Playwright kun je netwerkverzoeken onderscheppen:
// Playwright: API-aanroepen mocken
test('toont producten van gemockte API', async ({ page }) => {
// Onderschep API-aanroepen
await page.route('**/api/products', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: '1', name: 'Test Product', price: 29.99 },
{ id: '2', name: 'Ander Product', price: 49.99 },
]),
})
})
await page.goto('/producten')
await expect(page.getByText('Test Product')).toBeVisible()
await expect(page.getByText('Ander Product')).toBeVisible()
})
5. Gebruik beschrijvende testnamen
Schrijf testnamen die het verwachte gedrag beschrijven. Als een test faalt, wil je meteen weten wat er mis is zonder de testcode te hoeven lezen:
// Goed: beschrijft het verwachte resultaat
test('toont een foutmelding wanneer het e-mailadres ongeldig is', ...)
test('navigeert naar de bedankpagina na succesvolle verzending', ...)
test('behoudt formulierwaarden na een validatiefout', ...)
// Minder goed: beschrijft de implementatie
test('setState wordt aangeroepen met error', ...)
test('redirect functie wordt aangeroepen', ...)
test('formData bevat de juiste waarden', ...)
6. Zorg voor onafhankelijke tests
Elke test moet op zichzelf kunnen draaien, zonder afhankelijk te zijn van andere tests. Vermijd gedeelde state en gebruik beforeEach om een schone uitgangssituatie te creëren. In Playwright heeft elke test sowieso z'n eigen browsercontext, wat mooi is.
7. Wees bewust van de beperkingen
Een paar dingen om in je achterhoofd te houden bij het testen van Next.js App Router-applicaties:
- Async Server Components kunnen niet met Vitest worden getest — gebruik Playwright
- Server Actions die
redirect()aanroepen, gooien in productie een speciale error — je mock moet dit mogelijk afhandelen - Middleware is lastig unit-testbaar — overweeg E2E-tests daarvoor
- Route Handlers (API Routes) kun je testen door ze als gewone functies te importeren, of via Playwright
- next/image en next/font vereisen soms extra mocking in Vitest
8. Optimaliseer je CI-pipeline
Een paar praktische tips om je CI snel te houden:
- Cache node_modules en Playwright-browsers
- Draai unit tests en E2E-tests parallel in aparte jobs
- Gebruik de
--shard-optie van Playwright om tests over meerdere machines te verdelen - Beperk het aantal browsers in CI tot wat je echt nodig hebt
- Gebruik
--changedof--sincein Vitest om alleen gewijzigde bestanden te testen bij pull requests
# Playwright sharding over meerdere CI-jobs
npx playwright test --shard=1/4
npx playwright test --shard=2/4
npx playwright test --shard=3/4
npx playwright test --shard=4/4
Samenvatting van de Teststrategie
Laten we alles even samenvatten in een overzichtelijk model:
| Laag | Tool | Wat | Snelheid |
|---|---|---|---|
| Unit | Vitest + RTL | Functies, Client Components, sync RSC, Server Actions | Zeer snel |
| Integratie | Vitest + RTL | Component-interacties, formuliervalidatie | Snel |
| E2E | Playwright | Async RSC, volledige flows, navigatie, Server Actions in browser | Langzamer |
Conclusie
Testen in Next.js App Router-applicaties vraagt om een weloverwogen aanpak. De combinatie van Server Components, Server Actions en streaming maakt dat geen enkele tool alles kan afdekken. Maar door Vitest en Playwright slim te combineren, krijg je het beste van twee werelden: razendsnelle unit tests voor je logica en componenten, en betrouwbare E2E-tests voor alles wat een echte browser vereist.
Mijn tip: begin met Vitest voor je unit tests. Dat geeft de snelste feedback en dekt het grootste deel van je codebase af. Voeg vervolgens Playwright toe voor je kritische gebruikersflows en de onderdelen die niet in jsdom kunnen draaien. Integreer beide in je CI/CD-pipeline en je hebt een solide vangnet waarmee je met vertrouwen kunt deployen.
De sleutel tot succes is uiteindelijk pragmatisme: test wat waardevol is, focus op gebruikersgedrag boven implementatiedetails, en investeer je testtijd daar waar de risico's het grootst zijn. Een goed geteste Next.js-applicatie is niet alleen betrouwbaarder — het maakt je hele team productiever, omdat iedereen met vertrouwen wijzigingen kan doorvoeren.