134 votes

Test unitaire du code avec une dépendance du système de fichiers

J'écris un composant qui, étant donné un fichier ZIP, doit :

  1. Décompressez le fichier.
  2. Trouver une dll spécifique parmi les fichiers dézippés.
  3. Chargez cette dll par réflexion et invoquez une méthode sur elle.

J'aimerais tester ce composant à l'unité.

Je suis tenté d'écrire un code qui traite directement avec le système de fichiers :

void DoIt()
{
   Zip.Unzip(theZipFile, "C:\\foo\\Unzipped");
   System.IO.File myDll = File.Open("C:\\foo\\Unzipped\\SuperSecret.bar");
   myDll.InvokeSomeSpecialMethod();
}

Mais les gens disent souvent : "N'écrivez pas de tests unitaires qui reposent sur le système de fichiers, la base de données, le réseau, etc.".

Si je devais écrire ceci d'une manière conviviale pour les tests unitaires, je suppose que cela ressemblerait à ceci :

void DoIt(IZipper zipper, IFileSystem fileSystem, IDllRunner runner)
{
   string path = zipper.Unzip(theZipFile);
   IFakeFile file = fileSystem.Open(path);
   runner.Run(file);
}

Yay ! Maintenant, c'est testable ; je peux introduire des doubles de test (mocks) dans la méthode DoIt. Mais à quel prix ? J'ai maintenant dû définir 3 nouvelles interfaces juste pour rendre cette méthode testable. Et qu'est-ce que je teste, exactement ? Je teste que ma fonction DoIt interagit correctement avec ses dépendances. Elle ne teste pas que le fichier zip a été décompressé correctement, etc.

Je n'ai plus l'impression de tester des fonctionnalités. J'ai l'impression de tester les interactions entre les classes.

Ma question est la suivante Le test unitaire : quelle est la bonne façon de tester un élément qui dépend du système de fichiers ?

modifier J'utilise .NET, mais le concept pourrait aussi s'appliquer à Java ou au code natif.

66voto

andreas buykx Points 4710

Bravo ! Maintenant, c'est testable ; je peux introduire des doubles de test (mocks) dans la méthode DoIt. Mais à quel prix ? J'ai maintenant dû définir 3 nouvelles interfaces juste pour rendre cette méthode testable. Et qu'est-ce que je teste, exactement ? Je teste que ma fonction DoIt interagit correctement avec ses dépendances. Elle ne teste pas que le fichier zip a été décompressé correctement, etc.

Vous avez mis le doigt sur le problème. Ce que vous voulez tester, c'est la logique de votre méthode, et pas nécessairement si un vrai fichier peut être adressé. Vous n'avez pas besoin de tester (dans ce test unitaire) si un fichier est correctement décompressé, votre méthode prend cela pour acquis. Les interfaces sont précieuses en soi car elles fournissent des abstractions contre lesquelles vous pouvez programmer, plutôt que de dépendre implicitement ou explicitement d'une implémentation concrète.

53voto

Christopher Perry Points 7972

Votre question met en évidence l'une des parties les plus difficiles des tests pour les développeurs qui débutent dans ce domaine :

"Qu'est-ce que je teste, bon sang ?"

Votre exemple n'est pas très intéressant parce qu'il ne fait que coller quelques appels d'API ensemble. Si vous deviez écrire un test unitaire pour cet exemple, vous finiriez par affirmer que les méthodes ont été appelées. Les tests de ce type couplent étroitement les détails de votre implémentation au test. C'est mauvais parce que maintenant vous devez changer le test chaque fois que vous changez les détails de l'implémentation de votre méthode parce que changer les détails de l'implémentation casse votre (vos) test(s) !

Avoir de mauvais tests est en fait pire que de ne pas en avoir du tout.

Dans votre exemple :

void DoIt(IZipper zipper, IFileSystem fileSystem, IDllRunner runner)
{
   string path = zipper.Unzip(theZipFile);
   IFakeFile file = fileSystem.Open(path);
   runner.Run(file);
}

Bien que vous puissiez passer des mocks, il n'y a pas de logique dans la méthode à tester. Si vous deviez tenter un test unitaire pour ceci, il pourrait ressembler à quelque chose comme ceci :

// Assuming that zipper, fileSystem, and runner are mocks
void testDoIt()
{
  // mock behavior of the mock objects
  when(zipper.Unzip(any(File.class)).thenReturn("some path");
  when(fileSystem.Open("some path")).thenReturn(mock(IFakeFile.class));

  // run the test
  someObject.DoIt(zipper, fileSystem, runner);

  // verify things were called
  verify(zipper).Unzip(any(File.class));
  verify(fileSystem).Open("some path"));
  verify(runner).Run(file);
}

Félicitations, vous avez essentiellement copié-collé les détails de l'implémentation de votre DoIt() dans un test. Bonne maintenance.

