TDD & Qualité

Clean Architecture et Testabilité : Écrire du Code Facile à Tester

Romain Lefebvre

Romain Lefebvre

4 avril 2026

Clean Architecture et Testabilité : Écrire du Code Facile à Tester

Le code difficile à tester vous dit quelque chose

Quand vous passez plus de temps à configurer des mocks qu'à écrire des assertions, quand votre test nécessite de démarrer un serveur HTTP pour tester une règle métier, quand vous devez modifier 12 fichiers après un changement anodin — ce sont des signaux d'architecture, pas de testing.

La testabilité n'est pas une propriété qu'on ajoute après coup avec les bons outils. Elle émerge d'un design qui respecte quelques principes fondamentaux. Explorons ces principes avec des exemples concrets.

L'injection de dépendances — la base de tout

Le premier obstacle à la testabilité est la dépendance directe aux ressources externes. Si votre fonction appelle directement new Database() ou fetch(), vous êtes bloqué.

// ❌ Code difficile à tester — dépendances cachées
export class OrderService {
  async processOrder(orderId: string): Promise<void> {
    const db = new PostgresDatabase(); // Dépendance cachée
    const emailClient = new SendGridClient(); // Dépendance cachée
    
    const order = await db.findOrder(orderId);
    order.status = 'processed';
    await db.saveOrder(order);
    await emailClient.send(order.userEmail, 'Commande traitée');
  }
}

// Pour tester ça, vous devez avoir une vraie base de données et SendGrid
// ou bidouiller des patches globals...
// ✅ Code testable — dépendances injectées
interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<Order>;
}

interface EmailService {
  send(to: string, subject: string, body: string): Promise<void>;
}

export class OrderService {
  constructor(
    private orderRepository: OrderRepository,
    private emailService: EmailService,
  ) {}

  async processOrder(orderId: string): Promise<void> {
    const order = await this.orderRepository.findById(orderId);
    if (!order) throw new Error(`Commande ${orderId} introuvable`);
    
    order.status = 'processed';
    await this.orderRepository.save(order);
    await this.emailService.send(
      order.userEmail,
      'Commande traitée',
      `Votre commande #${orderId} a été traitée avec succès.`
    );
  }
}

// Test — propre, rapide, sans dépendances externes
describe('OrderService.processOrder', () => {
  it('met à jour le statut et envoie un email', async () => {
    const mockRepo: OrderRepository = {
      findById: jest.fn().mockResolvedValue({
        id: '123',
        userEmail: '[email protected]',
        status: 'pending',
      }),
      save: jest.fn().mockImplementation((order) => Promise.resolve(order)),
    };
    
    const mockEmail: EmailService = {
      send: jest.fn().mockResolvedValue(undefined),
    };
    
    const service = new OrderService(mockRepo, mockEmail);
    await service.processOrder('123');
    
    expect(mockRepo.save).toHaveBeenCalledWith(
      expect.objectContaining({ status: 'processed' })
    );
    expect(mockEmail.send).toHaveBeenCalledWith(
      '[email protected]',
      'Commande traitée',
      expect.stringContaining('123')
    );
  });
});

La règle de dépendance dans la Clean Architecture

La Clean Architecture (Robert C. Martin) organise le code en couches concentriques avec une règle simple : les dépendances pointent vers l'intérieur. Le domaine ne connaît pas l'infrastructure.

          ┌─────────────────────────────┐
          │       Infrastructure        │  ← PostgreSQL, SendGrid, HTTP
          │  ┌───────────────────────┐  │
          │  │      Application      │  │  ← Use cases, orchestration
          │  │  ┌─────────────────┐  │  │
          │  │  │     Domain      │  │  │  ← Entités, règles métier pures
          │  │  └─────────────────┘  │  │
          │  └───────────────────────┘  │
          └─────────────────────────────┘
                    ↑ dépendances

En pratique, voici comment ça se traduit :

// === COUCHE DOMAIN — zéro dépendance externe ===

// src/domain/entities/order.ts
export type OrderStatus = 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';

export interface Order {
  id: string;
  customerId: string;
  items: OrderItem[];
  status: OrderStatus;
  totalAmount: number;
  createdAt: Date;
}

