Tests Next.js : Tester les comportements, pas l'UI
Publié le
Développeur React & Next.js freelance
Vous voulez tester votre projet Next.js mais vous ne savez pas par où commencer ? Ou pire : vous avez déjà des tests, mais ils cassent à chaque refactoring et ne détectent jamais les vrais bugs ?
La plupart des développeurs évitent les tests. Ils pensent que c'est une perte de temps, un travail "supplémentaire" qui n'aide pas au développement. Cette vision vient de mauvaises expériences : des tests couplés à l'UI, des mocks partout, des appels réseau qui échouent aléatoirement.
Après 7+ ans de développement et 43 missions sur des SaaS, j'ai développé une stratégie de tests Next.js qui fonctionne : tester les comportements métier, pas l'interface utilisateur.
Dans cet article, vous allez découvrir :
- Pourquoi les tests classiques échouent à apporter de la valeur
- Ma méthode : fakes, un expect par test, logique extraite
- Une stratégie de tests adaptée aux projets Next.js
- Les erreurs à éviter pour des tests maintenables
Le problème des tests classiques
Tester l'implémentation, pas le comportement
Voici un test que je vois régulièrement sur les projets SaaS. L'équipe veut tester la fonctionnalité d'upgrade de plan :
import { render, screen, fireEvent } from '@testing-library/react'
import { SubscriptionCard } from './subscription-card'
test('should show upgrade modal when clicking upgrade button', () => {
render(<SubscriptionCard plan="starter" />)
const button = screen.getByRole('button', { name: 'Passer à Pro' })
fireEvent.click(button)
expect(screen.getByText('Confirmer le changement')).toBeInTheDocument()
expect(screen.getByText('49€/mois')).toBeInTheDocument()
})Ce test semble correct. Il vérifie qu'un clic ouvre la modal d'upgrade. Mais que teste-t-il vraiment ?
- Que React met à jour le DOM après un
setState(déjà testé par React) - Que Testing Library trouve un bouton (déjà testé par la lib)
- Que le texte "49€/mois" s'affiche (détail d'implémentation)
Ce test cassera si vous :
- Changez le wording ("Passer à Pro" → "Upgrade")
- Modifiez le prix affiché ("49€/mois" → "49€ HT/mois")
- Refactorisez la modal en composant séparé
Il ne cassera pas si le calcul du prorata est buggé, si l'upgrade échoue côté API, ou si l'utilisateur est facturé deux fois.
La pyramide des tests mal comprise
La plupart des équipes SaaS appliquent la pyramide des tests à l'envers :
| Ce qu'ils font | Ce que je fais |
|---|---|
| 70% tests de composants React | 80% tests unitaires sur la logique |
| 20% tests d'API | 15% tests d'intégration (API, DB) |
| 10% tests E2E | 5% tests E2E (parcours critiques) |
Résultat : une suite de tests lente, fragile, et qui n'attrape pas les bugs métier.
Ma philosophie : tester les comportements
Principe 1 : Un test = un comportement métier complet
Ne testez pas "une fonction". Testez un comportement de bout en bout.
Exemple avec Redux/Zustand : ne testez pas le reducer, puis le selector, puis l'action séparément. Testez le flux complet en vérifiant l'état final :
import { createCartStore } from './cart'
test('adding a product updates the cart state', () => {
const store = createCartStore()
const product = { id: 'tshirt-1', name: 'T-shirt', price: 29.99 }
store.getState().addProduct(product)
expect(store.getState()).toEqual({
items: [{ id: 'tshirt-1', name: 'T-shirt', price: 29.99, quantity: 1 }],
total: 29.99,
itemCount: 1,
})
})
test('adding the same product twice increases quantity', () => {
const store = createCartStore()
const product = { id: 'tshirt-1', name: 'T-shirt', price: 29.99 }
store.getState().addProduct(product)
store.getState().addProduct(product)
expect(store.getState()).toEqual({
items: [{ id: 'tshirt-1', name: 'T-shirt', price: 29.99, quantity: 2 }],
total: 59.98,
itemCount: 2,
})
})Chaque test vérifie l'état complet du store. Si un champ change de manière inattendue, le test échoue.
Principe 2 : Un seul expect par test
Quand un test échoue, vous devez savoir immédiatement ce qui ne va pas. Avec plusieurs expect, impossible de le savoir sans lire le code :
// ❌ Mauvais : 3 expects, quel comportement est testé ?
test('user registration', () => {
const result = registerUser({ email: 'test@example.com', password: 'Str0ng!' })
expect(result.success).toBe(true)
expect(result.user.email).toBe('test@example.com')
expect(result.user.id).toBeDefined()
})
// ✅ Bon : 3 tests, 3 comportements distincts
test('valid registration succeeds', () => {
const result = registerUser({ email: 'test@example.com', password: 'Str0ng!' })
expect(result.success).toBe(true)
})
test('registered user has correct email', () => {
const result = registerUser({ email: 'test@example.com', password: 'Str0ng!' })
expect(result.user?.email).toBe('test@example.com')
})
test('registered user receives an id', () => {
const result = registerUser({ email: 'test@example.com', password: 'Str0ng!' })
expect(result.user?.id).toBeDefined()
})Le nom du test devient la documentation. Quand il échoue, vous savez exactement quel comportement est cassé.
Principe 3 : Tester la logique, pas le rendu
La clé : extraire la logique des composants React.
// ❌ Logique mélangée au composant
const PricingCalculator = ({ plan, users }: Props) => {
const basePrice = plan === 'pro' ? 49 : 29
const discount = users > 10 ? 0.2 : users > 5 ? 0.1 : 0
const total = basePrice * users * (1 - discount)
return <div>{total}€/mois</div>
}// ✅ Logique extraite, testable sans React
type Plan = 'starter' | 'pro'
export const calculatePricing = (plan: Plan, users: number) => {
const basePrice = plan === 'pro' ? 49 : 29
const discount = users > 10 ? 0.2 : users > 5 ? 0.1 : 0
return {
basePrice,
discount,
total: basePrice * users * (1 - discount),
}
}import { calculatePricing } from './pricing'
test('pro plan with 15 users applies 20% discount', () => {
const result = calculatePricing('pro', 15)
expect(result).toEqual({
basePrice: 49,
discount: 0.2,
total: 49 * 15 * 0.8,
})
})
test('starter plan with 3 users has no discount', () => {
const result = calculatePricing('starter', 3)
expect(result).toEqual({
basePrice: 29,
discount: 0,
total: 29 * 3,
})
})Ces tests sont :
- Rapides : pas de DOM, pas de React
- Stables : ne cassent pas si l'UI change
- Clairs : testent la règle métier, pas l'affichage
Fakes vs Mocks : pourquoi j'ai arrêté jest.mock()
Le problème des mocks Jest
Les mocks Jest créent un couplage fort à l'implémentation :
jest.mock('@/services/user-repository')
import { UserRepository } from '@/services/user-repository'
import { registerUser } from '@/domain/register-user'
const mockCreate = jest.fn()
;(UserRepository as jest.Mock).mockImplementation(() => ({
create: mockCreate,
findByEmail: jest.fn().mockResolvedValue(null),
}))
test('registration creates user', async () => {
mockCreate.mockResolvedValue({ id: '1', email: 'test@test.com' })
await registerUser({ email: 'test@test.com', password: 'Pass123!' })
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({
email: 'test@test.com',
}))
})Problèmes :
- Le mock doit connaître l'implémentation interne
- Si
UserRepositorychange de signature, le test passe mais le code est cassé - Maintenance cauchemardesque sur un projet SaaS à 50+ fichiers
Les Fakes : une meilleure alternative
Un fake est une implémentation simplifiée mais fonctionnelle :
import type { UserRepository, User } from '@/domain/ports/user-repository'
export const createFakeUserRepository = (): UserRepository => {
const users = new Map<string, User>()
return {
async create(data) {
const user: User = {
id: crypto.randomUUID(),
email: data.email,
passwordHash: data.passwordHash,
createdAt: new Date(),
}
users.set(user.id, user)
return user
},
async findByEmail(email) {
return [...users.values()].find(u => u.email === email) ?? null
},
async findById(id) {
return users.get(id) ?? null
},
}
}import { createFakeUserRepository } from '@/infrastructure/user-repository.fake'
import { createRegisterUser } from './register-user'
test('registration with existing email fails', async () => {
const userRepository = createFakeUserRepository()
const registerUser = createRegisterUser({ userRepository })
await registerUser({ email: 'existing@test.com', password: 'Pass123!' })
const result = await registerUser({ email: 'existing@test.com', password: 'Other456!' })
expect(result).toEqual({ success: false, error: 'EMAIL_EXISTS' })
})Avantages des fakes :
- Comportement réaliste : le fake stocke vraiment les données
- Réutilisable : un seul fake pour tous les tests
- Maintenable : si le contrat change, TypeScript vous alerte
- Développement sans backend : utilisez les fakes pour développer avant que l'API soit prête
- Développement sans réseau : travaillez en mode avion, dans le train, sans VPN
Mise en pratique avec Next.js 16 App Router
Pour les Server Actions, utilisez l'injection de dépendances :
export type User = {
id: string
email: string
passwordHash: string
createdAt: Date
}
export type CreateUserData = {
email: string
passwordHash: string
}
export type UserRepository = {
create: (data: CreateUserData) => Promise<User>
findByEmail: (email: string) => Promise<User | null>
findById: (id: string) => Promise<User | null>
}import type { UserRepository } from './ports/user-repository'
import { hashPassword } from '@/lib/crypto'
type Dependencies = {
userRepository: UserRepository
}
type RegisterInput = {
email: string
password: string
}
type RegisterResult =
| { success: true; userId: string }
| { success: false; error: 'EMAIL_EXISTS' | 'INVALID_PASSWORD' }
export const createRegisterUser = ({ userRepository }: Dependencies) => {
return async (input: RegisterInput): Promise<RegisterResult> => {
if (input.password.length < 8) {
return { success: false, error: 'INVALID_PASSWORD' }
}
const existing = await userRepository.findByEmail(input.email)
if (existing) {
return { success: false, error: 'EMAIL_EXISTS' }
}
const passwordHash = await hashPassword(input.password)
const user = await userRepository.create({
email: input.email,
passwordHash,
})
return { success: true, userId: user.id }
}
}'use server'
import { createRegisterUser } from '@/domain/register-user'
import { prismaUserRepository } from '@/infrastructure/prisma-user-repository'
const registerUser = createRegisterUser({
userRepository: prismaUserRepository
})
export const registerAction = async (formData: FormData) => {
const email = formData.get('email') as string
const password = formData.get('password') as string
return registerUser({ email, password })
}En production, vous injectez prismaUserRepository. En test, vous injectez createFakeUserRepository().
Stratégie de tests pour un projet Next.js
Niveau 1 : Tests unitaires (80% de l'effort)
Concentrez-vous sur la logique métier pure :
| Quoi tester | Exemple |
|---|---|
| Calculs métier | Pricing, remises, taxes |
| Validation | Email, mot de passe, formulaires |
| Transformations | Mapping API → Domain |
| Règles métier | Éligibilité, permissions, workflows |
import { canUpgrade, calculateProration } from './subscription'
test('user on starter can upgrade to pro', () => {
const subscription = { plan: 'starter', status: 'active' }
expect(canUpgrade(subscription, 'pro')).toBe(true)
})
test('prorated amount is calculated from remaining days', () => {
const result = calculateProration({
currentPlan: 'starter',
newPlan: 'pro',
daysRemaining: 15,
billingCycle: 30,
})
expect(result).toEqual({
currentPlanDailyRate: 29 / 30,
newPlanDailyRate: 49 / 30,
daysRemaining: 15,
amount: (49 - 29) * (15 / 30),
})
})Niveau 2 : Tests d'intégration (15%)
Testez les interactions avec les systèmes externes :
import { prismaUserRepository } from './prisma-user-repository'
import { prisma } from '@/lib/prisma'
beforeEach(async () => {
await prisma.user.deleteMany()
})
test('create and retrieve user', async () => {
const created = await prismaUserRepository.create({
email: 'test@example.com',
passwordHash: 'hash123',
})
const found = await prismaUserRepository.findById(created.id)
expect(found?.email).toBe('test@example.com')
})Niveau 3 : Tests E2E (5%)
Réservez les tests E2E aux parcours critiques :
- Inscription → Connexion → Achat
- Parcours d'onboarding
- Flux de paiement
import { test, expect } from '@playwright/test'
test('complete checkout flow', async ({ page }) => {
await page.goto('/pricing')
await page.getByTestId('plan-pro-cta').click()
await page.getByTestId('checkout-email').fill('test@example.com')
await page.getByTestId('checkout-card').fill('4242424242424242')
await page.getByTestId('checkout-submit').click()
await expect(page.getByTestId('payment-success')).toBeVisible()
})Utilisez data-testid plutôt que des sélecteurs de texte (getByText, getByRole({ name })). Si vous changez le wording "Payer" en "Confirmer le paiement", le test ne doit pas casser.
Les erreurs à éviter
Tester les composants React directement
Sauf pour un design system, tester des composants React est une perte de temps :
- Lent : rendu DOM, simulation d'events
- Fragile : casse à chaque modification CSS/structure
- Peu de valeur : ne teste pas la logique métier
Exception : les composants avec logique complexe (formulaires avec validation côté UI, comportements d'accessibilité).
Mocker trop de choses
Si vous devez mocker plus de 2 dépendances dans un test, c'est un signal : votre code est trop couplé.
// ❌ Trop de mocks = architecture à revoir
jest.mock('@/services/user')
jest.mock('@/services/email')
jest.mock('@/services/analytics')
jest.mock('@/services/logger')
jest.mock('@/lib/cache')
// ✅ Injectez les dépendances, utilisez des fakes
const useCase = createUseCase({
userRepository: fakeUserRepo,
emailService: fakeEmailService,
})Viser 100% de couverture
La couverture de code n'est pas un indicateur de qualité. 100% de couverture avec des tests inutiles = fausse sécurité.
Visez plutôt :
- 100% des chemins critiques (paiement, auth, données sensibles)
- 80% de la logique métier
- 0% des getters/setters triviaux
FAQ : Questions fréquentes sur les tests Next.js
Faut-il utiliser Jest ou Vitest pour tester un projet Next.js ?
Jest reste le choix recommandé pour Next.js grâce à l'intégration native via next/jest. Vitest est plus rapide mais nécessite une configuration manuelle pour le App Router. Pour un nouveau projet, les deux fonctionnent, mais Jest a l'avantage de la documentation officielle.
Comment tester les Server Components et Server Actions ?
Les Server Components asynchrones ne sont pas encore supportés par Jest. La solution : extraire la logique métier dans des fonctions pures testables, et réserver les tests E2E (Playwright) pour valider le rendu final. Pour les Server Actions, utilisez l'injection de dépendances avec des fakes.
Combien de temps faut-il pour mettre en place cette stratégie de tests ?
En moins d'une journée, vous pouvez avoir vos premiers tests en place. L'approche : commencez par un use case critique (paiement, inscription), créez un fake pour la dépendance principale, et écrivez 3-5 tests. Puis itérez. L'investissement est rentabilisé dès le premier bug évité en production.
Dois-je tester tous les composants React ?
Non. Tester les composants React directement est rarement utile (sauf design system). Concentrez-vous sur la logique métier extraite : calculs, validations, transformations de données. C'est là que se trouvent les vrais bugs.
Conclusion
Résumé de la philosophie
- Testez les comportements : un test = un comportement métier complet
- Un expect par test : le nom du test est la documentation
- Extrayez la logique : testez des fonctions pures, pas des composants
- Utilisez des fakes : implémentations simplifiées plutôt que mocks Jest
Checklist avant de committer
- Chaque test vérifie un seul comportement
- Le nom du test décrit le comportement attendu
- La logique métier est extraite des composants
- Les dépendances externes utilisent l'injection (fakes en test)
- Pas de
jest.mock()sur plus de 2 modules
Pour aller plus loin
Cette approche s'inscrit dans une architecture plus large. Si vous voulez comprendre comment structurer un projet Next.js pour faciliter les tests :
- Architecture hexagonale en front-end : la structure qui rend cette approche possible
- Pourquoi TypeScript : les types au service des tests
- Discriminated unions : pour des états explicites et testables
- Comment recruter un développeur front-end freelance : si vous cherchez un renfort pour votre équipe

À propos de l'auteur
Je suis Dimitri Dumont, développeur freelance spécialisé React & Next.js depuis plus de 7 ans. J'ai accompagné 22 startups et réalisé 43 missions avec une note de 5/5. J'applique ces patterns au quotidien pour continuer de livrer rapidement du code évolutif. En savoir plus →
Cet article vous a été utile ?
Je peux vous accompagner sur votre projet React & Next.js.
Discutons de votre projet →