Aux prises avec des dépendances cycliques dans les tests unitaires

24

J'essaie de pratiquer le TDD, en l'utilisant pour développer un simple comme Bit Vector. Il se trouve que j'utilise Swift, mais c'est une question indépendante de la langue.

My BitVectorest un structqui stocke un seul UInt64et présente une API qui vous permet de le traiter comme une collection. Les détails importent peu, mais c'est assez simple. Les 57 bits supérieurs sont des bits de stockage et les 6 bits inférieurs sont des bits de "comptage", qui vous indiquent combien de bits de stockage stockent réellement une valeur contenue.

Jusqu'à présent, j'ai une poignée de capacités très simples:

  1. Un initialiseur qui construit des vecteurs de bits vides
  2. Une countpropriété de typeInt
  3. Une isEmptypropriété de typeBool
  4. Un opérateur d'égalité ( ==). NB: il s'agit d'un opérateur d'égalité de valeurs semblable à Object.equals()Java, pas d'un opérateur d'égalité de référence comme ==en Java.

Je rencontre un tas de dépendances cycliques:

  1. Le test unitaire qui teste mon initialiseur doit vérifier que le nouveau modèle a été construit BitVector. Il peut le faire de trois manières:

    1. Vérifier bv.count == 0
    2. Vérifier bv.isEmpty == true
    3. Regarde ça bv == knownEmptyBitVector

    La méthode 1 s'appuie sur count, la méthode 2 s'appuie sur isEmpty(qui elle-même s'appuie count, il n'y a donc aucun intérêt à l'utiliser), la méthode 3 s'appuie sur ==. En tout cas, je ne peux pas tester mon initialiseur isolément.

  2. Le test pour les countbesoins de fonctionner sur quelque chose, qui teste inévitablement mes initialiseur (s)

  3. La mise en œuvre de isEmptys'appuie surcount

  4. La mise en œuvre de ==s'appuie sur count.

J'ai pu résoudre en partie ce problème en introduisant une API privée qui construit un à BitVectorpartir d'un modèle binaire existant (en tant que UInt64). Cela m'a permis d'initialiser les valeurs sans tester les autres initialiseurs, afin de pouvoir "booter" en montant.

Pour que mes tests unitaires soient vraiment des tests unitaires, je me retrouve à faire un tas de hacks, ce qui complique considérablement ma prod et mon code de test.

Comment contournez-vous exactement ce genre de problèmes?

Alexander - Rétablir Monica
la source
20
Vous adoptez une vision trop étroite du terme «unité». BitVectorest une taille d'unité parfaitement fine pour les tests unitaires et résout immédiatement vos problèmes dont les membres du public ont BitVectorbesoin les uns des autres pour effectuer des tests significatifs.
Bart van Ingen Schenau
Vous connaissez trop de détails de mise en œuvre dès le départ. Votre développement est-il vraiment piloté par les tests ?
herby
@herby Non, c'est pourquoi je m'entraîne. Bien que cela semble être une norme vraiment inaccessible. Je ne pense pas avoir jamais programmé quoi que ce soit sans une approximation mentale assez claire de ce que la mise en œuvre impliquera.
Alexander - Reinstate Monica
@Alexander Vous devriez essayer de détendre cela, sinon ce sera le test d'abord, mais pas piloté par le test. Dites juste vague "je ferai un petit vecteur avec un 64bit int comme magasin de sauvegarde" et c'est tout; à partir de là, refactorisez le TDD rouge-vert l'un après l'autre. Les détails de l'implémentation, ainsi que l'API, devraient émerger de la tentative d'exécution des tests (les premiers) et de l'écriture de ces tests en premier lieu (les seconds).
herby

Réponses:

66

Vous vous inquiétez trop des détails de mise en œuvre.

Peu importe que dans votre implémentation actuelle , isEmptys'appuie sur count(ou sur toute autre relation que vous pourriez avoir): tout ce dont vous devriez vous soucier est l'interface publique. Par exemple, vous pouvez avoir trois tests:

  • Qu'un objet nouvellement initialisé a count == 0.
  • Qu'un objet nouvellement initialisé a isEmpty == true
  • Qu'un objet nouvellement initialisé est égal à l'objet vide connu.

Ce sont tous des tests valides, et deviennent particulièrement importants si vous décidez de refactoriser les internes de votre classe afin d' isEmptyavoir une implémentation différente qui ne dépende pas count- tant que vos tests réussissent tous, vous savez que vous n'avez pas régressé n'importe quoi.

Des choses similaires s'appliquent à vos autres points - n'oubliez pas de tester l'interface publique, pas votre implémentation interne. Vous pouvez trouver TDD utile ici, car vous écririez alors les tests dont vous avez besoin isEmptyavant d'avoir écrit une implémentation pour cela.

Philip Kendall
la source
6
@Alexander Vous parlez comme un homme qui a besoin d'une définition claire des tests unitaires. Le meilleur que je connaisse vient de Michael Feathers
candied_orange
14
@Alexander, vous traitez chaque méthode comme un morceau de code testable indépendamment. C'est la source de vos difficultés. Ces difficultés disparaissent si vous testez l'objet dans son ensemble, sans essayer de le diviser en parties plus petites. Les dépendances entre objets ne sont pas comparables aux dépendances entre méthodes.
amon
9
@Alexander "un morceau de code" est une mesure arbitraire. Juste en initialisant une variable, vous utilisez de nombreux «morceaux de code». Ce qui importe, c'est que vous testiez une unité comportementale cohésive telle que définie par vous .
Ant P
9
"D'après ce que j'ai lu, j'ai l'impression que si vous ne cassez qu'un morceau de code, seuls les tests unitaires directement liés à ce code devraient échouer." Cela semble être une règle très difficile à suivre. (par exemple, si vous écrivez une classe vectorielle et que vous faites une erreur sur la méthode d'indexation, vous aurez probablement des tonnes de casse dans tout le code qui utilise cette classe vectorielle)
jhominal
4
@Alexander Consultez également le modèle "Arrange, Act, Assert" pour les tests. Fondamentalement, vous configurez l'objet dans l'état dans lequel il doit se trouver (Arranger), appelez la méthode que vous testez réellement (Act), puis vérifiez que son état a changé en fonction de vos attentes. (Affirmer). Les éléments que vous configurez dans Arrange seraient des "conditions préalables" au test.
GalacticCowboy
5

Comment contournez-vous exactement ce genre de problèmes?

Vous révisez votre réflexion sur ce qu'est un «test unitaire».

Un objet qui gère des données mutables en mémoire est fondamentalement une machine à états. Ainsi, tout cas d'utilisation utile va, au minimum, invoquer une méthode pour mettre des informations dans l'objet et invoquer une méthode pour lire une copie des informations de l'objet. Dans les cas d'utilisation intéressants, vous allez également invoquer des méthodes supplémentaires qui modifient la structure des données.

En pratique, cela ressemble souvent à

// GIVEN
obj = new Object(...)

// THEN
assert object.read(...)

ou

// GIVEN
obj = new Object(...)

// WHEN
object.change(...)

// THEN
assert object.read(...)

La terminologie du «test unitaire» - eh bien, elle n'est pas très bonne depuis longtemps.

Je les appelle des tests unitaires, mais ils ne correspondent pas très bien à la définition acceptée des tests unitaires - Kent Beck, Test Driven Development by Example

Kent a écrit la première version de SUnit en 1994 , le port vers JUnit était en 1998, la première version du livre TDD était début 2002. La confusion avait beaucoup de temps à se propager.

L'idée clé de ces tests (plus précisément appelés "tests programmeurs" ou "tests développeurs") est que les tests sont isolés les uns des autres. Les tests ne partagent aucune structure de données mutable, ils peuvent donc être exécutés simultanément. Il n'y a aucun souci que les tests doivent être exécutés dans un ordre spécifique pour mesurer correctement la solution.

Le principal cas d'utilisation de ces tests est qu'ils sont exécutés par le programmeur entre les modifications de son propre code source. Si vous effectuez le protocole de refactorisation rouge vert, un ROUGE inattendu indique toujours une erreur dans votre dernière modification; vous annulez cette modification, vérifiez que les tests sont VERTS et réessayez. Il n'y a pas beaucoup d'avantages à essayer d'investir dans une conception où chaque bogue possible est détecté par un seul test.

Bien sûr, si une fusion introduit une faute, trouver que la faute n'est plus anodine. Vous pouvez suivre différentes étapes pour vous assurer que les défauts sont faciles à localiser. Voir

VoiceOfUnreason
la source
1

En général (même si vous n'utilisez pas TDD), vous devez vous efforcer d'écrire des tests autant que possible tout en faisant semblant de ne pas savoir comment il est mis en œuvre.

Si vous faites du TDD, cela devrait déjà être le cas. Vos tests sont une spécification exécutable du programme.

L'aspect du graphe d'appel sous les tests n'a pas d'importance, tant que les tests eux-mêmes sont raisonnables et bien entretenus.

Je pense que votre problème est votre compréhension du TDD.

À mon avis, votre problème est que vous "mélangez" vos personnages TDD. Vos personnages "test", "code" et "refactor" fonctionnent de manière totalement indépendante les uns des autres, idéalement. En particulier, vos personnes de codage et de refactoring n'ont aucune obligation envers les tests autres que de les faire / maintenir vertes.

Bien sûr, en principe, il serait préférable que tous les tests soient orthogonaux et indépendants les uns des autres. Mais ce n'est pas une préoccupation de vos deux autres personnages TDD, et ce n'est certainement pas une exigence stricte stricte ou même nécessairement réaliste de vos tests. Fondamentalement: ne jetez pas vos sentiments de bon sens sur la qualité du code pour essayer de répondre à une exigence que personne ne vous demande.

Tim Seguine
la source