Tests unitaires fragiles en raison du besoin de moqueries excessives

21

Je me bats avec un problème de plus en plus ennuyeux concernant nos tests unitaires que nous mettons en œuvre dans mon équipe. Nous essayons d'ajouter des tests unitaires dans du code hérité qui n'était pas bien conçu et même si nous n'avons eu aucune difficulté avec l'ajout réel des tests, nous commençons à avoir du mal avec la façon dont les tests se déroulent.

Comme exemple du problème, supposons que vous ayez une méthode qui appelle 5 autres méthodes dans le cadre de son exécution. Un test pour cette méthode pourrait être de confirmer qu'un comportement se produit à la suite de l'appel de l'une de ces 5 autres méthodes. Donc, comme un test unitaire doit échouer pour une raison et une seule raison, vous voulez éliminer les problèmes potentiels causés par l'appel de ces 4 autres méthodes et les simuler. Génial! Le test unitaire s'exécute, les méthodes simulées sont ignorées (et leur comportement peut être confirmé dans le cadre d'autres tests unitaires), et la vérification fonctionne.

Mais il y a un nouveau problème - le test unitaire a une connaissance intime de la façon dont vous avez confirmé que le comportement et toute modification de signature à l'une de ces 4 autres méthodes à l'avenir, ou toute nouvelle méthode qui doit être ajoutée à la `` méthode parent '', sera oblige à modifier le test unitaire pour éviter d'éventuelles pannes.

Naturellement, le problème pourrait être quelque peu atténué en faisant simplement en sorte que plus de méthodes accomplissent moins de comportements, mais j'espérais qu'il y avait peut-être une solution plus élégante disponible.

Voici un exemple de test unitaire qui capture le problème.

Comme note rapide, «MergeTests» est une classe de tests unitaires qui hérite de la classe que nous testons et remplace le comportement au besoin. Il s'agit d'un «modèle» que nous utilisons dans nos tests pour nous permettre de remplacer les appels à des classes / dépendances externes.

[TestMethod]
public void VerifyMergeStopsSpinner()
{
    var mockViewModel = new Mock<MergeTests> { CallBase = true };
    var mockMergeInfo = new MergeInfo(Mock.Of<IClaim>(), Mock.Of<IClaim>(), It.IsAny<bool>());

    mockViewModel.Setup(m => m.ClaimView).Returns(Mock.Of<IClaimView>);
    mockViewModel.Setup(
        m =>
        m.TryMergeClaims(It.IsAny<Func<bool>>(), It.IsAny<IClaim>(), It.IsAny<IClaim>(), It.IsAny<bool>(),
                         It.IsAny<bool>()));
    mockViewModel.Setup(m => m.GetSourceClaimAndTargetClaimByMergeState(It.IsAny<MergeState>())).Returns(mockMergeInfo);
    mockViewModel.Setup(m => m.SwitchToOverviewTab());
    mockViewModel.Setup(m => m.IncrementSaveRequiredNotification());
    mockViewModel.Setup(m => m.OnValidateAndSaveAll(It.IsAny<object>()));
    mockViewModel.Setup(m => m.ProcessPendingActions(It.IsAny<string>()));

    mockViewModel.Object.OnMerge(It.IsAny<MergeState>());    

    mockViewModel.Verify(mvm => mvm.StopSpinner(), Times.Once());
}

Comment le reste d'entre vous avez-vous géré cela ou n'y a-t-il pas une excellente manière «simple» de le gérer?

Mise à jour - J'apprécie les commentaires de tout le monde. Malheureusement, et ce n'est pas vraiment une surprise, il ne semble pas y avoir une excellente solution, un modèle ou une pratique à suivre dans les tests unitaires si le code testé est médiocre. J'ai marqué la réponse qui captait le mieux cette simple vérité.

PremiumTier
la source
Wow, je ne vois qu'une configuration fictive, pas d'instanciation SUT ou quoi que ce soit, testez-vous une implémentation réelle ici? Qui est censé appeler StopSpinner? OnMerge? Vous devez vous moquer de toutes les dépendances auxquelles il peut appeler mais pas la chose elle-même ..
Joppe
C'est un peu difficile à voir, mais le Mock <MergeTests> est le SUT. Nous définissons l'indicateur CallBase pour garantir que la méthode 'OnMerge' s'exécute sur l'objet réel, mais simulons les méthodes appelées par 'OnMerge' qui pourraient entraîner l'échec du test en raison de problèmes de dépendance, etc. Le but du test est la dernière ligne - pour vérifier que nous avons arrêté le spinner dans ce cas.
PremiumTier
MergeTests ressemble à une autre classe instrumentée, pas quelque chose qui vit dans la production d'où la confusion.
Joppe
1
En dehors de vos autres problèmes, il me semble erroné que votre SUT soit un <MergeTests> simulé. Pourquoi voudriez-vous tester une maquette? Pourquoi ne testez-vous pas la classe MergeTests elle-même?
Eric King

Réponses:

