Est-il acceptable de simuler une partie de la classe testée?


22

Supposons que j'ai une classe (pardonnez l'exemple artificiel et sa mauvaise conception):

class MyProfit
{
  public decimal GetNewYorkRevenue();
  public decimal GetNewYorkExpenses();
  public decimal GetNewYorkProfit();

  public decimal GetMiamiRevenue();
  public decimal GetMiamiExpenses();
  public decimal GetMiamiProfit();

  public bool BothCitiesProfitable();

}

(Notez que les méthodes GetxxxRevenue () et GetxxxExpenses () ont des dépendances qui sont tronquées)

Je teste maintenant à la fois BothCitiesProfitable () qui dépend de GetNewYorkProfit () et GetMiamiProfit (). Est-il correct de bloquer GetNewYorkProfit () et GetMiamiProfit ()?

Il semble que si je ne le fais pas, je teste simultanément GetNewYorkProfit () et GetMiamiProfit () avec BothCitiesProfitable (). Je dois m'assurer de configurer le stubbing pour GetxxxRevenue () et GetxxxExpenses () afin que les méthodes GetxxxProfit () renvoient les valeurs correctes.

Jusqu'à présent, je n'ai vu que des exemples de dépendances de stubbing sur des classes externes et non sur des méthodes internes.

Et si ça va, y a-t-il un modèle particulier que je devrais utiliser pour ce faire?

MISE À JOUR

Je crains que nous ne manquions pas le problème principal et c'est probablement la faute de mon pauvre exemple. La question fondamentale est la suivante: si une méthode d'une classe dépend d'une autre méthode exposée publiquement dans cette même classe, est-il acceptable (ou même recommandé) de bloquer cette autre méthode?

Peut-être que je manque quelque chose, mais je ne suis pas sûr que la séparation des cours ait toujours un sens. Peut-être un autre exemple minimalement meilleur serait:

class Person
{
 public string FirstName()
 public string LastName()
 public string FullName()
}

où le nom complet est défini comme:

public string FullName()
{
  return FirstName() + " " + LastName();
}

Est-il correct de bloquer FirstName () et LastName () lors du test de FullName ()?


Si vous testez du code simultanément, comment cela peut-il être mauvais? Quel est le problème? Normalement, il devrait être préférable de tester le code plus souvent et plus intensivement.
utilisateur inconnu

Je viens de découvrir votre mise à jour, j'ai mis à jour ma réponse
Winston Ewert

Réponses:


27

Vous devez séparer la classe en question.

Chaque classe doit effectuer une tâche simple. Si votre tâche est trop compliquée à tester, alors la tâche de la classe est trop grande.

Ignorant la maladresse de cette conception:

class NewYork
{
    decimal GetRevenue();
    decimal GetExpenses();
    decimal GetProfit();
}


class Miami
{
    decimal GetRevenue();
    decimal GetExpenses();
    decimal GetProfit();
}

class MyProfit
{
     MyProfit(NewYork new_york, Miami miami);
     boolean bothProfitable();
}

MISE À JOUR

Le problème avec les méthodes de stubbing dans une classe est que vous violez l'encapsulation. Votre test doit vérifier si le comportement externe de l'objet correspond ou non aux spécifications. Tout ce qui se passe à l'intérieur de l'objet ne le regarde pas.

Le fait que FullName utilise FirstName et LastName est un détail d'implémentation. Rien en dehors de la classe ne devrait s'en soucier. En se moquant des méthodes publiques afin de tester l'objet, vous faites une hypothèse sur cet objet est implémenté.

À un certain moment dans l'avenir, cette hypothèse pourrait cesser d'être correcte. Peut-être que toute la logique du nom sera déplacée vers un objet Name que Person appelle simplement. Peut-être que FullName accédera directement aux variables membres first_name et last_name au lieu d'appeler FirstName et LastName.

La deuxième question est de savoir pourquoi vous en ressentez le besoin. Après tout, votre classe individuelle pourrait être testée quelque chose comme:

Person person = new Person("John", "Doe");
Test.AssertEquals(person.FullName(), "John Doe");

Vous ne devriez pas ressentir le besoin de bloquer quoi que ce soit pour cet exemple. Si vous le faites, vous êtes heureux et bien ... arrêtez! Il n'y a aucun avantage à se moquer des méthodes car vous avez de toute façon le contrôle de ce qui s'y trouve.

Le seul cas où il semblerait logique que les méthodes utilisées par FullName soient moquées est si en quelque sorte FirstName () et LastName () étaient des opérations non triviales. Peut-être que vous écrivez l'un de ces générateurs de noms aléatoires, ou que FirstName et LastName interrogent la base de données pour une réponse, ou quelque chose. Mais si c'est ce qui se passe, cela suggère que l'objet fait quelque chose qui n'appartient pas à la classe Person.

