Le Test-Driven Development (TDD) est une pratique de développement logiciel qui consiste à écrire les tests avant d'écrire le code de production. Cette approche garantit que le code est testable dès sa conception et qu'il répond exactement aux exigences.
Le TDD suit un cycle en trois étapes :
🔴 RED → 🟢 GREEN → 🔵 REFACTOR → 🔴 RED → ...
Écrivez un test pour une fonctionnalité qui n'existe pas encore. Le test doit échouer car le code n'est pas encore implémenté.
Écrivez juste assez de code pour faire passer le test. Ne vous préoccupez pas de la perfection à ce stade.
Améliorez le code tout en gardant les tests verts. Éliminez la duplication, améliorez la lisibilité et la structure.
Emplacement : src/Tests/Application.UnitTests/
Les tests unitaires testent une unité de code isolée (une classe, une méthode) en isolation complète des dépendances externes.
Caractéristiques :
- ✅ Rapides à exécuter (millisecondes)
- ✅ Utilisent des mocks pour simuler les dépendances
- ✅ Testent la logique métier pure
- ✅ Indépendants les uns des autres
Exemple - Test Unitaire d'un UseCase :
[TestFixture]
public class CreateProductUseCaseTests
{
private Mock<IProductRepository> _productRepositoryMock;
private CreateProductUseCase _useCase;
[SetUp]
public void SetUp()
{
_productRepositoryMock = new Mock<IProductRepository>();
_useCase = new CreateProductUseCase(_productRepositoryMock.Object);
}
[Test]
public async Task Execute_ShouldAddProductAndReturnId()
{
// Arrange
var request = new CreateProductRequest("Test Product", 100m);
var productId = Guid.NewGuid();
_productRepositoryMock
.Setup(repo => repo.AddAsync(It.IsAny<Product>()))
.Callback<Product>(product => product.Id = productId)
.Returns(Task.CompletedTask);
// Act
var result = await _useCase.Execute(request);
// Assert
Assert.That(result, Is.EqualTo(productId));
_productRepositoryMock.Verify(repo => repo.AddAsync(It.IsAny<Product>()), Times.Once);
}
}Quand utiliser :
- Pour tester la logique métier des UseCases
- Pour tester les entités du domaine
- Pour tester les validations
Emplacement : src/Tests/Application.IntegrationTests/
Les tests d'intégration testent l'intégration entre plusieurs composants (UseCases + Repository, par exemple).
Caractéristiques :
- ✅ Plus lents que les tests unitaires
- ✅ Utilisent des implémentations réelles ou en mémoire
- ✅ Testent les interactions entre composants
- ✅ Plus proches de l'utilisation réelle
Exemple - Test d'Intégration :
[TestFixture]
public class ProductUseCaseIntegrationTests
{
private InMemoryProductRepository _repository;
[SetUp]
public void SetUp()
{
_repository = new InMemoryProductRepository();
}
[Test]
public async Task CreateAndRetrieveProduct_ShouldWorkEndToEnd()
{
// Arrange
var createRequest = new CreateProductRequest("iPhone 15 Pro", 1299.99m);
// Act - Créer un produit
var createUseCase = new CreateProductUseCase(_repository);
var productId = await createUseCase.Execute(createRequest);
// Act - Récupérer le produit créé
var getUseCase = new GetProductUseCase(_repository);
var retrievedProduct = await getUseCase.Execute(productId);
// Assert
Assert.That(retrievedProduct.Name, Is.EqualTo("iPhone 15 Pro"));
Assert.That(retrievedProduct.Price, Is.EqualTo(1299.99m));
}
}Quand utiliser :
- Pour tester le flux complet d'un cas d'utilisation
- Pour vérifier l'intégration avec la base de données
- Pour tester les transactions et la persistance
Imaginons que nous voulons ajouter une fonctionnalité pour mettre à jour le prix d'un produit.
[TestFixture]
public class UpdateProductPriceUseCaseTests
{
private Mock<IProductRepository> _repositoryMock;
private UpdateProductPriceUseCase _useCase;
[SetUp]
public void SetUp()
{
_repositoryMock = new Mock<IProductRepository>();
_useCase = new UpdateProductPriceUseCase(_repositoryMock.Object);
}
[Test]
public async Task Execute_ShouldUpdateProductPrice()
{
// Arrange
var productId = Guid.NewGuid();
var product = new Product("Test Product", 100m) { Id = productId };
var newPrice = 150m;
_repositoryMock
.Setup(repo => repo.GetAsync(productId))
.ReturnsAsync(product);
// Act
var request = new UpdateProductPriceRequest(productId, newPrice);
await _useCase.Execute(request);
// Assert
Assert.That(product.Price, Is.EqualTo(150m));
_repositoryMock.Verify(repo => repo.UpdateAsync(product), Times.Once);
}
}À ce stade, le test échoue car :
UpdateProductPriceUseCasen'existe pasUpdateProductPriceRequestn'existe pas- La méthode
UpdatePricesurProductn'existe pas
Créer le DTO de requête :
// Application/DTOs/UpdateProductPriceRequest.cs
public record UpdateProductPriceRequest(Guid ProductId, decimal NewPrice);Ajouter la méthode au domaine :
// Domain/Entities/Product.cs
public class Product
{
// ... propriétés existantes ...
public void UpdatePrice(decimal newPrice)
{
if (newPrice <= 0)
throw new ArgumentException("Le prix doit être positif", nameof(newPrice));
Price = newPrice;
}
}Créer le UseCase :
// Application/UseCases/product/UpdateProductPriceUseCase.cs
public class UpdateProductPriceUseCase : IUseCase<UpdateProductPriceRequest, bool>
{
private readonly IProductRepository _repository;
public UpdateProductPriceUseCase(IProductRepository repository)
{
_repository = repository;
}
public async Task<bool> Execute(UpdateProductPriceRequest request)
{
var product = await _repository.GetAsync(request.ProductId);
if (product == null) return false;
product.UpdatePrice(request.NewPrice);
await _repository.UpdateAsync(product);
return true;
}
}Exécuter les tests → ✅ Vert !
Maintenant que le test passe, nous pouvons améliorer le code :
Ajouter un test pour le cas d'erreur :
[Test]
public void UpdatePrice_WithNegativePrice_ShouldThrowException()
{
// Arrange
var product = new Product("Test", 100m);
// Act & Assert
Assert.Throws<ArgumentException>(() => product.UpdatePrice(-50m));
}Ajouter un test pour produit inexistant :
[Test]
public async Task Execute_WithNonExistentProduct_ShouldReturnFalse()
{
// Arrange
var productId = Guid.NewGuid();
_repositoryMock
.Setup(repo => repo.GetAsync(productId))
.ReturnsAsync((Product?)null);
// Act
var request = new UpdateProductPriceRequest(productId, 150m);
var result = await _useCase.Execute(request);
// Assert
Assert.That(result, Is.False);
}Tous les tests suivent le pattern AAA (Arrange-Act-Assert) :
[Test]
public async Task NomDescriptifDuTest()
{
// Arrange - Préparer les données et les mocks
var request = new CreateProductRequest("Product", 100m);
// ... configuration des mocks ...
// Act - Exécuter le code à tester
var result = await _useCase.Execute(request);
// Assert - Vérifier les résultats
Assert.That(result, Is.EqualTo(expectedValue));
}- Créer les objets nécessaires
- Configurer les mocks
- Préparer les données de test
- Appeler la méthode à tester
- Une seule ligne dans la plupart des cas
- Vérifier que le résultat est correct
- Vérifier que les mocks ont été appelés correctement
Exemples :
- ✅
Execute_ShouldAddProductAndReturnId - ✅
Execute_WhenProductExists_ShouldReturnProduct - ✅
Execute_WhenProductDoesNotExist_ShouldThrowNotFoundException - ✅
UpdatePrice_WithNegativePrice_ShouldThrowException
Les noms de tests doivent être suffisamment descriptifs pour comprendre :
- Ce qui est testé
- Dans quelles conditions
- Quel est le comportement attendu
Framework de test utilisé dans ce template.
Attributs principaux :
[TestFixture]- Marque une classe de tests[Test]- Marque une méthode de test[SetUp]- Exécuté avant chaque test[TearDown]- Exécuté après chaque test[OneTimeSetUp]- Exécuté une fois avant tous les tests[OneTimeTearDown]- Exécuté une fois après tous les tests
Bibliothèque de mocking pour créer des objets simulés.
Exemples d'utilisation :
// Créer un mock
var mock = new Mock<IProductRepository>();
// Configurer le comportement
mock.Setup(r => r.GetAsync(It.IsAny<Guid>()))
.ReturnsAsync(new Product("Test", 100m));
// Vérifier les appels
mock.Verify(r => r.AddAsync(It.IsAny<Product>()), Times.Once);
mock.Verify(r => r.UpdateAsync(It.IsAny<Product>()), Times.Never);dotnet testdotnet test src/Tests/Application.UnitTests/
dotnet test src/Tests/Application.IntegrationTests/dotnet test --collect:"XPlat Code Coverage"dotnet watch testLe code fait exactement ce dont on a besoin, rien de plus, rien de moins.
Tous les chemins du code sont testés car les tests sont écrits avant le code.
Les tests servent de documentation vivante et permettent de refactoriser en confiance.
Les problèmes sont détectés dès l'écriture du code, pas en production.
Le TDD force à penser à l'API et à l'utilisation avant l'implémentation, conduisant à un meilleur design.
- Écrire les tests d'abord - C'est l'essence du TDD
- Un concept par test - Chaque test vérifie une seule chose
- Tests indépendants - Les tests ne doivent pas dépendre les uns des autres
- Tests rapides - Les tests unitaires doivent s'exécuter en millisecondes
- Noms descriptifs - Le nom du test explique ce qu'il teste
- Refactoriser régulièrement - Gardez le code propre
- Ne pas tester les détails d'implémentation - Testez le comportement, pas l'implémentation
- Ne pas avoir de logique dans les tests - Les tests doivent être simples
- Ne pas ignorer les tests qui échouent - Corrigez-les immédiatement
- Ne pas écrire des tests trop complexes - Si le test est complexe, le code l'est aussi
- Ne pas dupliquer le code - Utilisez
[SetUp]pour la configuration commune
1. Écrire le test (RED) 🔴
[Test]
public async Task Execute_ShouldDeleteProduct()
{
// Arrange
var productId = Guid.NewGuid();
_repositoryMock.Setup(r => r.DeleteAsync(productId))
.Returns(Task.CompletedTask);
// Act
var useCase = new DeleteProductUseCase(_repositoryMock.Object);
await useCase.Execute(productId);
// Assert
_repositoryMock.Verify(r => r.DeleteAsync(productId), Times.Once);
}2. Implémenter le code (GREEN) 🟢
public class DeleteProductUseCase : IUseCase<Guid, Task>
{
private readonly IProductRepository _repository;
public DeleteProductUseCase(IProductRepository repository)
{
_repository = repository;
}
public async Task Execute(Guid productId)
{
await _repository.DeleteAsync(productId);
}
}3. Refactoriser (REFACTOR) 🔵
// Ajouter validation
public async Task Execute(Guid productId)
{
if (productId == Guid.Empty)
throw new ArgumentException("L'ID du produit est requis", nameof(productId));
await _repository.DeleteAsync(productId);
}
// Ajouter test correspondant
[Test]
public void Execute_WithEmptyGuid_ShouldThrowArgumentException()
{
var useCase = new DeleteProductUseCase(_repositoryMock.Object);
Assert.ThrowsAsync<ArgumentException>(() => useCase.Execute(Guid.Empty));
}- Test Driven Development par Kent Beck
- NUnit Documentation
- Moq Documentation
- Clean Code par Robert C. Martin
Le TDD est plus qu'une technique de test - c'est une discipline de conception. En écrivant les tests d'abord :
- Vous concevez de meilleures APIs
- Vous écrivez du code plus maintenable
- Vous avez confiance en vos refactorisations
- Vous produisez du code de meilleure qualité
N'oubliez pas : Red → Green → Refactor ! 🔴🟢🔵