expria-frontend/docs/TESTS_AUTOMATISES.md

18 KiB

TESTS_AUTOMATISES.md — Expria Frontend

Document de référence — Version 1.0 Ce document contient les tests Vitest automatisés à implémenter dans le frontend. Ces tests s'exécutent en quelques secondes et détectent les régressions invisibles que le Golden Dataset ne peut pas attraper par des tests manuels.

Calqué sur TESTS_AUTOMATISES.md du backend pour garantir la parité et appliquer le même standard de rigueur.


1. Principe

Tests ciblés, pas exhaustifs. On teste ce qui vérifie un droit, modifie des données, calcule un résultat, ou orchestre un flux critique. On ne teste pas les composants d'affichage (trop fragiles, couverts par le Golden Dataset manuel).

Fonctions critiques à tester

Fonction Rôle Conséquence si cassée
checkFeatureAccess / hasAccess Vérifie l'accès à une feature selon le plan Feature Premium accessible gratuitement
canUserSimulate / canSimulate Vérifie quota simulations Free Blocage quota cassé = coût explosif DeepSeek
getPlanPermissions Retourne les permissions d'un plan Mauvaises features affichées
apiFetch Wrapper HTTP avec retry, timeout, parsing erreur Tous les appels API peuvent échouer silencieusement
parseApiError Parse les erreurs backend vers ApiError typée Mauvaise gestion des codes erreur côté UI
applyReportFloutage Filtre le rapport selon le plan (Free = flouté) Rapport Premium accessible en Free (fuite de valeur)
t2LiveReducer State machine T2 Live Transitions d'états cassées = bugs audio/WebSocket
usePlan (hook) Cache du plan utilisateur + refetch après upgrade Plan stale après paiement = UX brisée

2. Installation

Dépendances à ajouter

npm install --save-dev vitest @vitest/coverage-v8 @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

Configuration vitest.config.ts à la racine

import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'node:path'

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test-setup.ts'],
    coverage: {
      reporter: ['text', 'html'],
      include: [
        'src/entities/**',
        'src/shared/lib/**',
        'src/features/*/hooks/**',
        'src/features/*/state/**',
      ],
      exclude: ['**/__tests__/**', '**/*.test.ts', '**/*.test.tsx'],
    },
  },
})

Fichier src/test-setup.ts

import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'

afterEach(() => {
  cleanup()
})

Scripts dans package.json

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage"
  }
}

3. Tests déjà produits (session documentation — avant scaffold)

src/entities/user/__tests__/access.test.ts — 26 assertions

Teste la parité avec le backend sur les 3 fonctions de access.ts :

  • canUserSimulate : 7 tests (free dans limites, free au quota, standard, premium, plan inconnu)
  • getPlanPermissions : 4 tests (les 3 plans + plan inconnu lève une erreur)
  • checkFeatureAccess : 14 tests (1 basic_report + 4 blocages free + 5 standard + 4 premium)
  • PLANS structure : 1 test (parité des clés entre plans)

src/entities/user/__tests__/lib.test.ts — 11 assertions

Teste les alias hasAccess et canSimulate :

  • hasAccess : 3 tests (comportement identique à checkFeatureAccess pour chaque plan)
  • canSimulate : 7 tests (parité + signature ergonomique)
  • getPlanPermissions réexporté : 1 test

Total déjà produit : 37 tests.


4. Tests à implémenter par sprint

Sprint 1 — Auth + API layer

src/shared/lib/__tests__/api-client.test.ts

Tester apiFetch<T> dans toutes ses variantes.

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { apiFetch } from '../api-client'

// Mock de fetch global
global.fetch = vi.fn()

