Guide Complet des Stratégies de Test

Stratégies de test

Une stratégie de test est un plan détaillé qui définit comment les tests doivent être conçus, mis en œuvre et exécutés pour valider le bon fonctionnement d'un système ou d'une application. Elle en fixe les objectifs, les scénarios, les cas de test et les outils nécessaires pour garantir la qualité du système.

Pourquoi une stratégie de test ?

Objectifs

  • Validation des fonctionnalités - S'assurer que l'application fonctionne comme prévu
  • Détection précoce des bugs - Repérer les problèmes avant la production
  • Confiance dans les déploiements - Permettre des déploiements automatisés
  • Documentation vivante - Les tests servent de spécifications
  • Prévention des régressions - Éviter de réintroduire des bugs

📋 Étapes principales

  1. Définir les objectifs - Les critères de validation du système
  2. Planification - Scénarios, cas de test, environnements
  3. Conception - Scripts, outils, implémentation
  4. Exécution - Lancer les tests et collecter les résultats
  5. Analyse - Évaluer les résultats et identifier les défauts
  6. Gestion des anomalies - Correction, nouvelle exécution des tests, documentation

Types de tests

Classification générale

Tests statiques (sans exécution)

  • Analyse de code - Linters, analyseurs statiques
  • Revue de code - Relecture par les pairs
  • Analyse de la qualité - SonarQube, ESLint

Tests dynamiques (avec exécution)

  • Tests fonctionnels - Validation des fonctionnalités
  • Tests non fonctionnels - Performance, sécurité, compatibilité

Pyramide des tests

        /\
       /  \
      / E2E \     ← Peu nombreux, lents, coûteux
     /______\
    /        \
   /Intégration\ ← Assez nombreux
  /____________\
 /              \
/   Unitaires    \  ← Très nombreux, rapides, peu coûteux
/________________\

🧪 Tests unitaires

Tests des plus petites unités de code (fonctions, méthodes, composants).

// Exemple avec Jest
describe('Calculator', () => {
  test('doit additionner correctement deux nombres', () => {
    const result = add(2, 3)
    expect(result).toBe(5)
  })
  
  test('doit gérer les nombres négatifs', () => {
    const result = add(-2, 3)
    expect(result).toBe(1)
  })
  
  test('doit lever une erreur pour une entrée invalide', () => {
    expect(() => add('a', 2)).toThrow('Entrée invalide')
  })
})

// React Testing Library
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Button from './Button'

test('doit appeler onClick au clic', async () => {
  const handleClick = jest.fn()
  render(<Button onClick={handleClick}>Cliquez ici</Button>)
  
  const button = screen.getByRole('button', { name: /cliquez ici/i })
  await userEvent.click(button)
  
  expect(handleClick).toHaveBeenCalledTimes(1)
})

Tests d'intégration

Tests de l'interaction entre différents modules ou services.

// Test d'API avec Supertest
import request from 'supertest'
import app from '../app'

describe('API des utilisateurs', () => {
  test('POST /users doit créer un nouvel utilisateur', async () => {
    const userData = {
      name: 'John Doe',
      email: 'john@example.com'
    }
    
    const response = await request(app)
      .post('/users')
      .send(userData)
      .expect(201)
    
    expect(response.body).toMatchObject({
      id: expect.any(Number),
      name: 'John Doe',
      email: 'john@example.com'
    })
  })
})

// Test d'intégration React
test('doit afficher les données utilisateur après leur chargement', async () => {
  // Simuler la réponse de l'API
  server.use(
    rest.get('/api/users/1', (req, res, ctx) => {
      return res(ctx.json({ id: 1, name: 'John Doe' }))
    })
  )
  
  render(<UserProfile userId={1} />)
  
  expect(await screen.findByText('John Doe')).toBeInTheDocument()
})

Tests de bout en bout (E2E)

Tests complets du parcours utilisateur, dans l'application réelle.

