web-dev-qa-db-fra.com

Mes classes devraient-elles avoir des constructeurs séparés uniquement pour les tests unitaires?

J'aime écrire des classes avec deux constructeurs: un constructeur principal utilisé dans le code de production et un constructeur par défaut juste pour les tests unitaires. Je le fais parce que le constructeur principal crée d'autres dépendances concrètes qui consomment des ressources externes. Je ne peux pas faire tout ça dans un test unitaire.

Donc mes cours ressemblent à ça:

public class DoesSomething : BaseClass
{
    private Foo _thingINeed;
    private Bar _otherThingINeed;
    private ExternalResource _another;

    public DoesSomething()
    {
        // Empty constructor for unit tests
    }

    public DoesSomething(string someUrl, string someThingElse, string blarg)
    {
         _thingINeed = new Foo(someUrl);
         _otherThingINeed = Foo.CreateBar(blarg);
         _another = BlargFactory.MakeBlarg(_thingINeed, _otherThingINeed.GetConfigurationValue("important");
    }
}

C'est un modèle que je suis en train de suivre avec beaucoup de mes classes afin que je puisse écrire des tests unitaires pour eux. J'inclus toujours le commentaire dans le constructeur par défaut pour que les autres puissent dire à quoi il sert. Est-ce un bon moyen d'écrire des classes testables ou existe-t-il une meilleure approche?


Il a été suggéré que cela pourrait être un doublon de "Dois-je avoir un constructeur avec des dépendances injectées et un autre qui les instancie." Ce n'est pas. Cette question concerne une manière différente de créer les dépendances. Lorsque j'utilise le constructeur de test unitaire par défaut, les dépendances ne sont pas du tout créées. Ensuite, je rend la plupart des méthodes publiques et elles n'utilisent pas les dépendances. Ce sont ceux que je teste.

6
Scott Hannen

Ne créez jamais de chemins de code séparés pour les tests unitaires. Cela va à l'encontre de l'objectif des tests unitaires, car vous testez un chemin de code dans vos tests unitaires et en exécutez un autre en production.

Pour une raison qui semble insuffisante parce que je ne fais que vous répéter certaines parties de votre question. Vous savez déjà que vous exécutez un code différent dans vos tests unitaires. Le commentaire que vous mettez dans votre constructeur le dit. C'est comme demander s'il est approprié de prendre le téléphone portable de quelqu'un d'autre parce qu'il lui appartient, pas à vous, et que vous voulez l'avoir.

Un argument du consensus serait-il utile? Ne faites pas ça parce que personne d'autre ne le fait. Quelqu'un qui sait mieux sera choqué. Pire encore, quelqu'un qui ne sait pas mieux pourrait en tirer des leçons, ce qui serait mauvais, car ... j'ai l'impression de tourner en rond. Mais j'espère que cela a un certain poids que d'autres personnes disent que vous ne devriez pas faire ça.

Je pourrais peut-être vous montrer qu'il existe un meilleur moyen: l'inversion de dépendance et l'injection de dépendance. L'inversion des dépendances signifie que vous dépendez d'abstractions (comme les interfaces) au lieu d'implémentations concrètes. C'est impossible si votre classe crée ses propres dépendances, c'est là que l'injection de dépendance entre en jeu.

Au lieu de créer des instances de dépendances, vous définissez des abstractions qui décrivent comment votre classe doit les utiliser. Par exemple, si ExternalResource est quelque chose à partir duquel vous téléchargez des fichiers, vous pouvez créer une interface comme celle-ci:

public interface IFileStorage
{
    byte[] GetFile(string filePath);
}

Ensuite, vous créez votre classe comme ceci:

public class DoesSomething : BaseClass
{
    private readonly IFileStorage _fileStorage;

