La politique de notre projet est d'écrire des tests unitaires pour des classes uniques uniquement. Toutes les dépendances sont moquées. Récemment, nous avons remarqué que cette approche nous rend vulnérables dans de tels cas:
À l'origine, la classe A
ressemble à ceci:
class A(val b: B) {
fun doSomething() {
b.doSomethingElse()
b.doSomethingElse2()
}
}
Et cette classe A
est couverte de tests unitaires. Ensuite, en raison de nouvelles exigences, la classe B
passe par le refactoring et se cache derrière une interface de sorte qu'il est techniquement possible que la classe A
obtienne un comportement différent en fonction d'un scénario.
Le problème est que maintenant, quand nous voulons suivre les directives de notre projet, nous devons également refactoriser les tests unitaires de A
. Auparavant, il y avait un test pour une bonne communication avec un objet de B
(raillé bien sûr). Maintenant, lorsque la référence à B
a disparu, le test est refactorisé pour vérifier la communication avec cette nouvelle interface.
Je trouve ce scénario comme une perte d'informations se produisant lors de ce refactoring de test. Nous ne vérifions plus aucune communication entre A
-> B
en raison de la pureté du test unitaire lorsque dans le système réel une telle communication existe.
Dans cet esprit, devrions-nous changer notre façon de penser les tests unitaires et créer des cas de test où il y a plus d'un objet réel? Serait-ce encore des tests unitaires ou s'agit-il déjà de tests d'intégration?
La limite extérieure d'une unité est IO. Si vous parlez à des périphériques, vous n'avez plus de tests unitaires.
Mais à l'intérieur de cela, vous pouvez découper les choses aussi profondément ou aussi finement que vous le souhaitez.
Un test peut exercer des méthodes à partir de trois objets tout en étant un test unitaire. Certains peuvent prétendre que c'est l'intégration, mais trois lignes de code sont tout aussi "intégrées", ce n'est donc pas vraiment une chose. Les limites des méthodes et des objets ne présentent pas d'obstacle significatif aux tests. Faire du périphérique.
Un test n'est pas un test unitaire si:
- Il parle à la base de données
- Il communique à travers le réseau
- Il touche le système de fichiers
- Il ne peut pas fonctionner en même temps que n'importe lequel de vos autres tests unitaires
- Vous devez faire des choses spéciales à votre environnement (comme éditer des fichiers de configuration) pour l'exécuter.
Vous pouvez maintenant affirmer que certaines personnes considèrent que chaque test pouvant être automatisé par un cadre de test unitaire fonctionne comme un test unitaire.
Si nous voulons juste discuter de la sémantique, alors je vais définir une unité comme une belle fleur dans un champ qui sent mauvais.
Indépendamment des termes, ce qui est nécessaire est la séparation des tests lents et gênants des tests si aveuglément rapides et stables que vous pouvez les exécuter pendant que vous tapez.
Appelez ce qu'ils agissent sur ce que vous voulez. Ne les mélangez pas.
Approche alternative pour classer les tests en deux catégories
- Tests lents
- Tests rapides
Les tests lents sont généralement des tests qui nécessitent un accès à des ressources externes, les tests lents peuvent également être des tests qui nécessitent une configuration très très très compliquée.
Les tests rapides sont des tests utilisés par les développeurs pour obtenir des commentaires rapides pendant leur travail.
Souhaitez-vous tester chaque classe de manière explicite ou tester plusieurs classes dans les tests de consommation, cette décision devrait être prise par l'équipe, comme Arseni Mourzenko l'a déjà souligné dans sa réponse.
Si vous écrivez des tests avant d'écrire du code de production, vous constaterez que tester plusieurs classes accélère le flux de travail et fournit des commentaires plus précieux.
Parce que lorsque vous commencez à travailler sur une fonctionnalité, vous ne savez pas encore quel type d'interaction vous auriez.
Donc, tout simplement, vous écririez tout dans une classe ou une méthode couverte de tests et ensuite vous le refactorisez en extrayant les duplications ou les dépendances.
Ne vous laissez pas prendre dans les définitions. Ce n'est pas la chose importante.
Il y a un certain désaccord sur la signification exacte de "unité". Mais le but de vos tests automatisés n'est pas de se conformer à une définition arbitraire. Le but est de détecter les bugs. Ou plus généralement: vérifier le comportement du code est conforme aux attentes.
Lors de la refactorisation, les tests unitaires doivent garantir que vous n'avez pas introduit de bogues ou changé de comportement. Par conséquent, les tests ne doivent pas être couplés aux détails d'implémentation dans le code testé. En effet, il devrait être possible de réécrire complètement l'implémentation d'une classe et de faire vérifier par les tests que le comportement est toujours le même.
Donc, de ce point de vue, un test ne se soucie pas si la classe testée appelle dans d'autres classes. Il s'agit d'un détail d'implémentation. En effet, une refactorisation typique consiste à extraire du code interne dans une méthode vers une classe distincte - et c'est exactement le genre de scénario dans lequel les tests devraient pouvoir vérifier que la refactorisation n'a pas changé de comportement.
Mais si vous décidez qu'un test ne doit tester qu'une seule classe, vous couplez le test aux détails d'implémentation. Vous ne pouvez pas refactoriser l'implémentation en toute sécurité car vous devrez peut-être également modifier le test et introduire des simulations. Mais changer le test en même temps que changer le code testé va à l'encontre de l'objectif du test, car vous ne pouvez pas être sûr de vérifier la même chose qu'avant.
Le type de moquerie est particulièrement insidieux lorsque vous vérifiez simplement que certaines méthodes sont appelées avec certains paramètres sur la maquette. Cette forme de test se couple si étroitement à la mise en œuvre qu'ils deviennent un frein à la refactorisation plutôt qu'une aide.
Donc, en résumé: évitez les simulacres (sauf dans le cas de services externes ou d'entrées non déterministes) et laissez les tests exercer autant de classes que nécessaire pour vérifier le comportement testé.
Personnellement, j'aime penser à "unité" comme "unité de comportement" - la plus petite partie d'une spécification qui peut être testée indépendamment. Mais c'est juste ma définition.
Dans votre classe refactorisée A
, vous dites qu'il n'est plus responsable de communiquer directement avec la classe B
, mais plutôt de communiquer avec un Interface
. Donc, tant que vous testez cette communication, vous ne devriez pas avoir à vous soucier de ne plus communiquer directement avec la classe spécifique B
.
La classe A
ne connaît rien de la classe B
, donc les tests de la classe A
ne devraient pas non plus.
Pour tester le comportement de la classe B
(dans le cas où la classe A
ou toute autre classe appelle une instance de la classe B
au moment de l'exécution), vous devriez avoir d'autres tests autour de la classe B
.
Le comportement du test de nombreuses classes dans un test est-il toujours un test unitaire?
Cela dépend de la définition du test unitaire qui fait autorité.
devrions-nous changer notre façon de penser les tests unitaires et créer des cas de test où il y a plus d'un objet réel? Serait-ce encore des tests unitaires ou s'agit-il déjà de tests d'intégration?
Lorsque vous effectuez des modifications incompatibles en arrière, les choses qui dépendent de ce que vous avez modifié se brisent. Donc, si vos tests vous crient maintenant, c'est génial - tout fonctionne comme prévu.
À ce stade, vous avez deux choix. L'une consiste à repenser les modifications que vous apportez à votre conception, afin qu'elles soient rétrocompatibles. Cela signifie généralement créer de nouvelles classes et interfaces et implémenter les anciens cas d'utilisation en termes de nouveaux éléments. En effet, l'ancien code est refactorisé pour tirer parti de la nouvelle façon de faire, le cas échéant, et les tests vérifient que vous n'avez pas introduit de nouvelles erreurs en le faisant. Vos nouveaux tests unitaires couvrent les nouveaux comportements.
Remarque: vous pouvez à cette étape déprécier l'ancienne façon de faire les choses comme un moyen de gérer le changement. Cela vous permet de séparer dans le temps l'ajout du nouveau de la suppression de l'ancien.
Une autre possibilité est que vous décidiez que ce changement de rupture est nécessaire, et c'est le bon moment pour en payer les frais. Il suffit donc de supprimer le test de problème. Ta-da! et vous avez terminé. Choix parfaitement raisonnable lorsque vous remplacez à la fois le bébé et l'eau du bain.
Les cas problématiques se situent au milieu; vous commettez une "petite" modification incompatible, et la plupart de la valeur des tests est toujours présente, mais continuer à augmenter cette valeur nécessite un montant disproportionné de travail.
Cela signifie souvent que vos tests s'étendent sur de nombreuses décisions volatiles (voir Parnas, 1971 ) et ou que votre conception actuelle rend les échanges d'éléments trop coûteux.
Par exemple, il est facile de prétendre qu'un comportement est une chose atomique unique, alors qu'en pratique c'est en fait une accumulation d'un certain nombre d'idées indépendantes différentes. Si vous décomposez vos tests afin qu'ils soient mieux concentrés sur les idées comportementales (plutôt que de vous soucier de problèmes structurels comme la "classe"), alors vous testez des éléments qui résistent aux changements en dehors de leur préoccupation immédiate.
Mais soyons honnêtes, pour arriver à cet état, il faut faire preuve de réflexion et de conception.
Vous voudrez peut-être revoir James Shore's Testing Without Mocks ; ou certains des articles publiés sur Property Based Testing , qui prennent souvent un seul comportement et le partitionnent en un certain nombre de tests différents, ce qui vous permet de préserver/réutiliser bon nombre de vos tests même lorsque le changements de comportement globaux.
Un test unitaire historique teste le comportement d'un composant unique, une classe. Afin de trouver immédiatement des erreurs de régression, où la classe ne répond plus au comportement attendu.
Cependant, les tests unitaires sont aujourd'hui utilisés pour beaucoup, beaucoup plus.
Développement piloté par les tests
C'est un moyen rapide de développer des pièces isolées/pelucheuses, avec un comportement assuré, dès le départ.
Intégrité des données
J'ai vu des contrôles sur les XML des flux de processus métier, pour vérifier qu'il y a toujours un flux inconditionnel par défaut vers un état suivant. Ces tests garantissent que les ressources de données matérielles ne contiennent pas d'éventuelles erreurs. Votre propre "compilateur" de vérification pour ainsi dire. Le même peut tenir pour comparer les traductions de plusieurs langues. (Pour les fichiers de ressources, j'ai vu tester tous les fichiers dans une arborescence de répertoires de ressources, dans un seul test unitaire utilisant une marche de répertoire de chemin de classe, donc les ressources futures sera automatiquement testé.)
Ces cas montrent déjà que les tests unitaires sont en fait utilisés comme une mesure assurance qualité.
Et par conséquent, vous pouvez également tester les conteneurs de composants et tout le comportement du système. L'idée de tester uniquement les unités (après avoir écrit les unités) est obsolète.