18
  1. Corrigez le code pour être mieux conçu. Si vos tests ont ces problèmes, votre code aura des problèmes plus graves lorsque vous essayez de changer les choses.

  2. Si vous ne le pouvez pas, vous devez peut-être être moins idéal. Testez les pré et post-conditions de la méthode. Peu importe si vous utilisez les 5 autres méthodes? Ils ont vraisemblablement leurs propres tests unitaires expliquant clairement (er) ce qui a causé l'échec lorsque les tests échouent.

"Les tests unitaires ne devraient avoir qu'une seule raison d'échouer" est une bonne ligne directrice, mais d'après mon expérience, peu pratique. Les tests difficiles à écrire ne sont pas écrits. Les tests fragiles ne sont pas crus.

Telastyn
la source
Je suis entièrement d'accord avec la fixation de la conception du code, mais dans le monde moins idéal de développement pour une grande entreprise avec des délais serrés, il peut être difficile de justifier le `` remboursement '' de la dette technique contractée par les équipes passées ou de mauvaises décisions tout au une fois que. Pour votre deuxième point, une grande partie de la moquerie n'est pas simplement parce que nous voulons que le test échoue uniquement pour une raison - c'est parce que le code en cours d'exécution ne peut pas être autorisé à s'exécuter sans également traiter d'abord un grand nombre de dépendances créées à l'intérieur de ce code . Désolé d'avoir déplacé les poteaux de but sur celui-ci.
PremiumTier
Si un meilleur design n'est pas réaliste, je suis d'accord avec "Qui se soucie si vous utilisez les 5 autres méthodes?" Vérifiez que la méthode remplit la fonction requise, pas comment elle le fait.
Kwebble
@Kwebble - Compris, mais l'objectif de la question était de déterminer s'il existait un moyen simple de vérifier le comportement d'une méthode lorsque vous devez également simuler d'autres comportements appelés dans la méthode afin d'exécuter le test. Je veux supprimer le 'comment', mais je ne sais pas comment :)
PremiumTier
Il n'y a pas de solution miracle. Il n'y a pas de "moyen simple" de tester un mauvais code. Soit le code en cours de test doit être refactorisé, soit le code de test lui-même sera également médiocre. Soit le test sera médiocre car il sera trop spécifique aux détails internes, comme vous l'avez rencontré, ou comme suggéré par btilly , vous pouvez exécuter les tests dans un environnement de travail, mais les tests seront alors beaucoup plus lents et plus complexes. Quoi qu'il en soit, les tests seront plus difficiles à écrire, plus difficiles à maintenir et sujets à de faux négatifs.
Steven Doggart
8

Décomposer les grandes méthodes en petites méthodes plus ciblées est certainement une meilleure pratique. Vous voyez cela comme une douleur dans la vérification du comportement des tests unitaires, mais vous ressentez également la douleur d'autres manières.

Cela dit, c'est une hérésie mais je suis personnellement un fan de la création d'environnements de test temporaires réalistes. Autrement dit, plutôt que de vous moquer de tout ce qui est caché à l'intérieur de ces autres méthodes, assurez-vous qu'il existe un environnement temporaire facile à configurer (avec des bases de données et des schémas privés - SQLite peut vous aider ici) qui vous permet d'exécuter tout cela. La responsabilité de savoir comment créer / détruire cet environnement de test incombe au code qui l'exige, de sorte que lorsqu'il change, vous n'avez pas à modifier tout le code de test unitaire qui dépend de son existence.

Mais je note que c'est une hérésie de ma part. Les gens qui sont fortement dans les tests unitaires préconisent des tests unitaires "purs" et appellent ce que j'ai décrit des "tests d'intégration". Je ne m'inquiète pas personnellement de cette distinction.

btilly
la source
3

J'envisagerais d'assouplir les simulations et de formuler simplement des tests qui pourraient inclure les méthodes auxquelles il fait appel.

Ne testez pas le comment , testez le quoi . C'est le résultat qui compte, incluez les sous-méthodes si besoin est.

Sous un autre angle, vous pouvez formuler un test, le faire passer avec une grande méthode, refactoriser et vous retrouver avec un arbre de méthodes après refactoring. Vous n'avez pas besoin de tester chacun d'entre eux isolément. C'est le résultat final qui compte.

Si les sous-méthodes rendent difficile le test de certains aspects, envisagez de les répartir dans des classes distinctes afin de pouvoir les simuler plus proprement sans que votre classe testée soit fortement instrumentée / cousue. Il est difficile de dire si vous testez réellement une implémentation concrète dans votre exemple de test.

Joppe
la source
Le problème est que nous devons nous moquer du «comment» pour tester le «quoi». C'est une limitation imposée par la conception du code. Je n'ai certainement pas envie de "moquer" le comment car c'est ce qui rend le test fragile.
PremiumTier
En regardant les noms des méthodes, je pense que votre classe testée prend tout simplement trop de responsabilités. Lisez sur le principe de la responsabilité unique. Emprunter à MVC peut aider un peu, votre classe semble gérer à la fois l'interface utilisateur, l'infrastructure et les problèmes commerciaux.
Joppe
Oui :( Ce serait ce code hérité mal conçu auquel je faisais référence. Nous travaillons sur la refonte et la refonte, mais nous avons pensé qu'il serait préférable de mettre la source sous test en premier.
PremiumTier