Introduction

“Il faut 100% de couverture de tests !” vs “Les tests, c’est une perte de temps !”. Entre ces deux extrêmes, où se trouve la vérité ? Après avoir vu des projets paralysés par des suites de tests trop lourdes et d’autres s’effondrer faute de tests, je pense avoir trouvé quelques équilibres pragmatiques.

Les tests ne sont pas une religion, c’est un outil. Et comme tout outil, ils doivent être adaptés au contexte du projet et aux contraintes de l’équipe.

La pyramide des tests : théorie vs réalité

Le modèle théorique

La fameuse pyramide nous dit :

  • Base large : beaucoup de tests unitaires (rapides, isolés)
  • Milieu : quelques tests d’intégration (plus lents, plus réalistes)
  • Sommet : peu de tests end-to-end (lents, fragiles, mais complets)

Adapter la pyramide au contexte

Dans la réalité, la forme optimale dépend du projet :

Projet API/Backend

        E2E (5%)
    Integration (30%)
  Unit Tests (65%)

Application Frontend

      E2E (15%)
  Integration (25%)
 Unit Tests (60%)

Startup en croissance rapide

    E2E Critical (10%)
 Happy Path Tests (40%)
Core Logic Tests (50%)

Mes règles pragmatiques

1. Commencer par les tests qui ont le plus d’impact

// Priorisez ces tests en premier
describe('Critical Business Logic', () => {
  test('Payment processing calculates correct amounts', () => {
    const order = {
      items: [{ price: 10, quantity: 2 }],
      taxRate: 0.2,
      discountCode: 'SAVE10'
    };
    
    const result = calculateOrderTotal(order);
    
    expect(result.subtotal).toBe(20);
    expect(result.discount).toBe(2);
    expect(result.tax).toBe(3.6);
    expect(result.total).toBe(21.6);
  });
});

2. Tests d’intégration pour les interactions critiques

// Tester les intégrations qui cassent souvent
describe('Database Integration', () => {
  test('User creation creates profile and sends welcome email', async () => {
    const userData = { email: 'test@example.com', name: 'John' };
    
    const user = await userService.createUser(userData);
    
    // Vérification DB
    const savedUser = await db.users.findById(user.id);
    expect(savedUser.email).toBe(userData.email);
    
    // Vérification profile créé
    const profile = await db.profiles.findByUserId(user.id);
    expect(profile).toBeDefined();
    
    // Vérification email dans la queue
    const emailJobs = await emailQueue.getJobs();
    expect(emailJobs).toHaveLength(1);
    expect(emailJobs[0].data.to).toBe(userData.email);
  });
});

Stratégies par type de code

Code métier : tests obligatoires

Tout ce qui touche à l’argent, aux données utilisateur critiques, ou aux règles métier complexes doit être testé.

// Logique de pricing complexe = tests exhaustifs
describe('Subscription Pricing', () => {
  const testCases = [
    { plan: 'basic', users: 5, expected: 50 },
    { plan: 'basic', users: 15, expected: 125 }, // +25% au-delà de 10
    { plan: 'pro', users: 5, expected: 100 },
    { plan: 'enterprise', users: 100, expected: 800 }
  ];
  
  test.each(testCases)('calculates correct price for $plan with $users users', 
    ({ plan, users, expected }) => {
      expect(calculateSubscriptionPrice(plan, users)).toBe(expected);
    }
  );
});

Interface utilisateur : tests sélectifs

Ne testez pas chaque bouton, concentrez-vous sur les parcours critiques.

// Test E2E pour le parcours d'achat complet
describe('Purchase Flow', () => {
  test('Complete purchase journey', async () => {
    // Setup
    await page.goto('/products');
    
    // Sélection produit
    await page.click('[data-testid="product-123"]');
    await page.click('[data-testid="add-to-cart"]');
    
    // Checkout
    await page.click('[data-testid="checkout-button"]');
    await page.fill('[data-testid="email"]', 'test@example.com');
    await page.fill('[data-testid="card-number"]', '4242424242424242');
    
    // Validation
    await page.click('[data-testid="pay-button"]');
    await expect(page.locator('[data-testid="success-message"]')).toBeVisible();
    
    // Vérification backend
    const order = await db.orders.findByEmail('test@example.com');
    expect(order.status).toBe('paid');
  });
});

