Testa Next.js-applikationer
Jest, React Testing Library och Playwright i Next.js: vad du bör testa på varje nivå, Server Component-begränsningar och ett praktiskt upplägg.
Testning är en av de frågor som delas upp tydligast i två läger: de som testar allt och de som inte testar alls. Båda extremerna är problematiska. Det första ger en testsuite som tar tio minuter att köra och bromsar varje ändring. Det andra ger en kodbas där varje deploy är ett vågspel.
Den praktiska vägen ligger mitt emellan: testa det som faktiskt kan gå sönder på sätt som inte syns direkt, skippa det som inte ger något utöver tidsspill. Den här guiden går igenom hur det upplägget ser ut konkret i en Next.js App Router-applikation, med Jest, React Testing Library, MSW och Playwright.
Teststrategier
Det finns tre nivåer att hålla isär. De skiljer sig åt i vad de fångar, hur snabba de är och vad de kostar att underhålla.
Unit tests testar en enskild funktion eller komponent i fullständig isolation. Alla externa beroenden är mockade. De är snabba, billiga att skriva och utmärkta för ren logik: valideringsfunktioner, beräkningar, enstaka komponentbeteenden. Nackdelen är att de missar integrationsbuggar helt. Du kan ha tio gröna unit tests och ändå ha en funktion som inte fungerar när delarna kopplas ihop.
Integrationstester testar en komponent med dess verkliga beroenden, inte mockade versioner. En formulärkomponent testas med den faktiska valideringslogiken inkopplad, inte en stubb. De är långsammare än unit tests men fångar de kopplingsfel som unit tests aldrig ser. Det är den nivå som ger mest valuta för pengarna i React-applikationer.
E2E-tester kör ett fullständigt användarflöde i en riktig webbläsare (headless). De fångar det som ingenting annat fångar: att frontend pratar rätt med backend, att navigation fungerar, att hela flödet från klick till bekräftelse fungerar som det ska. Priset är högt. De är långsamma, de är känsliga för UI-ändringar och de kräver mer underhåll per test än de andra nivåerna.
En praktisk fördelning ser ut så här: många unit- och integrationstester för ren logik och interaktiva komponenter, ett litet antal E2E-tester för de kritiska flödena (inloggning, checkout, viktiga formulär). Skriv inte E2E-tester för varje enskild feature. De kommer att gå sönder konstant när UI:t förändras, och du kommer att sluta lita på dem.
Jest och React Testing Library
React Testing Library (RTL) är standardverktyget för att testa React-komponenter. Det är byggt kring idén att du ska testa det användaren ser och gör, inte implementationsdetaljer som komponentens interna state.
Installera beroendena för Next.js App Router:
npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdom
Skapa konfigurationsfilen för Jest. Next.js levererar en inbyggd transformer som hanterar JSX, path aliases och miljövariabler:
// jest.config.js
const nextJest = require('next/jest')
const createJestConfig = nextJest({ dir: './' })
module.exports = createJestConfig({
testEnvironment: 'jsdom',
setupFilesAfterFramework: ['<rootDir>/jest.setup.js'],
})
// jest.setup.js
import '@testing-library/jest-dom'
@testing-library/jest-dom lägger till matchare som toBeInTheDocument, toHaveValue och toBeDisabled direkt på Jests expect. Utan det faller du tillbaka på generiska matchare som ger sämre felmeddelanden.
Här är ett konkret test för ett kontaktformulär med validering:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import KontaktFormular from './KontaktFormular'
test('visar felmeddelande om e-post saknas', async () => {
render(<KontaktFormular />)
await userEvent.click(screen.getByRole('button', { name: 'Skicka' }))
expect(screen.getByText('E-postadress krävs.')).toBeInTheDocument()
})
test('skickar formuläret med giltig data', async () => {
render(<KontaktFormular />)
await userEvent.type(screen.getByLabelText('E-post'), 'test@exempel.se')
await userEvent.click(screen.getByRole('button', { name: 'Skicka' }))
expect(screen.getByText('Tack! Vi hör av oss.')).toBeInTheDocument()
})
Notera userEvent istället för fireEvent. userEvent simulerar verkliga interaktioner (fokus, tangentnedslagningar, musrörelser) och fångar buggar som fireEvent missar. Använd alltid userEvent för formulärtestning.
screen.getByRole och screen.getByLabelText är de frågor du bör föredra. De letar efter element på samma sätt som en skärmläsare eller en riktig användare gör det. Undvik getByTestId om det inte är absolut nödvändigt. Det testar implementationsdetaljer, inte beteende.
Testa Server Components
Det finns en viktig begränsning att känna till: RTL körs i jsdom, en simulerad webbläsarmiljö, och kan inte exekvera asynkrona React Server Components direkt. Server Components renderas på servern och använder Node.js-specifika API:er, filsystem och direkta databasanrop som inte finns i jsdom.
Vad du kan testa med RTL: Client Components ("use client"), komponenter som tar emot data som props och presenterar den, all interaktionslogik.
Vad du inte kan testa direkt: Server Components som anropar databaser, gör fetch-anrop eller använder cookies() och headers().
Lösningen är att separera datahämtning från presentation. Lägg datahämtningslogiken i egna async-funktioner och testa dem direkt med Jest, utan RTL:
// lib/hamtaInlagg.js
export async function hamtaInlagg(slug) {
const res = await fetch(`${process.env.API_URL}/inlagg/${slug}`)
if (!res.ok) throw new Error('Hittades inte')
return res.json()
}
// __tests__/hamtaInlagg.test.js
import { hamtaInlagg } from '@/lib/hamtaInlagg'
global.fetch = jest.fn()
test('returnerar inlägg vid lyckad hämtning', async () => {
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: '1', titel: 'Testar' }),
})
const inlagg = await hamtaInlagg('testar')
expect(inlagg.titel).toBe('Testar')
})
test('kastar fel om hämtning misslyckas', async () => {
fetch.mockResolvedValueOnce({ ok: false })
await expect(hamtaInlagg('saknas')).rejects.toThrow('Hittades inte')
})
Det här mönstret ger dig full testtäckning av datahämtningslogiken utan att behöva köra en server. Server Components som sedan importerar dessa funktioner kan du testa via E2E om du behöver bekräfta att hela kedjan hänger ihop.
MSW för API-mockning
Att mocka fetch direkt med jest.fn() fungerar, men det har begränsningar. Du kopplar mocken till ett specifikt anrop i ett specifikt test. Har du tio komponenter som pratar med samma endpoints behöver du upprepa mocken tio gånger, eller skapa ett delat modul som snabbt blir otymplig.
Mock Service Worker (MSW) löser det på ett annat sätt. Istället för att mocka fetch som funktion interceptar MSW fetch-anrop på nätverksnivå och svarar med definierade handlers. Det fungerar likadant i tests och i webbläsaren, vilket gör det enkelt att återanvända samma handlers.
npm install --save-dev msw
Definiera handlers en gång för de endpoints du testar:
// __tests__/handlers.js
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/inlagg', () => {
return HttpResponse.json([
{ id: '1', titel: 'Testar MSW' },
])
}),
]
Sätt upp servern i en setup-fil som Jest läser in:
// __tests__/setup.js
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
server.resetHandlers() efter varje test är viktigt. Utan det kan en handler som du lägger till i ett test läcka in i nästa test och ge svårspårade fel.
MSW är värt installationskostnaden när du har flera komponenter som pratar med samma endpoints, eller när du vill att mocken ska bete sig som en riktig API: korrekta statuskoder, headers, felsvar. För enkla one-off-tester räcker direktmockning av fetch.
Playwright för E2E
Playwright är Microsofts E2E-testverktyg och det starkaste alternativet för Next.js just nu. Det har inbyggt stöd för att starta dev-servern innan testerna körs, vilket gör CI-integrationen betydligt enklare.
npm install --save-dev @playwright/test
npx playwright install
Skapa konfigurationsfilen i projektroten:
// playwright.config.js
const { defineConfig } = require('@playwright/test')
module.exports = defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://localhost:3000',
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
reuseExistingServer: !process.env.CI innebär att Playwright återanvänder en körande dev-server lokalt (snabbare feedback) men alltid startar en ny i CI (reproducerbart resultat).
Här är ett test för ett inloggningsflöde:
// e2e/inloggning.spec.js
import { test, expect } from '@playwright/test'
test('lyckad inloggning navigerar till dashboard', async ({ page }) => {
await page.goto('/logga-in')
await page.fill('[name="email"]', 'test@exempel.se')
await page.fill('[name="password"]', 'losenord123')
await page.click('button[type="submit"]')
await expect(page).toHaveURL('/dashboard')
await expect(page.getByRole('heading', { name: 'Välkommen' })).toBeVisible()
})
Håll E2E-tester fokuserade på hela flöden, inte på enskilda element. Det här testet bekräftar att inloggningsflödet fungerar från start till slut: rätt URL efter redirect och rätt innehåll på dashboarden. Det testar inte att e-postfältet har rätt placeholder-text. Det är RTL:s jobb.
För att köra testerna mot en produktionsliknande miljö i CI: bygg applikationen och kör mot npm start istället för npm run dev. Det fångar buildfel som inte syns i dev-läge.
Vad du bör testa
Det är lätt att hamna i diskussionen om coverage-procent. Den är inte meningslös, men den missar poängen. En codebase med 90 % coverage kan fortfarande ha en trasig checkout-funktion om de 90 procenten täcker statisk presentation och de 10 procenten är betalflödet.
Testa det här:
Interaktiva Client Components: formulär med validering, komponenter med tillstånd som förändras vid användarinteraktion, komponenter som visar olika utfall beroende på props. Det är här buggar introduceras och det är här RTL är som effektivast.
Rena utility-funktioner: valideringslogik, datumformatering, prisberäkningar, allt som är ren input-output utan sidoeffekter. Unit tests är snabba att skriva och ger snabb feedback.
Datahämtningsfunktioner: async-funktioner som pratar med API:er eller databaser, testade med mockad fetch eller MSW. Testa lyckade svar, felsvar och edge cases som tom data.
Autentiseringsflöden via E2E: inloggning, utloggning, skyddade rutter som redirectar obehöriga användare. Det är för känsligt för att bara unit-testa.
Kritiska konverteringsflöden via E2E: checkout, registrering, det primära syfte applikationen har. En till två Playwright-specs för de flöden som aldrig får gå sönder.
Skippa det här:
Statisk layout och ren presentation: en komponent som renderar en rubrik och ett stycke text har ingen logik att testa. Testet går sönder vid varje UI-ändring och ger ingenting i gengäld.
TypeScript-typer: kompilatorn testar det. Skriv inte runtime-tester för typkontroll.
Tredjepartsbibliotek: testa inte att React, Next.js eller dina npm-beroenden fungerar. Testa din kod som använder dem.
Saker som täcks på en lägre nivå: om du har grundliga RTL-tester för formulärvalideringen behöver du inte ett E2E-test som testar exakt samma validering via webbläsaren. Det ger dig ingenting utöver längre testkörningstid.
Rekommendation
För de flesta Next.js-applikationer räcker det här upplägget:
RTL för alla interaktiva Client Components och utility-logik. Det ger snabb feedback under utveckling och fångar regressioner direkt.
Direktmockad fetch eller MSW för datahämtningsfunktioner. MSW om du har många komponenter mot samma API:er, annars direktmockning.
Playwright för de två till tre flöden som aldrig får gå sönder. Typiskt: inloggning, det primära konverteringsflödet, eventuellt ett flöde kring kontoskapande. Det räcker med en spec-fil per kritiskt flöde.
Server Component-logik testas som vanliga async-funktioner, separerade från renderingen.
Det är ett upplägg som ger meningsfull täckning utan att testa-infrastrukturen tar över projektet. Du kan köra hela Jest-suiten på sekunder och Playwright på några minuter i CI. Det är snabbt nog för att faktiskt köras vid varje push, och det täcker det som faktiskt spelar roll.