describe('apiFetch', () => {
  beforeEach(() => {
    vi.resetAllMocks()
  })

  describe('succès', () => {
    it('retourne le payload JSON pour une réponse 2xx', async () => {
      const mockResponse = { plan: 'premium', simulations_used: 0 }
      vi.mocked(fetch).mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => mockResponse,
      } as Response)

      const result = await apiFetch<typeof mockResponse>('/plans/status')
      expect(result).toEqual(mockResponse)
    })

    it('ajoute automatiquement le header Authorization si un token est fourni', async () => {
      vi.mocked(fetch).mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({}),
      } as Response)

      await apiFetch('/plans/status', {}, 'jwt-token-xyz')

      expect(fetch).toHaveBeenCalledWith(
        expect.any(String),
        expect.objectContaining({
          headers: expect.objectContaining({
            'Authorization': 'Bearer jwt-token-xyz',
          }),
        })
      )
    })

    it('ajoute X-API-Version à toutes les requêtes', async () => {
      vi.mocked(fetch).mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({}),
      } as Response)

      await apiFetch('/plans/status')

      expect(fetch).toHaveBeenCalledWith(
        expect.any(String),
        expect.objectContaining({
          headers: expect.objectContaining({
            'X-API-Version': '1.0',
          }),
        })
      )
    })
  })

  describe('erreurs backend', () => {
    it('throw une ApiError sur 401 AUTH_REQUIRED', async () => {
      vi.mocked(fetch).mockResolvedValueOnce({
        ok: false,
        status: 401,
        json: async () => ({
          error: true,
          code: 'AUTH_REQUIRED',
          message: 'Authentification requise.',
        }),
      } as Response)

      await expect(apiFetch('/plans/status')).rejects.toMatchObject({
        code: 'AUTH_REQUIRED',
      })
    })

    it('throw une ApiError sur 403 QUOTA_REACHED', async () => {
      vi.mocked(fetch).mockResolvedValueOnce({
        ok: false,
        status: 403,
        json: async () => ({
          error: true,
          code: 'QUOTA_REACHED',
          message: 'Quota atteint.',
          status: 403,  // quirk backend (voir ARCHITECTURE.md §5)
        }),
      } as Response)

      await expect(apiFetch('/simulations', { method: 'POST' })).rejects.toMatchObject({
        code: 'QUOTA_REACHED',
      })
    })

    it('throw NETWORK_ERROR si fetch lui-même échoue', async () => {
      vi.mocked(fetch).mockRejectedValueOnce(new TypeError('Failed to fetch'))

      await expect(apiFetch('/plans/status')).rejects.toMatchObject({
        code: 'NETWORK_ERROR',
      })
    })
  })

  describe('retry et timeout', () => {
    it('retry 3 fois sur 500 INTERNAL_ERROR', async () => {
      vi.mocked(fetch)
        .mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({ error: true, code: 'INTERNAL_ERROR', message: '...' }) } as Response)
        .mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({ error: true, code: 'INTERNAL_ERROR', message: '...' }) } as Response)
        .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ plan: 'free' }) } as Response)

      const result = await apiFetch('/plans/status')
      expect(result).toEqual({ plan: 'free' })
      expect(fetch).toHaveBeenCalledTimes(3)
    })

    it('ne retry PAS sur 400 VALIDATION_ERROR', async () => {
      vi.mocked(fetch).mockResolvedValueOnce({
        ok: false,
        status: 400,
        json: async () => ({ error: true, code: 'VALIDATION_ERROR', message: '...' }),
      } as Response)

      await expect(apiFetch('/simulations', { method: 'POST' })).rejects.toMatchObject({
        code: 'VALIDATION_ERROR',
      })
      expect(fetch).toHaveBeenCalledTimes(1)
    })

    it('applique le timeout configuré', async () => {
      vi.useFakeTimers()
      vi.mocked(fetch).mockImplementationOnce(
        () => new Promise((resolve) => setTimeout(resolve, 10_000))
      )

      const promise = apiFetch('/plans/status', { timeoutMs: 1000 })
      vi.advanceTimersByTime(1500)

      await expect(promise).rejects.toMatchObject({ code: 'TIMEOUT' })
      vi.useRealTimers()
    })
  })
})

Nombre minimum de tests : 12.

src/shared/lib/__tests__/auth-client.test.ts

Teste la gestion du token Supabase (avec mock de supabase.auth).

import { describe, it, expect, vi } from 'vitest'

vi.mock('../supabase', () => ({
  supabase: {
    auth: {
      getSession: vi.fn(),
      signInWithPassword: vi.fn(),
      signOut: vi.fn(),
    },
  },
}))

import { getAccessToken, signIn, signOut } from '../auth-client'
import { supabase } from '../supabase'

describe('getAccessToken', () => {
  it('retourne le token si session valide', async () => {
    vi.mocked(supabase.auth.getSession).mockResolvedValueOnce({
      data: { session: { access_token: 'token-xyz' } },
      error: null,
    } as any)

    const token = await getAccessToken()
    expect(token).toBe('token-xyz')
  })

  it('retourne null si pas de session', async () => {
    vi.mocked(supabase.auth.getSession).mockResolvedValueOnce({
      data: { session: null },
      error: null,
    } as any)

    const token = await getAccessToken()
    expect(token).toBeNull()
  })
})

// + tests signIn, signOut

Nombre minimum de tests : 6.


Sprint 2 — Dashboard conditionnel

