Tutoriel pour comprendre la nécessité de Mocker pendant les tests

Renaud Humbert-Labeaumaz

En tant que Test Driven Developer et plutôt adepte de l'école de TDD de Londres, j'ai tendance à utiliser un nombre assez important de mocks quand je teste mon code. Cependant, il y a des cas où je trouve plus utile de ne pas en utiliser.

L'objectif de cet article est de décrire ces cas ainsi que la façon de tester le code sans utiliser de mocks.

Pour réagir à ce tutoriel, un espace de dialogue vous est proposé sur le forum. Commentez .

- Titre : Tutoriel pour comprendre la nécessité de Mocker pendant les tests

- Auteur : Renaud Humbert-Labeaumaz

- Parution : 14 juin 2017

- Public visé :

- Licence : Copyright ® 2017 Renaud Humbert-Labeaumaz. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Bases de données

- II. Réseau

- III. Système de fichiers

- IV. Mail

- V. Conclusion

- VI. Remerciements

I - Bases de données

Quand je traite directement avec une base de données, la seule chose qui m'importe est si le code interagit correctement avec elle ou non. À part quelques cas particuliers d'optimisation ou de protection contre les injections SQL, la requête elle-même n'a que très peu d'importance.
Je préfère donc utiliser une vraie base de données et faire des assertions directement dessus plutôt que de vérifier la requête générée.

