Code de test unitaire avec une dépendance de système de fichiers

138

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

  1. Décompressez le fichier.
  2. Trouvez une DLL spécifique parmi les fichiers décompressés.
  3. Chargez cette DLL par réflexion et invoquez une méthode dessus.

Je voudrais tester ce composant unitaire.

Je suis tenté d'écrire du code qui traite directement du 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 de 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 alimenter en double test (mocks) la méthode DoIt. Mais à quel prix? J'ai maintenant dû définir 3 nouvelles interfaces juste pour rendre cela testable. Et qu'est-ce que je teste exactement? Je teste que ma fonction DoIt interagit correctement avec ses dépendances. Il ne teste pas que le fichier zip a été décompressé correctement, etc.

Je n'ai plus l'impression de tester la fonctionnalité. J'ai l'impression que je teste simplement les interactions de classe.

Ma question est la suivante : quelle est la bonne façon de tester unitaire quelque chose qui dépend du système de fichiers?

edit J'utilise .NET, mais le concept pourrait également appliquer du code Java ou natif.

Judah Gabriel Himango
la source
8
Les gens disent de ne pas écrire dans le système de fichiers dans un test unitaire, car si vous êtes tenté d'écrire dans le système de fichiers, vous ne comprenez pas ce qui constitue un test unitaire. Un test unitaire interagit généralement avec un seul objet réel (l'unité testée) et toutes les autres dépendances sont simulées et transmises. La classe de test se compose alors de méthodes de test qui valident les chemins logiques via les méthodes de l'objet et UNIQUEMENT les chemins logiques dans l'unité sous test.
Christopher Perry
1
dans votre situation, la seule partie qui nécessite un test unitaire serait myDll.InvokeSomeSpecialMethod();où vous vérifieriez qu'il fonctionne correctement dans les situations de réussite et d'échec, donc je ne ferais pas de test unitaire, DoItmais DllRunner.Runcela dit, une mauvaise utilisation d'un test UNIT pour vérifier que l'ensemble du processus fonctionne serait une mauvaise utilisation acceptable et comme ce serait un test d'intégration masquant un test unitaire, les règles de test unitaire normales n'ont pas besoin d'être appliquées aussi strictement
MikeT

Réponses:

47

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 indésirables. Plus précisément, assurez-vous que vous nettoyez après vous-même - supprimez tous les fichiers temporaires que vous avez créés - et que vous n'écrasez pas accidentellement un fichier existant qui portait le même nom de fichier qu'un fichier temporaire que vous utilisiez. Utilisez toujours des chemins relatifs et non des chemins absolus.

Ce serait également une bonne idée de placer chdir()dans un répertoire temporaire avant d'exécuter votre test, et de chdir()revenir ensuite.

Adam Rosenfield
la source
27
+1, notez cependant que cela chdir()concerne l'ensemble du processus, vous risquez donc de ne pas pouvoir exécuter vos tests en parallèle, si votre cadre de test ou une version future de celui-ci le prend en charge.
69

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

Vous avez frappé le clou juste sur sa tête. Ce que vous voulez tester, c'est la logique de votre méthode, 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 elles-mêmes car elles fournissent des abstractions sur lesquelles vous pouvez programmer, plutôt que de vous fier implicitement ou explicitement à une implémentation concrète.

andreas buykx
la source
12
La DoItfonction testable comme indiqué n'a même pas besoin d'être testée. Comme l'intervenant l'a souligné à juste titre, il ne reste plus rien d'important à tester. Maintenant, c'est la mise en œuvre de IZipper, IFileSystemet IDllRunnercela doit être testé, mais ce sont précisément les choses qui ont été simulées pour le test!
Ian Gold du
56

Votre question expose l'une des parties les plus difficiles des tests pour les développeurs qui ne font que commencer:

"Qu'est-ce que je teste?"

Votre exemple n'est pas très intéressant car il rassemble simplement certains appels d'API, donc si vous deviez écrire un test unitaire pour celui-ci, vous finiriez par affirmer que les méthodes ont été appelées. Des tests comme celui-ci associent étroitement les détails de votre implémentation au test. C'est mauvais car maintenant vous devez changer le test à chaque fois que vous changez les détails d'implémentation de votre méthode parce que changer les détails d'implémentation casse vos tests!

Avoir de mauvais tests est en fait pire que de ne pas avoir de tests 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 simulations, il n'y a pas de logique dans la méthode à tester. Si vous tentez un test unitaire pour cela, cela pourrait ressembler à 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 d'implémentation de votre DoIt()méthode dans un test. Bon maintien.

Lorsque vous écrivez des tests, vous voulez tester le QUOI et non le COMMENT . Voir Black Box Testing pour en savoir plus.