// Cypress
describe('Parcours de connexion', () => {
  it('doit permettre de se connecter et d\'accéder au tableau de bord', () => {
    cy.visit('/login')
    
    cy.get('[data-testid=email]').type('user@example.com')
    cy.get('[data-testid=password]').type('password123')
    cy.get('[data-testid=login-button]').click()
    
    cy.url().should('include', '/dashboard')
    cy.get('[data-testid=welcome-message]')
      .should('contain', 'Bon retour !')
  })
})

// Playwright
import { test, expect } from '@playwright/test'

test('un utilisateur peut finaliser un achat', async ({ page }) => {
  await page.goto('/products')
  
  await page.click('[data-testid=add-to-cart]')
  await page.click('[data-testid=cart-icon]')
  await page.click('[data-testid=checkout-button]')
  
  await page.fill('[data-testid=card-number]', '4242424242424242')
  await page.click('[data-testid=pay-button]')
  
  await expect(page.locator('[data-testid=success-message]'))
    .toContainText('Paiement réussi')
})

Approches de test

Tests en boîte noire

Tester sans connaître le code interne (API, interface utilisateur).

// Test d'API sans connaître l'implémentation
describe('API d\'authentification', () => {
  test('doit renvoyer 401 pour des identifiants invalides', async () => {
    const response = await request(app)
      .post('/auth/login')
      .send({
        email: 'wrong@example.com',
        password: 'wrongpassword'
      })
    
    expect(response.status).toBe(401)
    expect(response.body.error).toBe('Identifiants invalides')
  })
})

Tests en boîte blanche

Tester en connaissant entièrement le code source.

// Test avec un mock des dépendances internes
jest.mock('../services/emailService')

test('doit appeler le service email lors de l\'inscription d\'un utilisateur', async () => {
  const mockSendEmail = jest.fn()
  emailService.sendWelcomeEmail = mockSendEmail
  
  await userService.registerUser({
    email: 'test@example.com',
    password: 'password123'
  })
  
  expect(mockSendEmail).toHaveBeenCalledWith('test@example.com')
})

Types de tests spécialisés

🎭 Tests basés sur les propriétés (property-based testing)

// Avec fast-check
import fc from 'fast-check'

test('inverser deux fois doit redonner le tableau de départ', () => {
  fc.assert(fc.property(
    fc.array(fc.integer()),
    (arr) => {
      const reversed = reverse(reverse(arr))
      expect(reversed).toEqual(arr)
    }
  ))
})

💨 Smoke tests (tests de fumée)

Des tests de base pour vérifier que l'application démarre.

describe('Smoke Tests', () => {
  test('l\'application doit démarrer sans planter', () => {
    render(<App />)
    expect(screen.getByTestId('app-container')).toBeInTheDocument()
  })
  
  test('les routes principales doivent être accessibles', async () => {
    const routes = ['/', '/about', '/contact']
    
    for (const route of routes) {
      const response = await request(app).get(route)
      expect(response.status).toBe(200)
    }
  })
})

🧠 Sanity tests (tests de cohérence)

Des tests rapides après un déploiement, pour vérifier les fonctionnalités critiques.

describe('Sanity Tests', () => {
  test('le parcours utilisateur critique fonctionne', async () => {
    // Connexion
    await page.goto('/login')
    await page.fill('[data-testid=email]', 'test@example.com')
    await page.fill('[data-testid=password]', 'password')
    await page.click('[data-testid=login-button]')
    
    // Aller à la fonctionnalité principale
    await page.click('[data-testid=main-feature]')
    
    // Vérifier qu'elle se charge
    await expect(page.locator('[data-testid=feature-content]'))
      .toBeVisible()
  })
})

👥 Tests d'acceptation utilisateur (UAT)

# language: fr
# Gherkin avec Cucumber, mots-clés en français grâce à la ligne du dessus
Fonctionnalité: Inscription d'un utilisateur
  En tant que nouvel utilisateur
  Je veux créer un compte
  Afin de pouvoir accéder à l'application

  Scénario: Inscription réussie
    Étant donné que je suis sur la page d'inscription
    Quand je remplis "Email" avec "user@example.com"
    Et je remplis "Mot de passe" avec "securepassword"
    Et je clique sur "S'inscrire"
    Alors je vois "Inscription réussie"
    Et je suis redirigé vers le tableau de bord

