Le Tdd résout des problèmes de design
La couverture de test est un effet de bord bienvenu, mais n’est pas l’intérêt principal du TDD. L’objectif est d’écrire du code de production de qualité.
Ma première expérience avec le TDD⌗
Je me souviens avoir eu du mal à écrire des tests unitaires. C’était avant d’avoir entendu parler des principes SOLID. Mon développement était basé sur de l’intuition, et produisait des grandes fonctions. Une période extrêmement frustrante à cause de mon incapacité à produire quelque chose de compréhensible, sans bug, et n’avait même pas l’avantage d’être efficace. Probablement un début de carrière classique.
Puis, j’ai découvert à peu près en même temps les principes de Clean Code et SOLID. Cela a réveillé un plaisir de coder à nouveau, mon code était un peu mieux en devenant plus facilement testable. Et j’ai entamé une période de transition vers ce que j’appelle ma période élitiste. C’était mieux que mes débuts, mais je devenais incapable d’accepter des alternatives au Clean Code. Chaque dette technique était perçue comme inacceptable, que le code se devait être élégant. Mais malgré tous les défauts que je reprochais à ma méthodologie, le code était devenu testable.
Malheureusement pour moi, l’objectif initial du Test Driven Development m’a échappé pendant des années. Convaincu erronément que l’objectif du TDD était d’écrire des tests de qualités, il s’agit en réalité d’écrire du code de production de qualité. Si j’avais compris cette nuance plus tôt, le TDD m’aurait servi à m’améliorer plus vite et plus tôt.
Cet article a pour objectif de montrer comment le TDD peut aider à résoudre des problèmes de design avec un exemple.
Démonstration d’un test impertinent⌗
Ceci est un code d’exemple pour servir mon propos. Il a été inspiré par du code que je vois régulièrement en entreprise utilisant le C# en Belgique. Le livre Unit Testing Principles, Practices, and Patterns (Vladimir Khorikov, 2020) les appelle les tests londoniens.
// Pseudo code for production
public interface IUserRepository
{
Task<User[]> GetUsers(IEnumerable<int> id);
Task SaveUser(User user);
}
public class User
{
public int Id { get; }
public int Age { get; }
public bool IsAdult { get; }
public User(int id, int age, bool isAdult = false)
{
Id = id;
Age = age;
IsAdult = isAdult;
}
}
public interface IExecuteForUsers
{
Task Execute(IEnumerable<int> userids);
}
public interface IExecuteForUser
{
Task Execute(User user);
}
public class ExecuteForUsers : IExecuteForUsers
{
private readonly IExecuteForUser executeForUser;
private readonly IUserRepository userRepository;
public ExecuteForUsers(IExecuteForUser executeForUser, IUserRepository userRepository)
{
this.executeForUser = executeForUser;
this.userRepository = userRepository;
}
public async Task Execute(IEnumerable<int> userids)
{
var users = await userRepository.GetUsers(userids);
await Task.WhenAll(users.Select(executeForUser.Execute));
await Task.WhenAll(users.Select(userRepository.SaveUser)); // TODO gestion des erreurs
}
}
internal class SetIsAsdultIfHigherThan18Majeur : IExecuteForUser
{
public async Task Execute(User user)
{
user.IsAdult = user.Age >= 18;
}
}
// Cas de test
public class ExecuteForUsersTest
{
private Mock<IUserRepository> userRepositoryMock;
private Mock<IExecuteForUser> executeForUserMock;
private ExecuteForUsers sut;
public ExecuteForUsersTest()
{
this.executeForUserMock = new Mock<IExecuteForUser>();
this.userRepositoryMock = new Mock<IUserRepository>();
sut = new ExecuteForUsers(executeForUserMock.Object, userRepositoryMock.Object);
}
[Fact]
public async Task ExecuteForUsers_ForEachUserFound_CallInner()
{
userRepositoryMock.
Setup(x =>
x.GetUsers(
It.IsAny<IEnumerable<int>>()
)
).ReturnsAsync(new User[] { new User(1, "John", 25), new User(2, "Foo", 30) });
await sut.Execute(new int[] { 1, 2 });
executeForUserMock.
Verify(x =>
x.Execute(It.IsAny<User>()
), Times.Exactly(2));
}
}
Ayant le même problème qu’expliquait un post précédent, le test n’apporte que très peu de valeur ajoutée. Concrètement, le test assure que ce composant fournit à une autre composant des données. Le post précédent explique bien quel est le problème, je ne m’attarde pas plus. Le vrai sujet est comment écrire du code de production avec le TDD.
Je considère personnellement que la classe ExecuteForUsers souffre d’une overdose de SOLID : à force de vouloir faire peu de chose, elle ne fait rien. À mon opinion, écrire une classe qui a pour seul objectif de lier le résultat d’un repository avec un autre process est faible. La cohésion entre récupérer une liste d’utilisateur, et y faire un traitement est suffisamment fort pour les coupler ensemble une seule classe.
C’est malheureusement le genre de classe que j’ai pu écrire de nombreuse fois dans ma période élitiste. Et c’est aussi le genre de classe qui apparaissent de nombreuse fois dans des articles de blog pour y présenter différents principes. Mais laissez-moi vous dire que c’est over engineeré.
Toute la méthodologie y est expliquée dans Test Driven Development By Example(Kent Beck, 2002). Je vous la conseille vivement.
Le TDD pour sauver le design⌗
Comment arriver à une solution équivalente que précédemment, mais via le TDD ?
Le TDD s’applique en 3 étapes qui se répètent sans cesse.
- Écrire un test suffisant pour faire échouer la suite de test. Un échec de compilation est une situation qui valide cette étape.
- Écrire le code suffisant pour passer les tests en vert, même s’il s’agit de hardcoder la valeur attendue.
- Refactorer si nécessaire, pour éliminer le code dupliqué.
Faisons l’exercice de suivre ses 3 règles :
Pour le bien de cet article, j’ai réellement fait le TDD de bout en bout pour arriver au résultat produit dans ce chapitre.
Je commence par faire une liste des choses à faire
- Un User doit être considéré comme adulte en Belgique s’il a 18 ans ou plus.
- Un User ne doit pas être considéré comme adulte en Belgique s’il a strictement moins de 18 ans.
Ensuite écrire un test sur la première spécification
Aussi, l’application est internationale. Selon le pays, la majorité peut être à 21 ans. Mais pour cet exemple, je vais me limiter à 18 ans dans le cas de la Belgique. En plus les utilisateurs sont éphémères. C’est ok de stocker l’âge des utilisateurs au lieu de la date de naissance.
public class UserIsAdultTest
{
[Fact]
public void UserWithMoreThan18ShouldBeAdult()
{
var user = new User(1, 18);
var isAdultBelgium = new IsAdultBelgium(user);
Assert.True(isAdultBelgium.IsAdult());
}
}
Pendant mon exercice, j’ai en réalité fait plusieurs étapes intermédiaires. J’ai écrit la première ligne, fait l’implémentation, la suivante, fait l’implémentation… Mais cet article serait interminable si je devais retracer toutes les étapes. Mais c’est exactement cette pratique que j’ai faite durant la rédaction de cet article.
Le code ne compile pas, écrivons juste ce qu’il faut pour le compiler.
public class User
{
public int Id { get; set; }
public int Age { get; set; }
public User(int id, int age)
{
Id = id;
Age = age;
}
}
public class IsAdultBelgium
{
private User user;
public IsAdultBelgium(User user)
{
this.user = user;
}
public bool IsAdult()
{
return true;
}
}
À ce stade, les spécifications 1 et 2 sont respectés. Mais pas la 3. Écrivons un test correspondant :
[Fact]
public void UserWithLessThan18ShouldNotBeAdult()
{
var user = new User(1, 17);
var isAdultBelgium = new IsAdultBelgium(user);
Assert.False(isAdultBelgium.IsAdult);
}
Et rectifions la classe
public class IsAdultBelgium
{
private User user;
public IsAdultBelgium(User user)
{
this.user = user;
}
public bool IsAdult()
{
return user.Age >= 18;
}
}
Cette méthode d’avoir une seule solution possible pour valider plusieurs tests le plus vite possible s’appelle la triangulation.
À cette étape, les tests méritent un petit refactoring. Les 2 tests ont du code extrêmement similaire
[Theory]
[InlineData(18, true)]
[InlineData(17, false)]
public void UserShouldGetIsAdultIfMoreThan18(int age, bool expectedResult)
{
var user = new User(1, age);
var isAdultBelgium = new IsAdultBelgium(user);
Assert.Equal(expectedResult, isAdultBelgium.IsAdult());
}
Note sur le résultat⌗
Pendant la rédaction d’un article de blog, il m’aurait été très facile de tricher et faire l’implémentation juste pour matcher ma critique plus tôt. Mais je vous assure avoir fait l’effort de faire la boucle de TDD tel que les règles l’impose.
Le résultat est simple à cause d’une application d’un article de blog, loin d’un projet réel. L’intérêt du TDD se ressent mieux dans des applications plus complexes et plus volumineuses.
L’usage d’une classe externe IsAdultBelgium au lieu d’introduire une notion d’adulte dans la classe User y est ici volontaire, car il est connu à l’avance que selon le contexte (=le pays), la définition d’un adulte change. À une époque j’aurais introduit la notion d’adulte dans la classe User. Mais par expérience, il est plus aisé d’étendre ce genre d’évaluation via une classe externe au lieu de l’introduire dans la classe User.
Comme pour chaque article, le contenu se doit de rester limité pour y être lu rapidement.. Je peux que vous encourager à aller plus loin, via les Tests Driven Development By Example de Ken Beck qui y fait un exercice identique mais avec des exemples beaucoup plus parlant et proche du monde réel. Mais aussi Refactoring de Martin Fowler (et Kent Beck) qui vous donne des techniques pour le refactoring.
A propos de l'auteur
Je m'appelle Mathieu Scolas. Constatant que peu de blogueurs proposent du contenu personnel, constructif et nouveau, ce blog tente de compléter ce manque. Il me permet de partager des guidelines et exprimer des opinions sur le développement d'application. Dans la volonté d'éveiller les lecteurs sur différents sujets, ma façon est de comparer les bonnes pratiques avec les interprétations populaires, ou de présenter des sujets peu évoqués ailleurs. Ce blog me sert aussi de source pour exprimer au mieux des sujets qui méritent un support pour expliquer convenablement les avantages des pratiques, comment les appliquer correctement, et quelles en sont les origines. Retrouvez-moi sur Linkedin et Github.