Autrement dit, se moquer des méthodes, c'est prendre l'objet et le diviser en deux. Une pièce est moquée tandis que l'autre pièce est testée. Ce que vous faites est essentiellement une décomposition ad hoc de l'objet. Si c'est le cas, il suffit de casser déjà l'objet.

Si votre classe est simple, vous ne devriez pas ressentir le besoin d'en moquer des morceaux pendant un test. Si votre classe est suffisamment complexe pour que vous ressentiez le besoin de vous moquer, alors vous devez diviser la classe en morceaux plus simples.

MISE À JOUR ENCORE

D'après moi, un objet a un comportement externe et interne. Le comportement externe inclut les appels de valeurs de retour dans d'autres objets, etc. Évidemment, tout élément de cette catégorie doit être testé. (sinon que testeriez-vous?) Mais le comportement interne ne devrait pas vraiment être testé.

Maintenant, le comportement interne est testé, car c'est ce qui entraîne le comportement externe. Mais je n'écris pas de tests directement sur le comportement interne, mais indirectement via le comportement externe.

Si je veux tester quelque chose, je pense qu'il doit être déplacé pour qu'il devienne un comportement externe. C'est pourquoi je pense que si vous voulez vous moquer de quelque chose, vous devez diviser l'objet afin que la chose que vous voulez moquer se trouve maintenant dans le comportement externe des objets en question.

Mais quelle différence cela fait-il? Si FirstName () et LastName () sont membres d'un autre objet, cela change-t-il vraiment le problème de FullName ()? Si nous décidons qu'il est nécessaire de se moquer de FirstName et LastName, est-ce réellement utile pour eux d'être sur un autre objet?

Je pense que si vous utilisez votre approche moqueuse, vous créez une couture dans l'objet. Vous disposez de fonctions telles que FirstName () et LastName () qui communiquent directement avec une source de données externe. Vous avez également FullName () qui n'en a pas. Mais comme ils sont tous dans la même classe, cela n'apparaît pas. Certaines pièces ne sont pas censées accéder directement à la source de données et d'autres le sont. Votre code sera plus clair si vous divisez ces deux groupes.

MODIFIER

Prenons un peu de recul et demandons: pourquoi nous moquons-nous des objets lorsque nous testons?

  1. Faites en sorte que les tests s'exécutent de manière cohérente (évitez d'accéder à des éléments qui changent d'une exécution à l'autre)
  2. Évitez d'accéder à des ressources coûteuses (ne touchez pas à des services tiers, etc.)
  3. Simplifiez le système testé
  4. Facilitez le test de tous les scénarios possibles (c'est-à-dire des choses comme la simulation de l'échec, etc.)
  5. Évitez de dépendre des détails d'autres morceaux de code afin que les changements dans ces autres morceaux de code ne cassent pas ce test.

Maintenant, je pense que les raisons 1 à 4 ne s'appliquent pas à ce scénario. Se moquer de la source externe lors du test de fullname prend en charge toutes ces raisons de se moquer. La seule pièce non manipulée qui est la simplicité, mais il semble que l'objet soit assez simple ce n'est pas un souci.

Je pense que votre préoccupation est la raison numéro 5. La préoccupation est qu'à un moment donné, changer la mise en œuvre de FirstName et LastName rompra le test. À l'avenir, FirstName et LastName peuvent obtenir les noms à partir d'un emplacement ou d'une source différente. Mais FullName le sera probablement toujours FirstName() + " " + LastName(). C'est pourquoi vous voulez tester FullName en se moquant de FirstName et LastName.

Ce que vous avez alors, c'est un sous-ensemble de l'objet personne qui est plus susceptible de changer que les autres. Le reste de l'objet utilise ce sous-ensemble. Ce sous-ensemble récupère actuellement ses données à l'aide d'une seule source, mais peut récupérer ces données d'une manière complètement différente à une date ultérieure. Mais pour moi, il semble que ce sous-ensemble soit un objet distinct essayant de sortir.

Il me semble que si vous vous moquez de la méthode de l'objet, vous divisez l'objet. Mais vous le faites de manière ponctuelle. Votre code ne précise pas qu'il existe deux éléments distincts à l'intérieur de votre objet Personne. Il suffit donc de diviser cet objet dans votre code réel, afin qu'il soit clair à la lecture de votre code ce qui se passe. Choisissez la division réelle de l'objet qui a du sens et n'essayez pas de diviser l'objet différemment pour chaque test.

Je soupçonne que vous pourriez vous opposer à diviser votre objet, mais pourquoi?

MODIFIER

J'avais tort.

Vous devez fractionner les objets plutôt que d'introduire des séparations ad hoc en se moquant des méthodes individuelles. Cependant, j'étais trop concentré sur la seule méthode de division des objets. Cependant, OO propose plusieurs méthodes de fractionnement d'un objet.

Ce que je proposerais:

class PersonBase
{
      abstract sring FirstName();
      abstract string LastName();