🔥 Fuzzing (fuzz testing)

// Test avec des données aléatoires
describe('Fuzz Testing', () => {
  test('l\'API doit gérer proprement les entrées malformées', async () => {
    const malformedInputs = [
      null,
      undefined,
      '',
      '<script>alert("xss")</script>',
      'A'.repeat(10000),
      { malformed: { deeply: { nested: 'object' } } }
    ]
    
    for (const input of malformedInputs) {
      const response = await request(app)
        .post('/api/data')
        .send(input)
      
      // Ne doit pas planter (500), mais gérer l'erreur proprement
      expect(response.status).not.toBe(500)
    }
  })
})

Configuration et outils

⚙️ Configuration de Jest

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
  moduleNameMapping: {
    '^@/(.*)$': '<rootDir>/src/$1'
  },
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/index.js',
    '!src/**/*.d.ts'
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  },
  testMatch: [
    '<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}',
    '<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}'
  ]
}

🎯 Configuration de Cypress

// cypress.config.js
import { defineConfig } from 'cypress'

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    supportFile: 'cypress/support/e2e.js',
    specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
    video: false,
    screenshotOnRunFailure: true,
    
    setupNodeEvents(on, config) {
      // Plugins et tâches personnalisées
    }
  },
  
  component: {
    devServer: {
      framework: 'react',
      bundler: 'vite'
    }
  }
})

🎭 Configuration de Playwright

// playwright.config.js
import { defineConfig } from '@playwright/test'

export default defineConfig({
  testDir: './tests',
  timeout: 30000,
  
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure'
  },
  
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
    { name: 'mobile', use: { ...devices['iPhone 12'] } }
  ]
})

Stratégies avancées

📊 Couverture de code

// Configuration de seuils de couverture
{
  "jest": {
    "collectCoverage": true,
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      },
      "./src/components/": {
        "branches": 90,
        "statements": 90
      }
    }
  }
}

🔄 Développement piloté par les tests (TDD)

// 1. Écrire le test (qui échoue)
test('doit calculer le prix total avec la taxe', () => {
  const items = [{ price: 100 }, { price: 200 }]
  const total = calculateTotalWithTax(items, 0.2)
  expect(total).toBe(360) // 300 + 20% de taxe
})

// 2. Écrire le minimum de code pour que le test passe
function calculateTotalWithTax(items, taxRate) {
  const subtotal = items.reduce((sum, item) => sum + item.price, 0)
  return subtotal * (1 + taxRate)
}

// 3. Refactoriser si nécessaire

🏭 Factories de test

// Factory pour créer des données de test
const userFactory = (overrides = {}) => ({
  id: Math.random(),
  name: 'John Doe',
  email: 'john@example.com',
  role: 'user',
  ...overrides
})

// Utilisation
test('un administrateur doit avoir accès', () => {
  const admin = userFactory({ role: 'admin' })
  expect(hasAccess(admin, 'admin-panel')).toBe(true)
})

🎯 Doublures de test (mocks, stubs, spies)

// Mock complet
jest.mock('../services/apiService', () => ({
  fetchUser: jest.fn(() => Promise.resolve({ id: 1, name: 'John' }))
}))

// Spy sur une méthode existante
const consoleSpy = jest.spyOn(console, 'error').mockImplementation()

// Stub avec une valeur de retour
const mockFetch = jest.fn()
mockFetch.mockResolvedValueOnce({ ok: true, json: () => ({ data: 'test' }) })

Intégration continue (CI)

🔄 GitHub Actions

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Installer Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
      
      - name: Installer les dépendances
        run: npm ci
      
      - name: Lancer les tests unitaires
        run: npm run test:unit
      
      - name: Lancer les tests d'intégration
        run: npm run test:integration
      
      - name: Lancer les tests E2E
        run: npm run test:e2e
      
      - name: Envoyer la couverture
        uses: codecov/codecov-action@v3