Lorsque vous écrivez des tests, vous voulez tester le QUOI et non le COMMENT . Voir Test boîte noire pour plus.

El QUOI est le nom de votre méthode (ou du moins il devrait l'être). Le site COMMENT sont tous les petits détails d'implémentation qui se trouvent dans votre méthode. De bons tests vous permettent d'échanger les COMMENT sans rompre le QUOI .

Pensez-y de cette façon, demandez-vous :

"Si je change les détails de l'implémentation de cette méthode (sans modifier le contrat public), est-ce que cela va casser mon ou mes tests ?".

Si la réponse est oui, vous êtes en train de tester la COMMENT et non le QUOI .

Pour répondre à votre question spécifique sur le test de code avec des dépendances du système de fichiers, disons que vous avez quelque chose d'un peu plus intéressant avec un fichier et que vous voulez sauvegarder le contenu encodé en Base64 d'un fichier de type byte[] vers un fichier. Vous pouvez utiliser des flux pour tester que votre code fait la bonne chose sans avoir à vérifier les éléments suivants comment il le fait. Un exemple pourrait être quelque chose comme ceci (en Java) :

interface StreamFactory {
    OutputStream outStream();
    InputStream inStream();
}

class Base64FileWriter {
    public void write(byte[] contents, StreamFactory streamFactory) {
        OutputStream outputStream = streamFactory.outStream();
        outputStream.write(Base64.encodeBase64(contents));
    }
}

@Test
public void save_shouldBase64EncodeContents() {
    OutputStream outputStream = new ByteArrayOutputStream();
    StreamFactory streamFactory = mock(StreamFactory.class);
    when(streamFactory.outStream()).thenReturn(outputStream);

    // Run the method under test
    Base64FileWriter fileWriter = new Base64FileWriter();
    fileWriter.write("Man".getBytes(), streamFactory);

    // Assert we saved the base64 encoded contents
    assertThat(outputStream.toString()).isEqualTo("TWFu");
}

Le test utilise un ByteArrayOutputStream mais dans l'application (en utilisant l'injection de dépendances), la véritable StreamFactory (peut-être appelée FileStreamFactory) retournerait FileOutputStream de outputStream() et écrirait à un File .

Ce qui était intéressant dans le write La méthode utilisée ici est qu'elle écrit le contenu en code Base64, c'est donc ce que nous avons testé. Pour votre DoIt() il serait plus approprié de tester cette méthode avec une test d'intégration .

43voto

Adam Rosenfield Points 176408

Il n'y a vraiment rien de mal à cela, c'est juste une question de savoir si vous l'appelez un test unitaire ou un test d'intégration. Vous devez simplement vous assurer que si vous interagissez avec le système de fichiers, il n'y a pas d'effets secondaires involontaires. Plus précisément, assurez-vous que vous faites le ménage après votre passage - supprimez tous les fichiers temporaires que vous avez créés - et que vous n'écrasez pas accidentellement un fichier existant qui porte le même nom qu'un fichier temporaire que vous utilisiez. Utilisez toujours des chemins relatifs et non des chemins absolus.

Il serait également judicieux de chdir() dans un répertoire temporaire avant d'exécuter votre test, et chdir() retour après.

23voto

Kent Boogaart Points 97432

Je suis réticent à polluer mon code avec des types et des concepts qui n'existent que pour faciliter les tests unitaires. Bien sûr, si cela rend la conception plus propre et meilleure, alors tant mieux, mais je pense que ce n'est pas souvent le cas.

Selon moi, vos tests unitaires devraient faire tout ce qu'ils peuvent, ce qui n'est peut-être pas une couverture à 100 %. En fait, il se peut que ce ne soit que 10%. Le fait est que vos tests unitaires doivent être rapides et ne pas avoir de dépendances externes. Ils pourraient tester des cas comme "cette méthode lève une ArgumentNullException lorsque vous passez null pour ce paramètre".

J'ajouterais ensuite des tests d'intégration (également automatisés et utilisant probablement le même cadre de tests unitaires) qui peuvent avoir des dépendances externes et tester des scénarios de bout en bout tels que ceux-ci.

Lorsque je mesure la couverture du code, je mesure à la fois les tests unitaires et les tests d'intégration.

8voto

JC. Points 3564

Il n'y a rien de mal à frapper le système de fichiers, considérez simplement cela comme un test d'intégration plutôt qu'un test unitaire. Je remplacerais le chemin codé en dur par un chemin relatif et créerais un sous-dossier TestData pour contenir les zips des tests unitaires.

Si vos tests d'intégration sont trop longs à exécuter, séparez-les pour qu'ils ne soient pas exécutés aussi souvent que vos tests unitaires rapides.

Je suis d'accord, parfois je pense que les tests basés sur l'interaction peuvent causer trop de couplage et finissent souvent par ne pas apporter assez de valeur. Vous voulez vraiment tester la décompression du fichier ici et pas seulement vérifier que vous appelez les bonnes méthodes.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X