      string FullName()
      {
            return FirstName() + " " + LastName();
      }
 }

 class Person extends PersonBase
 {
      string FirstName(); 
      string LastName();
 }

 class FakePerson extends PersonBase
 {
      void setFirstName(string);
      void setLastName(string);
      string getFirstName();
      string getLastName();
 }

C'est peut-être ce que vous faisiez depuis le début. Mais je ne pense pas que cette méthode aura les problèmes que j'ai vus avec les méthodes de simulation parce que nous avons clairement défini de quel côté chaque méthode est. Et en utilisant l'héritage, nous évitons la gêne qui pourrait survenir si nous utilisions un objet wrapper supplémentaire.

Cela introduit une certaine complexité, et pour seulement quelques fonctions utilitaires, je les testerais probablement en se moquant de la source tierce sous-jacente. Bien sûr, ils courent un risque accru de rupture, mais cela ne vaut pas la peine d'être réorganisé. Si vous avez un objet assez complexe dont vous avez besoin pour le diviser, alors je pense que quelque chose comme ça est une bonne idée.


8
Bien dit. Si vous essayez de trouver des solutions de contournement dans les tests, vous devrez probablement reconsidérer votre conception (en remarque, j'ai maintenant la voix de Frank Sinatra coincée dans ma tête).
2011 problématique

2
"En se moquant des méthodes publiques afin de tester l'objet, vous faites une hypothèse sur [comment] cet objet est implémenté." Mais n'est-ce pas le cas chaque fois que vous écrasez un objet? Par exemple, supposons que je sache que ma méthode xyz () appelle un autre objet. Afin de tester xyz () isolément, je dois écraser l'autre objet. Par conséquent, mon test doit connaître les détails d'implémentation de ma méthode xyz ().
Utilisateur

Dans mon cas, les méthodes "FirstName ()" et "LastName ()" sont simples mais elles interrogent une API tierce pour leur résultat.
Utilisateur

@Utilisateur, mis à jour, vous vous moquez probablement de l'API tierce pour tester FirstName et LastName. Quel est le problème avec la même moquerie lors du test de FullName?
Winston Ewert

@ Winston, c'est un peu mon argument, actuellement je me moque de l'API tierce utilisée dans prénom et nom afin de tester fullname (), mais je préférerais ne pas me soucier de la façon dont le prénom et le nom sont implémentés lors du test de fullname (bien sûr ça va quand je teste le prénom et le nom). D'où ma question sur le prénom et le nom moqueurs.
Utilisateur

10

Bien que je sois d'accord avec la réponse de Winston Ewert, il n'est parfois tout simplement pas possible de diviser une classe, que ce soit en raison de contraintes de temps, de l'API déjà utilisée ailleurs ou de ce que vous avez.

Dans un cas comme celui-ci, au lieu de méthodes moqueuses, j'écrirais mes tests pour couvrir la classe de manière incrémentielle. Testez les méthodes getExpenses()et getRevenue(), puis testez les getProfit()méthodes, puis testez les méthodes partagées. Il est vrai que vous aurez plus d'un test couvrant une méthode particulière, mais puisque vous avez écrit des tests réussis pour couvrir les méthodes individuellement, vous pouvez être sûr que vos sorties sont fiables compte tenu d'une entrée testée.


1
D'accord. Mais juste pour souligner le point pour quiconque lit ceci, c'est la solution que vous utilisez si vous ne pouvez pas faire la meilleure solution.
Winston Ewert

5
@Winston, et c'est la solution que vous utilisez avant d' arriver à une meilleure solution. C'est-à-dire ayant une grosse boule de code hérité, vous devez le couvrir avec des tests unitaires avant de pouvoir le refactoriser.
Péter Török

@ Péter Török, bon point. Bien que je pense que cela est couvert sous "ne peut pas faire la meilleure solution." :)
Winston Ewert

@all Tester la méthode getProfit () sera très difficile car les différents scénarios pour getExpenses () et getRevenues () multiplieront en fait les scénarios nécessaires pour getProfit (). si nous testons getExpenses () et get Revenus () indépendamment N'est-ce pas une bonne idée de se moquer de ces méthodes pour tester getProfit ()?
Mohd Farid

7

Pour simplifier davantage vos exemples, disons que vous testez C(), ce qui dépend de A()et B(), chacun ayant ses propres dépendances. OMI, cela revient à ce que votre test tente de réaliser.

Si vous testez le comportement de comportements C()connus donnés de A()et B()alors il est probablement plus facile et meilleur de se détacher A()et B(). Ce serait probablement appelé un test unitaire par les puristes.

Si vous testez le comportement de l'ensemble du système (du C()point de vue de), je quitterais A()et B()tel qu'implémenté et soit bloquer leurs dépendances (si cela permet de tester) ou configurer un environnement sandbox, tel qu'un test base de données. J'appellerais cela un test d'intégration.

Les deux approches peuvent être valides, donc si elles sont conçues pour que vous puissiez les tester dans un sens ou dans l'autre, ce sera probablement mieux à long terme.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.