📊 Contrôles qualité (quality gates)

// Scripts du package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:unit": "jest --testPathPattern=unit",
    "test:integration": "jest --testPathPattern=integration",
    "test:e2e": "cypress run",
    "test:all": "npm run test:unit && npm run test:integration && npm run test:e2e",
    
    "quality:check": "npm run lint && npm run test:coverage && npm run type-check",
    "pre-commit": "lint-staged && npm run test:unit"
  }
}

Outils et écosystème

🧪 Frameworks de test

  • Jest - Framework complet pour JavaScript
  • Vitest - Alternative rapide à Jest (compatible Vite)
  • Mocha - Framework flexible, avec Chai/Sinon
  • Jasmine - Framework BDD

🌐 Tests E2E

  • Cypress - Framework moderne et intuitif
  • Playwright - Multi-navigateur, par Microsoft
  • Puppeteer - Pilotage de Chrome/Chromium
  • Selenium - Le standard historique

📊 Qualité du code

  • SonarQube - Analyse complète de la qualité
  • ESLint - Linter JavaScript/TypeScript
  • Prettier - Formatage du code
  • Husky - Hooks Git pour l'automatisation

🎯 Utilitaires de test

  • React Testing Library - Des tests React centrés sur l'utilisateur
  • Vue Test Utils - Utilitaires pour tester Vue.js
  • Supertest - Tests d'API HTTP
  • MSW - Mock Service Worker, pour simuler les API

Bonnes pratiques

✅ À faire

// ✅ Des tests descriptifs et lisibles
describe('UserService', () => {
  describe('quand on crée un nouvel utilisateur', () => {
    test('doit envoyer un email de bienvenue à une adresse valide', () => {
      // Implémentation du test
    })
  })
})

// ✅ Le pattern Arrange, Act, Assert
test('doit calculer correctement la remise', () => {
  // Arrange : préparer
  const price = 100
  const discountPercent = 20
  
  // Act : agir
  const finalPrice = applyDiscount(price, discountPercent)
  
  // Assert : vérifier
  expect(finalPrice).toBe(80)
})

// ✅ Tester les cas limites
test('doit gérer les cas limites', () => {
  expect(divide(10, 0)).toBe(Infinity)
  expect(divide(0, 10)).toBe(0)
  expect(divide(-10, 2)).toBe(-5)
})

❌ À éviter

// ❌ Des tests trop complexes
test('doit tout faire', () => {
  // 50 lignes de code qui testent plusieurs choses
})

// ❌ Des tests fragiles (dépendants de l'implémentation)
test('doit appeler la méthode interne', () => {
  const spy = jest.spyOn(service, '_internalMethod')
  service.publicMethod()
  expect(spy).toHaveBeenCalled() // Fragile !
})

// ❌ Des tests sans assertion
test('ne doit pas planter', () => {
  service.doSomething() // Aucune assertion !
})

Stratégie recommandée

🎯 Priorités

  1. Tests unitaires (70%) - Rapides, fiables, bon retour sur investissement
  2. Tests d'intégration (20%) - Les points critiques
  3. Tests E2E (10%) - Les parcours utilisateur essentiels

📋 Checklist qualité

  • Couverture de code > 80% sur le code critique
  • Tests automatisés dans la CI/CD
  • Tests de non-régression pour chaque bug corrigé
  • Tests de performance sur les API critiques
  • Tests de sécurité sur les points d'entrée
  • Tests d'accessibilité sur l'interface utilisateur

🚀 Mise en place progressive

  1. Commencer simplement - Des tests unitaires sur les fonctions pures
  2. Ajouter l'intégration - Tests des API et des composants
  3. Automatiser - Une CI/CD avec des tests obligatoires
  4. Optimiser - Parallélisation, cache, sélection des tests
  5. Surveiller - Métriques de qualité et temps d'exécution

Ressources pour aller plus loin

Une bonne stratégie de test est indispensable pour maintenir la qualité du code et déployer en toute confiance. L'objectif est de trouver le bon équilibre entre la couverture, la vitesse d'exécution et la maintenance des tests.