Le WHAT est le nom de votre méthode (ou du moins il devrait l'être). Le HOW sont tous les petits détails d'implémentation qui vivent à l'intérieur de votre méthode. De bons tests vous permettent d'échanger le COMMENT sans casser le QUOI .

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

"Si je change les détails de mise en œuvre de cette méthode (sans modifier le marché public), est-ce que cela va casser mon (mes) test (s)?"

Si la réponse est oui, vous testez le COMMENT et non le QUOI .

Pour répondre à votre question spécifique sur le test du code avec les dépendances du système de fichiers, disons que vous aviez quelque chose d'un peu plus intéressant avec un fichier et que vous vouliez enregistrer le contenu encodé en Base64 d'un byte[]dans un fichier. Vous pouvez utiliser des flux pour cela pour tester que votre code fait la bonne chose sans avoir à vérifier 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 ByteArrayOutputStreammais dans l'application (par injection de dépendance) la vraie StreamFactory (peut - être appelé FileStreamFactory) retournerait FileOutputStreamde outputStream()et écrirait à un File.

Ce qui était intéressant à propos de la writeméthode ici, c'est qu'elle écrivait le contenu encodé en Base64, c'est donc ce que nous avons testé. Pour votre DoIt()méthode, cela serait mieux testé avec un test d'intégration .

Christopher Perry
la source
1
Je ne suis pas sûr d'être d'accord avec votre message ici. Êtes-vous en train de dire qu'il n'est pas nécessaire de tester ce type de méthode unitaire? Donc, vous dites essentiellement que TDD est mauvais? Comme si vous faisiez du TDD, vous ne pouvez pas écrire cette méthode sans écrire d'abord un test. Ou devez-vous vous fier à l'intuition que votre méthode ne nécessitera pas de test? La raison pour laquelle TOUS les frameworks de tests unitaires incluent une fonction de «vérification», c'est qu'il est possible de l'utiliser. "C'est mauvais car maintenant vous devez changer le test à chaque fois que vous changez les détails d'implémentation de votre méthode" ... Bienvenue dans le monde des tests unitaires.
Ronnie
2
Vous êtes censé tester le CONTRAT d'une méthode, pas sa mise en œuvre. Si vous devez changer votre test à chaque fois que votre implémentation de ce contrat change, alors vous êtes dans une période horrible pour maintenir à la fois la base de code de votre application et la base de code de test.
Christopher Perry
@Ronnie appliquer aveuglément des tests unitaires n'est pas utile. Il existe des projets de nature très variée et les tests unitaires ne sont pas efficaces dans tous. À titre d'exemple, je travaille sur un projet dans lequel 95% du code concerne les effets secondaires (notez que cette nature lourde d'effets secondaires est par exigence , c'est une complexité essentielle, pas accidentelle , car elle rassemble des données à partir de une grande variété de sources avec état et le présente avec très peu de manipulation, donc il n'y a pratiquement pas de logique pure). Les tests unitaires ne sont pas efficaces ici, les tests d'intégration le sont.
Vicky Chijwani
Les effets secondaires doivent être poussés jusqu'aux bords de votre système, ils ne doivent pas être entrelacés à travers les couches. Sur les bords, vous testez les effets secondaires, qui sont des comportements. Partout ailleurs, vous devriez essayer d'avoir des fonctions pures sans effets secondaires, qui sont faciles à tester et faciles à raisonner, à réutiliser et à composer.
Christopher Perry
24

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 le design plus propre et meilleur, c'est génial, mais je pense que ce n'est souvent pas le cas.

Mon point de vue est que vos tests unitaires feraient tout ce qu'ils peuvent, ce qui peut ne pas être une couverture à 100%. En fait, ce n'est peut-être que 10%. Le fait est que vos tests unitaires doivent être rapides et ne comporter aucune dépendance externe. Ils peuvent 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 probablement utilisant le même cadre de test unitaire) qui peuvent avoir des dépendances externes et tester des scénarios de bout en bout tels que ceux-ci.

Lors de la mesure de la couverture de code, je mesure à la fois des tests unitaires et d'intégration.

Kent Boogaart
la source
5
Ouais, je t'entends. Il y a ce monde bizarre que vous atteignez où vous vous êtes tellement découplé, qu'il ne vous reste plus que des invocations de méthodes sur des objets abstraits. Duvet aéré. Lorsque vous atteignez ce point, vous n'avez pas l'impression de tester vraiment quelque chose de réel. Vous testez simplement les interactions entre les classes.
Judah Gabriel Himango
6
Cette réponse est erronée. Les tests unitaires ne ressemblent pas au glaçage, mais plutôt au sucre. C'est cuit dans le gâteau. Cela fait partie de l'écriture de votre code ... une activité de conception. Par conséquent, vous ne «polluez» jamais votre code avec quoi que ce soit qui «faciliterait les tests», car les tests sont ce qui vous facilite l'écriture de votre code. 99% du temps, un test est difficile à écrire parce que le développeur a écrit le code avant le test et a fini par écrire du mauvais code non testable
Christopher Perry
1
@Christopher: pour prolonger votre analogie, je ne veux pas que mon gâteau finisse par ressembler à une tranche de vanille juste pour pouvoir utiliser du sucre. Tout ce que je préconise, c'est le pragmatisme.
Kent Boogaart
1
@Christopher: votre bio dit tout: "Je suis un fanatique du TDD". Moi, en revanche, je suis pragmatique. Je fais du TDD là où ça va et pas là où ça ne va pas - rien dans ma réponse ne suggère que je ne fais pas de TDD, même si vous semblez le penser. Et que ce soit TDD ou non, je n'introduirai pas de grandes quantités de complexité pour faciliter les tests.
Kent Boogaart
3
@ChristopherPerry Pouvez-vous expliquer comment résoudre le problème initial de l'OP alors de manière TDD? Je rencontre ça tout le temps; J'ai besoin d'écrire une fonction dont le seul but est d'effectuer une action avec une dépendance externe, comme dans cette question. Donc, même dans le scénario d'écriture du test en premier, quel serait ce test?
Dax Fohl
8

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

Si vos tests d'intégration prennent trop de temps à s'exécuter, séparez-les afin qu'ils ne s'exécutent pas 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 fournir suffisamment de valeur. Vous voulez vraiment tester la décompression du fichier ici, pas seulement vérifier que vous appelez les bonnes méthodes.

JC.
la source
La fréquence à laquelle ils courent est peu préoccupant; nous utilisons un serveur d'intégration continue qui les exécute automatiquement pour nous. Nous ne nous soucions pas vraiment du temps qu'ils prennent. Si «combien de temps à exécuter» n'est pas un problème, y a-t-il une raison de faire la distinction entre les tests unitaires et d'intégration?
Judah Gabriel Himango
4
Pas vraiment. Mais si les développeurs veulent exécuter rapidement tous les tests unitaires localement, c'est bien d'avoir un moyen simple de le faire.
JC.
6

Une façon serait d'écrire la méthode unzip pour prendre InputStreams. Ensuite, le test unitaire pourrait construire un tel InputStream à partir d'un tableau d'octets en utilisant ByteArrayInputStream. Le contenu de ce tableau d'octets peut être une constante dans le code de test unitaire.

nsayer
la source
Ok, donc cela permet l'injection du flux. Injection de dépendance / IOC. Que diriez-vous de la partie de la décompression du flux dans des fichiers, du chargement d'une dll parmi ces fichiers et de l'appel d'une méthode dans cette dll?
Judah Gabriel Himango
3

Cela semble être plus un test d'intégration car vous dépendez d'un détail spécifique (le système de fichiers) qui pourrait changer, en théorie.

Je résumerais le code qui traite du système d'exploitation dans son propre module (classe, assembly, jar, peu importe). Dans votre cas, vous souhaitez charger une DLL spécifique si elle est trouvée, alors créez une interface IDllLoader et une classe DllLoader. Demandez à votre application d'acquérir la DLL du DllLoader en utilisant l'interface et de tester cela .. vous n'êtes pas responsable du code de décompression après tout, n'est-ce pas?

robinet
la source
2

En supposant que les «interactions du système de fichiers» sont bien testées dans le cadre lui-même, créez votre méthode pour travailler avec les flux et testez-la. Ouvrir un FileStream et le transmettre à la méthode peut être omis de vos tests, car FileStream.Open est bien testé par les créateurs du framework.

Sunny Milenov
la source
Vous et nsayer avez essentiellement la même suggestion: faire fonctionner mon code avec des flux. Que diriez-vous de la partie sur la décompression du contenu du flux dans des fichiers dll, l'ouverture de cette dll et l'appel d'une fonction? Que feriez-vous là-bas?
Judah Gabriel Himango
3
@JudahHimango. Ces pièces ne peuvent pas nécessairement être testées. Vous ne pouvez pas tout tester. Abstenez les composants non testables dans leurs propres blocs fonctionnels et supposez qu'ils fonctionneront. Lorsque vous rencontrez un bogue avec le fonctionnement de ce bloc, concevez un test pour celui-ci, et le tour est joué. Les tests unitaires ne signifient PAS que vous devez tout tester. Une couverture de code à 100% n'est pas réaliste dans certains scénarios.
Zoran Pavlovic
1

Vous ne devez pas tester l'interaction de classe et l'appel de fonction. à la place, vous devriez envisager des tests d'intégration. Testez le résultat requis et non l'opération de chargement de fichier.

Aide Dror
la source
1

Pour le test unitaire, je vous suggère d'inclure le fichier de test dans votre projet (fichier EAR ou équivalent) puis d'utiliser un chemin relatif dans les tests unitaires, c'est-à-dire "../testdata/testfile".

Tant que votre projet est correctement exporté / importé, votre test unitaire devrait fonctionner.

James Anderson
la source
0

Comme d'autres l'ont dit, le premier est parfait comme test d'intégration. Le second teste uniquement ce que la fonction est censée faire réellement, ce qui est tout ce qu'un test unitaire devrait faire.

Comme indiqué, le deuxième exemple semble un peu inutile, mais il vous donne la possibilité de tester la façon dont la fonction répond aux erreurs dans l'une des étapes. Vous n'avez aucune vérification d'erreur dans l'exemple, mais dans le système réel, vous pouvez avoir, et l'injection de dépendances vous permettrait de tester toutes les réponses à toutes les erreurs. Ensuite, le coût en aura valu la peine.

David Sykes
la source