// Logique métier pure — testable sans aucune infrastructure
export function calculateOrderTotal(items: OrderItem[]): number {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

export function canCancelOrder(order: Order): boolean {
  return order.status === 'pending' || order.status === 'confirmed';
}

// Tests domain — pure functions, aucun mock nécessaire
describe('canCancelOrder', () => {
  it('autorise l\'annulation d\'une commande en attente', () => {
    const order = { status: 'pending' } as Order;
    expect(canCancelOrder(order)).toBe(true);
  });

  it('refuse l\'annulation d\'une commande expédiée', () => {
    const order = { status: 'shipped' } as Order;
    expect(canCancelOrder(order)).toBe(false);
  });
});


// === COUCHE APPLICATION — dépend du domain, pas de l'infrastructure ===

// src/application/use-cases/cancel-order.ts
export interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<Order>;
}

export interface NotificationService {
  notifyOrderCancelled(customerId: string, orderId: string): Promise<void>;
}

export class CancelOrderUseCase {
  constructor(
    private orderRepo: OrderRepository,
    private notificationService: NotificationService,
  ) {}

  async execute(orderId: string, reason: string): Promise<Order> {
    const order = await this.orderRepo.findById(orderId);
    if (!order) throw new Error(`Commande ${orderId} introuvable`);
    
    if (!canCancelOrder(order)) {
      throw new Error(`Impossible d'annuler une commande en statut "${order.status}"`);
    }
    
    const cancelledOrder = { ...order, status: 'cancelled' as const };
    const saved = await this.orderRepo.save(cancelledOrder);
    await this.notificationService.notifyOrderCancelled(order.customerId, orderId);
    
    return saved;
  }
}


// === COUCHE INFRASTRUCTURE — implémentations concrètes ===

// src/infrastructure/repositories/postgres-order.repository.ts
export class PostgresOrderRepository implements OrderRepository {
  constructor(private pool: Pool) {}

  async findById(id: string): Promise<Order | null> {
    const { rows } = await this.pool.query(
      'SELECT * FROM orders WHERE id = $1',
      [id]
    );
    return rows[0] ? mapRowToOrder(rows[0]) : null;
  }

  async save(order: Order): Promise<Order> {
    // ... implémentation PostgreSQL
  }
}

Le principe de responsabilité unique (SRP)

Les classes qui font trop de choses sont impossibles à tester proprement.

// ❌ Classe qui fait tout — difficile à tester
export class UserController {
  async register(req: Request, res: Response): Promise<void> {
    const { email, password, name } = req.body;
    
    // Validation
    if (!email.includes('@')) {
      return res.status(400).json({ error: 'Email invalide' });
    }
    
    // Hachage du mot de passe
    const hashedPassword = await bcrypt.hash(password, 12);
    
    // Sauvegarde en base
    const user = await db.query(
      'INSERT INTO users (email, password, name) VALUES ($1, $2, $3) RETURNING *',
      [email, hashedPassword, name]
    );
    
    // Envoi d'email
    await sendgrid.send({
      to: email,
      subject: 'Bienvenue !',
      text: `Bonjour ${name}...`,
    });
    
    res.status(201).json(user.rows[0]);
  }
}

Pour tester une seule règle métier ici, vous devez simuler HTTP, la DB, bcrypt, et SendGrid. Voici la version testable :

// ✅ Responsabilités séparées

// Validation — pure, testable sans mock
export function validateRegistration(data: { email: string; password: string; name: string }): string[] {
  const errors: string[] = [];
  if (!data.email.includes('@')) errors.push('Email invalide');
  if (data.password.length < 8) errors.push('Mot de passe trop court');
  if (!data.name.trim()) errors.push('Nom requis');
  return errors;
}

// Use case — testable avec mocks légers
export class RegisterUserUseCase {
  constructor(
    private userRepo: UserRepository,
    private passwordHasher: PasswordHasher,
    private emailService: EmailService,
  ) {}

  async execute(data: RegistrationData): Promise<User> {
    const errors = validateRegistration(data);
    if (errors.length > 0) throw new ValidationError(errors);
    
    const hashedPassword = await this.passwordHasher.hash(data.password);
    const user = await this.userRepo.create({ ...data, password: hashedPassword });
    await this.emailService.sendWelcome(user);
    
    return user;
  }
}

// Controller — fine, delegue tout
export class UserController {
  constructor(private registerUseCase: RegisterUserUseCase) {}

  async register(req: Request, res: Response): Promise<void> {
    try {
      const user = await this.registerUseCase.execute(req.body);
      res.status(201).json({ id: user.id, email: user.email });
    } catch (error) {
      if (error instanceof ValidationError) {
        res.status(400).json({ errors: error.messages });
      } else {
        res.status(500).json({ error: 'Erreur interne' });
      }
    }
  }
}