src/features/dashboard/hooks/__tests__/usePlan.test.tsx

Teste le hook usePlan avec TanStack Query (cache, refetch, invalidation).

import { describe, it, expect, vi } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { usePlan } from '../usePlan'

vi.mock('@/entities/user/api', () => ({
  getPlanStatus: vi.fn(),
}))

import { getPlanStatus } from '@/entities/user/api'

describe('usePlan', () => {
  it('retourne les données de /plans/status en cas de succès', async () => {
    vi.mocked(getPlanStatus).mockResolvedValueOnce({
      plan: 'standard',
      permissions: { /* ... */ },
      simulations_used: 5,
      simulations_remaining: null,
      plan_expires_at: null,
    })

    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
    const wrapper = ({ children }: any) => (
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    )

    const { result } = renderHook(() => usePlan(), { wrapper })
    await waitFor(() => expect(result.current.isSuccess).toBe(true))
    expect(result.current.data?.plan).toBe('standard')
  })

  // + tests : cache reuse, error handling, invalidation after upgrade
})

Nombre minimum de tests : 3.


Sprint 3 — Simulations EE + affichage rapport

src/entities/report/__tests__/floutage.test.ts

Teste la logique de floutage du rapport selon le plan.

import { describe, it, expect } from 'vitest'
import { applyReportFloutage } from '../lib'

const fullReport = {
  score: 15,
  nclc: 8,
  feedback_court: 'Bon travail',
  criteres: [
    { nom: 'Cohérence', score: 4, explication: '...' },
    { nom: 'Lexique', score: 3, explication: '...' },
  ],
  erreurs: [{ type: 'grammaire', explication: '...' }],
  production_modele: 'Voici un modèle complet...',
  suggestions_idees: ['Idée 1', 'Idée 2', 'Idée 3'],
  exercices: [{ titre: 'Exo 1', contenu: '...' }],
}

describe('applyReportFloutage', () => {
  describe('Plan FREE', () => {
    it('garde score et NCLC visibles', () => {
      const floute = applyReportFloutage(fullReport, 'free')
      expect(floute.score).toBe(15)
      expect(floute.nclc).toBe(8)
    })

    it('garde feedback_court visible', () => {
      const floute = applyReportFloutage(fullReport, 'free')
      expect(floute.feedback_court).toBe('Bon travail')
    })

    it('floute les critères détaillés', () => {
      const floute = applyReportFloutage(fullReport, 'free')
      expect(floute.criteres).toBeNull()
    })

    it('floute les erreurs détaillées', () => {
      const floute = applyReportFloutage(fullReport, 'free')
      expect(floute.erreurs).toBeNull()
    })

    it('tronque la production modèle à 1 phrase', () => {
      const floute = applyReportFloutage(fullReport, 'free')
      expect(floute.production_modele?.split('.').length).toBeLessThanOrEqual(2)
    })

    it('tronque les suggestions à 1 idée', () => {
      const floute = applyReportFloutage(fullReport, 'free')
      expect(floute.suggestions_idees?.length).toBeLessThanOrEqual(1)
    })

    it('masque les exercices', () => {
      const floute = applyReportFloutage(fullReport, 'free')
      expect(floute.exercices).toBeNull()
    })
  })

  describe('Plan STANDARD et PREMIUM', () => {
    it('retourne le rapport complet pour standard', () => {
      const result = applyReportFloutage(fullReport, 'standard')
      expect(result).toEqual(fullReport)
    })

    it('retourne le rapport complet pour premium', () => {
      const result = applyReportFloutage(fullReport, 'premium')
      expect(result).toEqual(fullReport)
    })
  })
})

Nombre minimum de tests : 9.


Sprint 2.5 — Spike T2 Live

src/features/t2-live/state/__tests__/t2-machine.test.ts