    public DoesSomething(IFileStorage fileStorage)
    {
        _fileStorage = fileStorage;
    }
}

Maintenant, vous n'avez plus besoin d'un constructeur distinct. Vos tests unitaires peuvent appeler le constructeur real et passer une maquette ou un double de test, ce qui signifie une implémentation de IFileStorage qui retourne exactement ce que vous voulez. De cette façon, vous pouvez écrire des tests sur le comportement de votre classe si le fichier contient ceci ou cela ou rien du tout, sans utiliser une implémentation concrète réelle de IFileStorage.

Par exemple, que faire si vous voulez voir comment votre classe se comporte si IFileStorage renvoie un tableau d'octets vide? Vous pouvez utiliser un framework comme Moq ou simplement faire ceci:

public class FileStorageThatReturnsEmptyArray : IFileStorage
{
    public byte[] GetFile(string filePath)
    {
        return new byte[] {};
    }
}

Votre sujet de test ressemble alors à ceci:

var subject = new DoesSomething(new FileStorageThatReturnsEmptyArray());

N'est-ce pas mieux? Vos tests unitaires valent quelque chose car ils testent votre classe de bout en bout, lui permettant de se comporter exactement comme en production.

Certes, en production, vous aurez une implémentation concrète de IFileStorage au lieu d'un test double. Mais le fait est que pour DoesSomething c'est la même chose. Un IFileStorage est le même qu'un autre.

Est-ce une contradiction, puisque je viens de dire que vous devriez tester le "vrai" code, mais maintenant je recommande d'utiliser des simulateurs? Non. La question portait sur les tests unitaires, ce qui signifie que vous vouliez tester DoesSomething, donc vous utiliseriez des mocks pour tester cette classe. Vous pouvez écrire des tests unitaires supplémentaires pour d'autres classes ou des tests d'intégration pour les tester ensemble. Dans le cadre de cette réponse, si vous allez tester DoesSomething, le fait est que vous ne voudriez pas vous tromper en testant une méthode de non-production de cette classe, pensant que c'est la même chose que tester la méthode de production. (Les méthodes de "production" et de "non-production" ne sont même pas une chose.)

J'espère que montrer comment d'autres développeurs ont résolu ce problème aide.

17
Scott Hannen

Je ne sais pas si cette réponse est différente de celle de Scott Hannen, mais il y a une différence de terminologie et d'intention.


Avec un peu d'inspiration (et de copier-coller) de Quelle est la différence entre un mock & stub? , vous testez du code qui n'utilise pas les dépendances transmises au constructeur. Ce que vous recherchez est un objet factice .

De Les moqueries ne sont pas des talons par Martin Fowler (c'est moi qui souligne):

  • Les objets factices sont transmis mais jamais réellement utilisés. Habituellement, ils sont juste utilisés pour remplir des listes de paramètres.
  • Les faux objets ont en fait des implémentations fonctionnelles, mais prennent généralement un raccourci qui les rend impropres à la production (une base de données en mémoire est un bon exemple).
  • Les talons fournissent des réponses prédéfinies aux appels effectués pendant le test, ne répondant généralement pas du tout à ce qui est en dehors de ce qui est programmé pour le test.
  • Les espions sont des talons qui enregistrent également certaines informations en fonction de la façon dont ils ont été appelés. Une forme de cela pourrait être un service de messagerie électronique qui enregistre le nombre de messages envoyés.
  • Les maquette sont ce dont nous parlons ici: des objets préprogrammés avec des attentes qui forment une spécification des appels qu'ils sont censés recevoir.

Vous avez un constructeur. Il faut lui passer un objet. Votre test n'utilise pas cet objet. Vous avez besoin d'un objet factice qui contient la même interface publique que la dépendance réelle, mais ne fait pas réellement n'importe quoi - en fait, il peut lever des exceptions chaque fois qu'une méthode est appelée dessus.

Je vais utiliser votre exemple de code pour montrer la voie à suivre à partir d'ici.

Tout d'abord, vous avez un constructeur qui fait plus de choses qu'il ne devrait vraiment. Il est temps de refactoriser cela pour créer un nouveau constructeur qui vous permet de passer dans les objets réels:

public class DoesSomething : BaseClass
{
    private Foo _thingINeed;
    private Bar _otherThingINeed;
    private ExternalResource _another;

    public DoesSomething(string someUrl, string someThingElse, string blarg)
        : this(new Foo(someUrl), Foo.CreateBar(blarg), BlargFactory.MakeBlarg(_thingINeed, _otherThingINeed.GetConfigurationValue("important"))
    {
    }

    public DoesSomething(Foo thing1, Bar thing2, ExternalResource thing3)
    {
         _thingINeed = thing1;
         _otherThingINeed = thing2;
         _another = thing3;
    }
}

Notez les différences:

  1. Il n'y a pas de constructeur vide
  2. La signature du constructeur existant existe toujours, prenant en charge la compatibilité descendante avec le code existant
  3. Un nouveau constructeur a été ajouté qui prend un Foo, Bar et ExternalResource
  4. L'ancien constructeur appelle le nouveau constructeur, garantissant que la logique d'initialisation n'est pas dupliquée

Je suppose que ExternalResource est un peu compliqué à créer. Vous avez besoin d'une interface pour cette dépendance, ce qui se traduit par un autre changement:

public class DoesSomething : BaseClass
{
    // ...

    public DoesSomething(Foo thing1, Bar thing2, IExternalResource thing3)
    {
        // ...
    }
}

public interface IExternalResource
{
    // ...
}

public class ExternalResource : IExternalResource
{
    // ...
}

Ensuite, vous devez vous assurer que Foo et Bar ne font aucun travail réel dans leurs constructeurs. De plus, si Foo et Bar ne sont utilisés par aucune méthode d'instance de cette classe, ils ne doivent pas être conservés comme champs! Cela simplifierait davantage les dépendances de cette classe, ce qui rendrait encore plus facile de se moquer des tests unitaires.

Donc, cela vous laisse vraiment créer un objet vide et stupide:

internal class DummyExternalResource : IExternalResource
{
    public void Something()
    {
        throw new NotImplementedException();
    }
}

Créez simplement un objet factice avec toutes les méthodes nécessaires de IExternalResource qui lèvent juste des exceptions. Puis dans vos tests:

// Arrange
var foo = new Foo("fakeFile.text");
var bar = new Bar("fake blarg");
var externalResource = new DummyExternalResource();
var something = new DoesSomething(foo, bar, externalResource);

// Act
something.Bazz();

// Assert
Assert.IsTrue(something.FooBar);

Si à l'avenir un changement de code nécessite d'appeler une méthode sur IExternalDependency, vous obtiendrez un test qui échouera, car une nouvelle dépendance pour la méthode testée a été introduite. Cela devrait obliger les développeurs à réévaluer la couverture des tests de cette méthode.

2
Greg Burghardt