Comportements de test unitaire sans couplage avec les détails d'implémentation

16

Dans son discours sur TDD, où tout a mal tourné , Ian Cooper pousse l'intention originale de Kent Beck derrière les tests unitaires en TDD (pour tester les comportements, pas les méthodes de classes en particulier) et plaide pour éviter de coupler les tests à la mise en œuvre.

Dans le cas d'un comportement comme save X to some data sourcedans un système avec un ensemble typique de services et de référentiels, comment pouvons-nous tester à l'unité la sauvegarde de certaines données au niveau du service, via le référentiel, sans coupler le test aux détails d'implémentation (comme appeler une méthode spécifique )? Est-ce que le fait d'éviter ce type de couplage ne vaut pas vraiment la peine / le mal d'une certaine façon?

Andy Hunt
la source
1
Si vous voulez tester que les données ont été enregistrées dans le référentiel, alors le test devra réellement aller vérifier le référentiel pour voir si les données sont là, non? Ou est-ce que je manque quelque chose?
Ma question était plutôt d'éviter de coupler les tests à un détail d'implémentation comme appeler une méthode spécifique sur le référentiel, ou vraiment si c'est quelque chose qui devrait être fait.
Andy Hunt

Réponses:

8

Votre exemple spécifique est un cas que vous devez généralement tester en vérifiant si une certaine méthode a été appelée, car cela saving X to data sourcesignifie communiquer avec une dépendance externe , donc le comportement que vous devez tester est que la communication se produit comme prévu .