Code utilitaire : tests unitaires massifs

Les fonctions pures, les helpers, les validations : testez-les à fond.

describe('Email Validation', () => {
  const validEmails = [
    'user@domain.com',
    'test.email+tag@example.co.uk',
    'user123@test-domain.org'
  ];
  
  const invalidEmails = [
    'invalid-email',
    '@domain.com',
    'user@',
    'user@domain',
    ''
  ];
  
  test.each(validEmails)('accepts valid email: %s', (email) => {
    expect(isValidEmail(email)).toBe(true);
  });
  
  test.each(invalidEmails)('rejects invalid email: %s', (email) => {
    expect(isValidEmail(email)).toBe(false);
  });
});

Mocking : l’art de la substitution

Quand mocker, quand ne pas mocker

À mocker systématiquement

// Services externes (APIs, paiement, email)
jest.mock('../services/paymentService', () => ({
  processPayment: jest.fn().mockResolvedValue({ success: true, transactionId: '123' })
}));

// Date/Time pour la reproductibilité
jest.useFakeTimers().setSystemTime(new Date('2025-01-15'));

// Système de fichiers
jest.mock('fs/promises', () => ({
  readFile: jest.fn(),
  writeFile: jest.fn()
}));

À ne pas mocker

// Logique métier interne
// ❌ Ne pas faire ça
jest.mock('../utils/calculateDiscount');

// Database en test d'intégration
// ❌ Ne pas faire ça non plus
jest.mock('../database/userRepository');

Patterns de mocking efficaces

Mock partiel avec des vraies données de test

// Simuler une API avec des réponses réalistes
const mockApiResponses = {
  getUserProfile: {
    id: 123,
    name: 'John Doe',
    email: 'john@example.com',
    subscription: {
      plan: 'pro',
      status: 'active',
      expiresAt: '2025-12-31'
    }
  },
  
  getOrders: [
    { id: 1, total: 29.99, status: 'completed' },
    { id: 2, total: 49.99, status: 'pending' }
  ]
};

// Mock qui retourne des données cohérentes
jest.mock('../services/apiService', () => ({
  get: jest.fn().mockImplementation((endpoint) => {
    if (endpoint.includes('/users/')) return mockApiResponses.getUserProfile;
    if (endpoint.includes('/orders')) return mockApiResponses.getOrders;
    throw new Error(`Unexpected API call: ${endpoint}`);
  })
}));

Environnement de test : la fondation invisible

Base de données de test

// Configuration Jest pour tests d'intégration
module.exports = {
  testEnvironment: 'node',
  setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
  testMatch: ['**/*.test.js'],
  collectCoverageFrom: [
    'src/**/*.js',
    '!src/**/*.test.js',
    '!src/migrations/**'
  ]
};
// tests/setup.js - Environnement de test propre
const { Pool } = require('pg');

let testDb;

beforeAll(async () => {
  // Base de test isolée
  testDb = new Pool({
    connectionString: process.env.TEST_DATABASE_URL,
    max: 1
  });
  
  // Migrations automatiques
  await runMigrations(testDb);
});

beforeEach(async () => {
  // Nettoyer entre chaque test
  await testDb.query('TRUNCATE TABLE users, orders, products CASCADE');
  
  // Données de base communes
  await seedTestData(testDb);
});

afterAll(async () => {
  await testDb.end();
});

Données de test cohérentes

// Factory pattern pour créer des données de test
const UserFactory = {
  build: (overrides = {}) => ({
    email: 'test@example.com',
    name: 'Test User',
    createdAt: new Date(),
    ...overrides
  }),
  
  create: async (overrides = {}) => {
    const userData = UserFactory.build(overrides);
    return await userService.create(userData);
  }
};