// Tests — chaque pièce testée indépendamment
describe('validateRegistration', () => {
  it('retourne une erreur pour un email invalide', () => {
    const errors = validateRegistration({ email: 'invalid', password: 'pass1234', name: 'Alice' });
    expect(errors).toContain('Email invalide');
  });

  it('retourne un tableau vide pour des données valides', () => {
    const errors = validateRegistration({ email: '[email protected]', password: 'pass1234', name: 'Alice' });
    expect(errors).toHaveLength(0);
  });
});

Les ports et adaptateurs (architecture hexagonale)

L'architecture hexagonale formalise l'injection de dépendances avec la notion de ports (interfaces) et d'adaptateurs (implémentations) :

// Ports — définis dans la couche application
export interface PaymentGateway {
  charge(amount: number, customerId: string): Promise<PaymentResult>;
  refund(paymentId: string): Promise<void>;
}

// Adaptateur de production
export class StripePaymentGateway implements PaymentGateway {
  async charge(amount: number, customerId: string): Promise<PaymentResult> {
    return stripe.paymentIntents.create({ amount, currency: 'eur', customer: customerId });
  }
  
  async refund(paymentId: string): Promise<void> {
    await stripe.refunds.create({ payment_intent: paymentId });
  }
}

// Adaptateur de test — dans vos tests
export class FakePaymentGateway implements PaymentGateway {
  public charges: Array<{ amount: number; customerId: string }> = [];
  public refunds: string[] = [];
  private shouldFail = false;

  simulateFailure(): void {
    this.shouldFail = true;
  }

  async charge(amount: number, customerId: string): Promise<PaymentResult> {
    if (this.shouldFail) throw new Error('Carte refusée');
    this.charges.push({ amount, customerId });
    return { id: `pi_test_${Date.now()}`, status: 'succeeded' };
  }

  async refund(paymentId: string): Promise<void> {
    this.refunds.push(paymentId);
  }
}

// Utilisation dans les tests
describe('CheckoutService', () => {
  it('traite le paiement et crée la commande', async () => {
    const gateway = new FakePaymentGateway();
    const service = new CheckoutService(gateway, mockOrderRepo);
    
    await service.checkout({ items, customerId: 'cust_123', totalAmount: 9999 });
    
    expect(gateway.charges).toHaveLength(1);
    expect(gateway.charges[0]).toEqual({ amount: 9999, customerId: 'cust_123' });
  });

  it('annule la commande si le paiement échoue', async () => {
    const gateway = new FakePaymentGateway();
    gateway.simulateFailure();
    const service = new CheckoutService(gateway, mockOrderRepo);
    
    await expect(
      service.checkout({ items, customerId: 'cust_123', totalAmount: 9999 })
    ).rejects.toThrow('Carte refusée');
    
    expect(mockOrderRepo.save).not.toHaveBeenCalled();
  });
});

Le principe d'inversion de dépendances pour les dates et le temps

Le temps est l'une des dépendances les plus insidieuses. Date.now() et new Date() directement dans le code métier rendent les tests fragiles :

// ❌ Dépendance cachée au temps
export function isTokenExpired(token: { expiresAt: Date }): boolean {
  return token.expiresAt < new Date(); // Teste contre le vrai temps actuel
}

// ✅ Injecter l'horloge
export interface Clock {
  now(): Date;
}

export const SystemClock: Clock = {
  now: () => new Date(),
};

export function isTokenExpired(
  token: { expiresAt: Date },
  clock: Clock = SystemClock
): boolean {
  return token.expiresAt < clock.now();
}

// Test avec une horloge fixe
describe('isTokenExpired', () => {
  const fixedDate = new Date('2024-06-15T12:00:00Z');
  const fakeClock: Clock = { now: () => fixedDate };

  it('retourne true pour un token expiré', () => {
    const expiredToken = { expiresAt: new Date('2024-06-15T11:59:59Z') };
    expect(isTokenExpired(expiredToken, fakeClock)).toBe(true);
  });

  it('retourne false pour un token valide', () => {
    const validToken = { expiresAt: new Date('2024-06-15T12:00:01Z') };
    expect(isTokenExpired(validToken, fakeClock)).toBe(false);
  });
});

Conclusion

Un code difficile à tester est souvent un code difficile à maintenir, à modifier, et à comprendre. Ce n'est pas une coïncidence : les mêmes propriétés qui rendent le code testable — faible couplage, responsabilités claires, dépendances explicites — sont les mêmes qui font un bon design logiciel.

Inversez l'approche : au lieu de chercher comment tester du code existant, demandez-vous comment écrire du code qui sera naturellement testable. La Clean Architecture et l'injection de dépendances ne sont pas des contraintes — ce sont des outils qui rendent votre code meilleur à tous les niveaux.