Teste la state machine (transitions d'états).

import { describe, it, expect } from 'vitest'
import { t2LiveReducer, initialT2State } from '../t2-machine'

describe('t2LiveReducer', () => {
  it('transition idle → connecting sur CONNECT', () => {
    const next = t2LiveReducer(initialT2State, { type: 'CONNECT' })
    expect(next.status).toBe('connecting')
  })

  it('transition connecting → listening sur CONNECTED', () => {
    const state = { ...initialT2State, status: 'connecting' as const }
    const next = t2LiveReducer(state, { type: 'CONNECTED' })
    expect(next.status).toBe('listening')
  })

  it('transition * → error sur ERROR avec close code 4001', () => {
    const state = { ...initialT2State, status: 'listening' as const }
    const next = t2LiveReducer(state, { type: 'ERROR', code: 4001, message: 'Auth' })
    expect(next.status).toBe('error')
    expect(next.errorCode).toBe(4001)
  })

  it('transition listening → speaking sur USER_SPEAKING', () => {
    const state = { ...initialT2State, status: 'listening' as const }
    const next = t2LiveReducer(state, { type: 'USER_SPEAKING' })
    expect(next.status).toBe('speaking')
  })

  it('transition speaking → listening sur USER_SILENT', () => {
    const state = { ...initialT2State, status: 'speaking' as const }
    const next = t2LiveReducer(state, { type: 'USER_SILENT' })
    expect(next.status).toBe('listening')
  })

  it('transition * → ended sur END', () => {
    const state = { ...initialT2State, status: 'listening' as const }
    const next = t2LiveReducer(state, { type: 'END' })
    expect(next.status).toBe('ended')
  })

  it('ignore les actions invalides selon l\'état', () => {
    const state = { ...initialT2State, status: 'ended' as const }
    const next = t2LiveReducer(state, { type: 'USER_SPEAKING' })
    expect(next.status).toBe('ended') // pas de changement
  })
})

Nombre minimum de tests : 7.


Sprint 5 — Billing

src/features/billing/hooks/__tests__/useStripeCheckout.test.ts

Teste le flux de checkout Stripe (mock de l'API backend + redirection).

Nombre minimum de tests : 5.


5. Objectif de couverture

Sprint Tests cumulés Fonctions critiques couvertes
Pré-scaffold 37 access.ts + lib.ts
Sprint 1 55 (+18) + api-client + auth-client
Sprint 2 58 (+3) + usePlan
Sprint 2.5 65 (+7) + t2-machine
Sprint 3 74 (+9) + floutage rapport
Sprint 5 79 (+5) + useStripeCheckout

Cible MVP : ≥ 80 tests Vitest verts, couvrant toutes les fonctions critiques identifiées au §1.

Pour comparaison, le backend a 124 tests (son périmètre de fonctions critiques est plus large — Stripe, Supabase, DeepSeek, Gemini). 80 tests frontend sur un périmètre plus restreint est un objectif proportionnellement équivalent.


6. Lancer les tests

# Dans D:\expria-frontend\

# Tous les tests une fois
npm run test

# Mode watch (relance automatiquement à chaque modification)
npm run test:watch

# Avec rapport de couverture
npm run test:coverage

Résultat attendu après Sprint 3 (exemple) :

✓ src/entities/user/__tests__/access.test.ts (26 tests)
✓ src/entities/user/__tests__/lib.test.ts (11 tests)
✓ src/shared/lib/__tests__/api-client.test.ts (12 tests)
✓ src/shared/lib/__tests__/auth-client.test.ts (6 tests)
✓ src/features/dashboard/hooks/__tests__/usePlan.test.tsx (3 tests)
✓ src/features/t2-live/state/__tests__/t2-machine.test.ts (7 tests)
✓ src/entities/report/__tests__/floutage.test.ts (9 tests)

Test Files  7 passed (7)
Tests       74 passed (74)
Duration    ~2s

7. Règle d'utilisation avec Claude Code

Avant chaque session Claude Code :

npm run test
# Tous les tests doivent être verts avant de commencer

Après chaque session Claude Code :

npm run typecheck && npm run test
# Typecheck OK + tous les tests verts = ok pour commit
# Typecheck KO ou test rouge = régression, ne pas continuer

Dans le prompt à Claude Code, toujours inclure :

"Après chaque modification, lance npm run typecheck puis npm run test. Si un test échoue, corrige la régression avant de passer à l'étape suivante. Ne me montre le résultat final que quand tous les tests sont verts."


8. Quand ajouter de nouveaux tests

Ajouter un test automatisé chaque fois que Claude Code crée une nouvelle fonction qui :

  • Vérifie un droit d'accès ou une permission
  • Modifie des données (état local, mutation API)
  • Calcule un montant, un score, un pourcentage
  • Parse une réponse d'API externe
  • Orchestre un flux métier complexe (state machine, workflow)

Ne pas tester :

  • Les composants purement visuels (Button, Badge, Modal wrappers) — trop fragiles
  • Les wrappers évidents (ex : formatDate qui wrap Intl.DateTimeFormat)
  • Les appels Supabase SDK (c'est leur responsabilité)
  • Les intégrations TanStack Query au niveau composant (couvert par usePlan au niveau hook)

9. Historique

Version Date Changements
1.0 2026-04-17 Création initiale, plan de tests par sprint avec objectif 80 tests MVP