J'écris un composant qui, étant donné un fichier Zip, doit:
Je voudrais tester unitaire ce composant.
Je suis tenté d'écrire du 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 alimenter en test double (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é correctement décompressé, etc.
Je n'ai plus l'impression de tester la fonctionnalité. J'ai l'impression de ne tester que les interactions de classe.
Ma question est la suivante: quelle est la bonne façon de tester un élément qui dépend du système de fichiers?
edit J'utilise .NET, mais le concept pourrait s'appliquer Java ou code natif aussi.
Il n'y a vraiment rien de mal à cela, c'est juste une question de savoir si vous appelez cela 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 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 se trouvait avoir 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 chdir()
dans un répertoire temporaire avant d'exécuter votre test, et de chdir()
de retour ensuite.
Yay! Maintenant, c'est testable; Je peux alimenter en test double (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é correctement décompressé, etc.
Vous avez frappé le clou directement 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 tient cela pour acquis. Les interfaces sont précieuses en elles-mêmes car elles fournissent des abstractions contre lesquelles vous pouvez programmer, plutôt que de vous fier implicitement ou explicitement à une implémentation concrète.
Votre question expose l'une des parties les plus difficiles des tests pour les développeurs qui viennent de s'y lancer:
"Que diable dois-je tester?"
Votre exemple n'est pas très intéressant car il colle simplement certains appels d'API, donc si vous deviez écrire un test unitaire, vous finiriez par affirmer que les méthodes ont été appelées. Des tests comme celui-ci couplent étroitement vos détails d'implémentation au test. C'est mauvais parce que maintenant vous devez changer le test à chaque fois que vous modifiez les détails d'implémentation de votre méthode car changer les détails d'implémentation rompt 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 aucune logique dans la méthode à tester. Si vous tentiez 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 méthode DoIt()
dans un test. Bon maintien.
Lorsque vous écrivez des tests, vous voulez tester le [~ # ~] ce que [~ # ~] et non le [~ # ~] comment [~ # ~]. Voir Black Box Testing pour en savoir plus.
Le [~ # ~] quel [~ # ~] est le nom de votre méthode (ou du moins il devrait l'être). Les [~ # ~] comment [~ # ~] 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 modifie les détails de mise en œuvre de cette méthode (sans modifier le contrat public), est-ce que cela cassera mes tests?"
Si la réponse est oui, vous testez le [~ # ~] comment [~ # ~] et non le [~ # ~] quoi [~ # ~] .
Pour répondre à votre question spécifique sur le test de 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 ça (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) le vrai StreamFactory (peut-être appelé FileStreamFactory) retournerait FileOutputStream
de outputStream()
et écrirait dans un File
.
Ce qui était intéressant à propos de la méthode write
ici, c'est qu'elle écrivait le contenu encodé en Base64, c'est donc pour cela que nous avons testé. Pour votre méthode DoIt()
, ce serait mieux testé avec un test d'intégration .
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, alors super, mais je pense que ce n'est souvent pas le cas.
Selon moi, vos tests unitaires en feraient autant qu'ils le 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 sans 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 comme ceux-ci.
Lors de la mesure de la couverture du code, je mesure les tests unitaires et d'intégration.
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 à 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 les interactions peuvent provoquer trop de couplage et finissent souvent par ne pas fournir suffisamment de valeur. Vous voulez vraiment tester la décompression du fichier ici et pas seulement vérifier que vous appelez les bonnes méthodes.
Une façon serait d'écrire la méthode de décompression pour prendre InputStreams. Ensuite, le test unitaire pourrait construire un tel InputStream à partir d'un tableau d'octets à l'aide de ByteArrayInputStream. Le contenu de ce tableau d'octets pourrait être une constante dans le code de test unitaire.
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 de l'OS dans son propre module (classe, Assembly, jar, peu importe). Dans votre cas, vous voulez charger un DLL si trouvé, alors créez une interface IDllLoader et une classe DllLoader. Demandez à votre application d'acquérir le DLL à partir du DllLoader en utilisant l'interface et tester que .. vous n'êtes pas responsable du code de décompression après tout non?
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 cela. L'ouverture d'un FileStream et sa transmission à la méthode peuvent être laissées en dehors de vos tests, car FileStream.Open est bien testé par les créateurs du framework.
Vous ne devez pas tester l'interaction de classe et l'appel de fonction. vous devriez plutôt envisager des tests d'intégration. Testez le résultat requis et non l'opération de chargement de fichier.
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.
Comme d'autres l'ont dit, le premier est un bon test d'intégration. Le second teste uniquement ce que la fonction est censée faire, ce qui est tout ce qu'un test unitaire devrait faire.
Comme illustré, 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épendance vous permettrait de tester toutes les réponses à toutes les erreurs. Ensuite, le coût en aura valu la peine.