// Usage dans les tests
describe('User Service', () => {
  test('updates user profile', async () => {
    const user = await UserFactory.create({ name: 'Original Name' });
    
    await userService.updateProfile(user.id, { name: 'Updated Name' });
    
    const updated = await userService.findById(user.id);
    expect(updated.name).toBe('Updated Name');
  });
});

Mesurer l’efficacité des tests

Métriques qui comptent vraiment

Au-delà de la couverture

// Script d'analyse personnalisé
const testMetrics = {
  // Vitesse d'exécution
  averageTestTime: calculateAverageTime(),
  
  // Fiabilité
  flakyTestsRatio: getFlakyTests().length / getAllTests().length,
  
  // Efficacité
  bugsFoundByTests: getBugsCaughtByTests(),
  bugsMissedByTests: getBugsMissedByTests(),
  
  // Maintenance
  testsUpdatedPerFeature: getTestUpdateFrequency()
};

Dashboard de qualité

// Intégration avec CI pour suivi qualité
const qualityGates = {
  coverage: {
    statements: 80,
    branches: 75,
    functions: 80,
    lines: 80
  },
  
  performance: {
    maxTestDuration: 300, // 5 minutes max
    maxSlowTests: 5 // Maximum 5 tests > 10s
  },
  
  reliability: {
    maxFlakyTests: 2, // Maximum 2 tests instables
    successRate: 95 // 95% de succès minimum
  }
};

Debugging de tests : quand ça ne marche pas

Tests instables : les identifier et les corriger

// Script pour détecter les tests flaky
// package.json
{
  "scripts": {
    "test:stability": "for i in {1..10}; do npm test || exit 1; done"
  }
}
// Patterns courants de tests instables

// ❌ Problème : dépendance à l'ordre d'exécution
describe('User Tests', () => {
  let userId;
  
  test('creates user', async () => {
    const user = await createUser();
    userId = user.id; // État partagé entre tests
  });
  
  test('updates user', async () => {
    await updateUser(userId); // Peut échouer si le premier test échoue
  });
});

// ✅ Solution : isolation complète
describe('User Tests', () => {
  test('creates user', async () => {
    const user = await createUser();
    expect(user.id).toBeDefined();
  });
  
  test('updates user', async () => {
    const user = await createUser(); // Chaque test indépendant
    await updateUser(user.id);
    // ... assertions
  });
});

Performance des tests

// Identifier les tests lents
jest.setTimeout(30000); // Timeout global

// Mesurer et optimiser
describe('Slow Operations', () => {
  test('bulk data processing', async () => {
    const start = Date.now();
    
    await processBulkData();
    
    const duration = Date.now() - start;
    console.log(`Test duration: ${duration}ms`);
    
    // Fail si trop lent (CI/CD monitoring)
    if (process.env.NODE_ENV === 'ci' && duration > 5000) {
      throw new Error(`Test too slow: ${duration}ms`);
    }
  });
});

Plan d’implémentation progressive

Phase 1 : Tests critiques (Semaine 1)

  • Identifier les 5 fonctions les plus critiques du projet
  • Écrire des tests unitaires complets pour ces fonctions
  • Mettre en place 1 test E2E pour le parcours principal

Phase 2 : Infrastructure (Semaines 2-3)

  • Configurer l’environnement de test isolé
  • Intégrer les tests dans CI/CD
  • Créer des factories pour les données de test

Phase 3 : Couverture étendue (Mois 2)

  • Tests d’intégration pour les API principales
  • Tests unitaires pour toute la logique métier
  • Mocking des services externes

Phase 4 : Optimisation (Mois 3)

  • Analyse de performance des tests
  • Élimination des tests instables
  • Mise en place de métriques qualité

Conclusion

Une stratégie de test efficace n’est pas celle qui a 100% de couverture, c’est celle qui donne confiance à l’équipe pour livrer rapidement sans casser l’existant.

Les meilleurs tests sont ceux qu’on oublie : ils tournent en arrière-plan, attrapent les bugs avant la production, et ne ralentissent pas le développement.

Commencez petit, mesurez l’impact, et ajustez au fur et à mesure. Les tests doivent servir votre projet, pas le contraindre.

Quelle sera votre première étape pour améliorer votre stratégie de test ?