Cependant, ce n'est pas une mauvaise chose. Les interfaces limites entre votre application et ses dépendances externes ne sont pas des détails d'implémentation , en fait, elles sont définies dans l'architecture de votre système; ce qui signifie qu'une telle frontière n'est pas susceptible de changer (ou si c'est le cas, ce serait le type de changement le moins fréquent). Ainsi, coupler vos tests à une repositoryinterface ne devrait pas vous poser trop de problèmes (si c'est le cas, pensez si l'interface ne dérobe pas des responsabilités à l'application).

Maintenant, considérez uniquement les règles métier d'une application, découplées de l'interface utilisateur, des bases de données et d'autres services externes. C'est là que vous devez être libre de modifier la structure et le comportement du code. C'est là que le couplage des tests et des détails d'implémentation vous obligera à modifier plus de code de test que le code de production, même lorsqu'il n'y a aucun changement dans le comportement global de l'application. C'est là que les tests Stateau lieu de Interactionnous aider à aller plus vite.

PS: Je n'ai pas l'intention de dire si le test par État ou Interactions est le seul vrai moyen de TDD - je pense qu'il s'agit d'utiliser le bon outil pour le bon travail.

MichelHenrich
la source
Lorsque vous mentionnez "communiquer avec une dépendance externe", faites-vous référence aux dépendances externes comme celles qui sont externes à l'unité testée, ou celles externes au système dans son ensemble?
Andy Hunt
Par «dépendance externe», je veux dire tout ce que vous pouvez considérer comme un plug-in pour votre application. Par application, je veux dire les règles métier, agnostiques de tout type de détail comme le cadre à utiliser pour la persistance ou l'interface utilisateur. Je pense que l'oncle Bob peut mieux l'expliquer, comme dans cette conférence: youtube.com/watch?v=WpkDN78P884
MichelHenrich
Je pense que c'est l'approche idéale, comme le dit le discours, pour tester sur une "caractéristique" ou "comportement", et un test par caractéristique ou comportement (ou permutation d'un, c'est-à-dire des paramètres variables). Cependant, si j'ai 1 test "heureux" pour une fonctionnalité, afin de faire TDD, cela signifie que j'aurai un seul commit géant (et revue de code) pour cette fonctionnalité, ce qui est une mauvaise idée. Comment cela pourrait-il être évité? Écrire une partie de cette fonctionnalité en tant que test et tout le code qui lui est associé, puis ajouter progressivement le reste de la fonctionnalité dans les validations suivantes?
jordanie
J'aimerais vraiment voir un exemple concret de tests qui se couplent à l'implémentation.
PositiveGuy
7

Mon interprétation de cet exposé est:

  • tester les composants, pas les classes.
  • tester les composants via leurs ports d'interface.

Ce n'est pas indiqué dans l'exposé, mais je pense que le contexte supposé des conseils est quelque chose comme:

  • vous développez un système pour les utilisateurs, pas, disons, une bibliothèque ou un cadre utilitaire.
  • l'objectif des tests est de livrer avec succès autant que possible dans un budget compétitif.
  • les composants sont écrits dans un langage unique, mature, probablement de type statique, comme C # / Java.
  • un composant est de l'ordre de 10 000 à 5 000 lignes; un projet Maven ou VS, un plugin OSGI, etc.
  • les composants sont écrits par un seul développeur ou par une équipe étroitement intégrée.
  • vous suivez la terminologie et l'approche de quelque chose comme l' architecture hexagonale
  • un port de composant est l'endroit où vous quittez la langue locale, et son système de type, derrière, en passant à http / SQL / XML / bytes / ...
  • enveloppant chaque port sont des interfaces typées, au sens Java / C #, qui peuvent avoir des implémentations commutées pour changer de technologie.

Ainsi, tester un composant est la plus grande étendue possible dans laquelle quelque chose peut encore être raisonnablement appelé test unitaire. C'est assez différent de la façon dont certaines personnes, en particulier les universitaires, utilisent le terme. Cela ne ressemble en rien aux exemples du didacticiel type d'outil de test unitaire. Il correspond cependant à son origine dans les tests matériels; les cartes et modules sont testés à l'unité, pas les fils et les vis. Ou du moins vous ne construisez pas un faux Boeing pour tester une vis ...

Extrapoler à partir de cela, et jeter certaines de mes propres pensées,

  • Chaque interface sera soit une entrée, une sortie ou un collaborateur (comme une base de données).
  • vous testez les interfaces d'entrée; appeler les méthodes, affirmer les valeurs de retour.
  • vous vous moquez des interfaces de sortie; vérifier que les méthodes attendues sont appelées pour un cas de test donné.
  • vous simulez les collaborateurs; fournir une mise en œuvre simple mais fonctionnelle

Si vous le faites correctement et proprement, vous avez à peine besoin d'un outil moqueur; il n'est utilisé que quelques fois par système.

Une base de données est généralement un collaborateur, elle est donc falsifiée plutôt que raillée. Ce serait pénible à mettre en œuvre à la main; heureusement, de telles choses existent déjà .

Le modèle de test de base consiste à effectuer une séquence d'opérations (par exemple, enregistrer et recharger un document); confirmez que cela fonctionne. C'est la même chose que pour tout autre scénario de test; aucun changement d'implémentation (fonctionnel) n'est susceptible d'entraîner l'échec d'un tel test.

L'exception est où les enregistrements de la base de données sont écrits mais jamais lus par le système testé; par exemple, les journaux d'audit ou similaires. Ce sont des sorties et doivent donc être moquées. Le modèle de test consiste à effectuer une séquence d'opérations; confirmer que l'interface d'audit a été appelée avec les méthodes et les arguments spécifiés.

Notez que même ici, à condition que vous utilisiez un outil de simulation de type sécurisé comme mockito , renommer une méthode d'interface ne peut pas entraîner un échec de test. Si vous utilisez un IDE avec les tests chargés, il sera refactorisé avec la méthode renommer. Si vous ne le faites pas, le test ne sera pas compilé.

Soru
la source
Pouvez-vous me décrire / me donner un exemple concret de port d'interface?
PositiveGuy
ce qui est un exemple d'une interface de sortie. Pouvez-vous être précis dans le code? Idem avec l'interface d'entrée.
PositiveGuy
Une interface (au sens Java / C #) enveloppe un port, qui peut être tout ce qui parle au monde extérieur (d / b, socket, http, ....). Une interface de sortie est une interface qui n'a pas de méthodes avec des valeurs de retour qui viennent du monde extérieur via le port, seulement des exceptions ou équivalent.
Soru
Une interface d'entrée est l'opposé, un collaborateur est à la fois entrée et sortie.
Soru
1
Je pense que vous parlez d'une approche de conception et d'un ensemble de terminologie entièrement différents de ceux décrits dans la vidéo. Mais 90% du temps, un référentiel (c'est-à-dire une base de données) est un collaborateur, pas une entrée ou une sortie. Et donc son interface est une interface de collaboration.
soru
0

Ma suggestion est d'utiliser une approche de test basée sur l'état:

DONNÉ Nous avons la DB de test dans un état connu

QUAND le service est appelé avec des arguments X

ALORS Affirmez que la base de données est passée de son état d'origine à l'état attendu en appelant des méthodes de référentiel en lecture seule et en vérifiant leurs valeurs renvoyées

De cette façon, vous ne comptez sur aucun algorithme interne du service et êtes libre de refactoriser son implémentation sans avoir à modifier les tests.

Le seul couplage ici est à l'appel de méthode de service et aux appels de référentiel nécessaires pour lire les données de la base de données, ce qui est bien.

Elifarley
la source