De plus, il est plus simple de monter une base de données embarquée que de capturer la requête. En effet, il serait nécessaire de connaître l'implémentation utilisée pour accéder à la base de données et ceci est un signe que nos tests sont trop couplés à notre implémentation. Ils tomberaient donc en échec si l'on venait à en changer (passage de requêtes JDBC à un ORM, ou l'inverse par exemple).

L'exemple suivant utilise les classes EmbeddedDatabaseBuilder et JdbcTemplate de Spring pour créer et accéder à une base de données embarquée.

1. public class UserRepositoryTest {

3. @Test

4. public void should_save_user_to_database() {

5. DataSource ds = dataSource();

6. JdbcUserRepository userRepository = new JdbcUserRepository(ds);

8. userRepository.save(new User("John", "Doe"));

10. assertThat(count(ds, "John", "Doe")).isEqualTo(1);

11. }

13. private int count(DataSource ds, String firstName, String lastName) {

14. return new JdbcTemplate(ds).queryForObject(

15. "select count(*) from user where first_name = ? and last_name = ?",

16. Integer.class,

17. firstName, lastName

18. );

19. }

21. private DataSource dataSource() {

22. return new EmbeddedDatabaseBuilder()

23. .setType(EmbeddedDatabaseType.HSQL)

24. .addScript("db/sql/create-db.sql")

25. .build();

26. }

27. }

Ce test ne tombera jamais en échec tant que le comportement de la classe ne change pas, même si son implémentation change totalement.

II - Réseau

Le code qui communique à travers le réseau peut être difficile à tester en isolation. Selon moi, ceci n'est surtout pas forcément utile. En effet, le but principal est de vérifier le bon transfert d'informations à travers le réseau, quelle que soit la bibliothèque utilisée pour effectuer ce transfert.

Dans ce genre de cas, je prends souvent le parti de créer un faux serveur qui sera lancé durant mes tests afin de communiquer avec le code à tester.

L'exemple suivant décrit le test d'un client TCP qui va communiquer avec un serveur qui se contente de renvoyer ce qu'on lui envoie. Le faux serveur est représenté par la classe FakeEchoServer :

1. public class FakeEchoServer {

2. private final int port;

3. private volatile boolean running;

5. public FakeEchoServer(int port) {

6. this.port = port;

7. }

9. public void run() throws IOException {

10. ServerSocket serverSocket = new ServerSocket(port);

11. running = true;

13. new Thread(() -> {

14. while (running) {

15. try {

16. Socket socket = serverSocket.accept();

17. try (DataInputStream reader = getReader(socket); DataOutputStream writer = getWriter(socket)) {

18. writer.writeUTF(reader.readUTF());

19. writer.flush();

20. }

21. } catch (IOException e) {

22. throw new RuntimeException(e);

23. }

24. }

25. }).start();

26. }

28. private DataOutputStream getWriter(Socket socket) throws IOException {

29. return new DataOutputStream(socket.getOutputStream());

30. }

32. private DataInputStream getReader(Socket socket) throws IOException {

33. return new DataInputStream(socket.getInputStream());

34. }

36. public void stop() {

37. running = false;

38. }

39. }

Le test, quant à lui, va instancier ce faux serveur et appeler la méthode à tester du client.

1. public class EchoClientTest {

3. private static final int PORT = 9999;

5. @Test

6. public void should_send_message_and_get_response() throws IOException {

7. FakeEchoServer server = new FakeEchoServer(PORT);

8. server.run();

10. String echoResult = new EchoClient("localhost", PORT).sendAndReceive("Hello");

11. assertThat(echoResult).isEqualTo("Hello");

13. server.stop();

14. }

15. }

Le faux serveur devra évoluer au fur et à mesure que le comportement du client changera, mais ne sera pas modifié par un changement de bibliothèque ou de façon d'utiliser le réseau.

III - Système de fichiers

Lorsque l'on doit interagir avec un système de fichiers, on le fait souvent via des appels statiques à des classes comme FileUtils de commons-io ou Files du JDK ou bien des appels à new File(). Quoi qu'il en soit, il est difficile de mocker ce genre d'appels pour faire des assertions pertinentes.

Une solution pourrait être de ne pas mocker du tout et de récupérer le fichier manipulé directement depuis le système de fichiers, mais il faudrait alors penser à bien nettoyer le disque avant chaque test et faire aussi attention aux droits dans le dossier à utiliser.

Fort heureusement, JUnit propose une Rule dédiée à ce genre de cas. Il s'agit de TemporaryFolder qui va créer un dossier temporaire dans lequel il sera possible de travailler. À la fin du test, ce dossier sera supprimé.

Le test d'une classe manipulant des fichiers se présente alors sous la forme suivante :

1. public class FileStoreTest {

3. @Rule

4. public TemporaryFolder temporaryFolder = new TemporaryFolder();

6. @Test

7. public void should_store_file_in_the_user_folder() throws IOException, URISyntaxException {

9. File rootFolder = temporaryFolder.newFolder();

10. byte[] content = Files.readAllBytes(Paths.get(ClassLoader.getSystemResource("files/avatar.png").toURI()));

12. new FileStore(rootFolder).store(new UserFile("avatar.png", content, "john"));

14. File expectedFile = new File(rootFolder, "john/files/avatar.png");

15. assertThat(expectedFile).exists().hasBinaryContent(content);

16. }

17. }

Cette technique a pour inconvénient de supprimer le dossier temporaire après chaque test. Ainsi, si le test est en échec il ne sera pas forcément aisé de comprendre pourquoi, car le dossier aura disparu. Si ce besoin se fait sentir, il peut être plus pertinent de créer un dossier temporaire à la main dans une méthode de setup (annotée @Before) au lieu d'utiliser la @Rule.

IV - Mail

De nombreuses applications envoient des mails. Les stratégies généralement utilisées pour les tester se divisent en deux catégories :

- mocker la classe responsable de l'envoi des mails ;

- utiliser une adresse mail « poubelle » qui va recevoir tous les mails et les vérifier manuellement.

Aucune de ces deux méthodes n'est optimale, car dans le premier cas, on ne sait pas ce qui est réellement envoyé par mail et cela rend le test très couplé à l'implémentation et dans le deuxième cas, le test n'est que très difficilement automatisable.

La solution optimale serait de pouvoir instancier un vrai serveur SMTP et de pouvoir récupérer les mails qu'il a reçus. C'est ce que propose la bibliothèque SubEtha SMTP.

Le test d'une classe permettant d'envoyer des mails va donc ressembler à ceci (on utilise également la classe JavaMailSender de Spring pour envoyer les mails) :

1. public class MailSenderTest {

3. private static final int SMTP_PORT = 5555;

5. @Test

6. public void should_send_mail_to_customer_address() throws MessagingException, IOException {

7. Wiser wiser = new Wiser(SMTP_PORT);

8. wiser.start();

10. new MailSender(getJavaMailSender(), "contact@acme.org").send(

11. new CustomerMail("john@acme.org", "Hey you!", "How are you?")

12. );

14. List<WiserMessage> messages = wiser.getMessages();

15. assertThat(messages).hasSize(1);

17. MimeMessage mimeMessage = messages.get(0).getMimeMessage();

18. assertThat(mimeMessage.getSubject()).isEqualTo("Hey you!");

19. assertThat(mimeMessage.getFrom()[0].toString()).isEqualTo("contact@acme.org");

20. assertThat(mimeMessage.getRecipients(Message.RecipientType.TO)[0].toString()).isEqualTo("john@acme.org");

21. assertThat(mimeMessage.getContent().toString()).isEqualTo("How are you?\r\n");

23. wiser.stop();

24. }

26. private JavaMailSender getJavaMailSender() {

27. JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl();

28. javaMailSender.setHost("localhost");

29. javaMailSender.setPort(SMTP_PORT);

30. javaMailSender.setProtocol("smtp");

31. return javaMailSender;

32. }

33. }

V - Conclusion

Cette liste n'est bien sûr pas exhaustive est peut être complétée à l'infini. Cependant, une tendance émerge. En effet, on se rend compte que les zones où le mock peut être compliqué, voire contreproductif, se situent au niveau des interfaces avec le monde extérieur. Il n'est pas toujours important de tester la manière dont le code va communiquer avec l'extérieur, ni même le format exact des données envoyées. La plupart du temps, il est seulement important de savoir si le système avec lequel le code interagit a correctement compris le message ou non. Dans les autres cas (vérification de protocole, optimisations de requêtes, etc.), il est plus pertinent d'utiliser des mocks pour analyser le contenu exact de ce qui est envoyé.

VI - Remerciements

Cet article a été publié avec l'aimable autorisation de Zenika et de Renaud Humbert-Labeaumaz.

Nous tenons à remercier Claude Leloup pour sa relecture orthographique et Winjerome pour la mise au gabarit.