Tests unitaires dans un monde «sans setter»

23

Je ne me considère pas comme un expert DDD mais, en tant qu'architecte de solution, j'essaie d'appliquer les meilleures pratiques chaque fois que possible. Je sais qu'il y a beaucoup de discussions autour du pour et du contre du "style" de setter (public) dans DDD et je peux voir les deux côtés de l'argument. Mon problème est que je travaille dans une équipe avec une grande diversité de compétences, de connaissances et d'expérience, ce qui signifie que je ne peux pas croire que chaque développeur fera les choses de la "bonne" manière. Par exemple, si nos objets de domaine sont conçus de sorte que les modifications de l'état interne de l'objet soient effectuées par une méthode mais fournissent des paramètres de propriété publique, quelqu'un définira inévitablement la propriété au lieu d'appeler la méthode. Utilisez cet exemple:

public class MyClass
{
    public Boolean IsPublished
    {
        get { return PublishDate != null; }
    }

    public DateTime? PublishDate { get; set; }

    public void Publish()
    {
        if (IsPublished)
            throw new InvalidOperationException("Already published.");

        PublishDate = DateTime.Today;

        Raise(new PublishedEvent());
    }
}

Ma solution a été de rendre les setters privés privés, ce qui est possible car l'ORM que nous utilisons pour hydrater les objets utilise la réflexion pour pouvoir accéder aux setters privés. Cependant, cela présente un problème lors de l'écriture de tests unitaires. Par exemple, lorsque je veux écrire un test unitaire qui vérifie l'exigence selon laquelle nous ne pouvons pas republier, je dois indiquer que l'objet a déjà été publié. Je peux certainement le faire en appelant deux fois Publish, mais mon test suppose que Publish est correctement implémenté pour le premier appel. Cela semble un peu malodorant.

Rendons le scénario un peu plus réel avec le code suivant:

public class Document
{
    public Document(String title)
    {
        if (String.IsNullOrWhiteSpace(title))
            throw new ArgumentException("title");

        Title = title;
    }

    public String ApprovedBy { get; private set; }
    public DateTime? ApprovedOn { get; private set; }
    public Boolean IsApproved { get; private set; }
    public Boolean IsPublished { get; private set; }
    public String PublishedBy { get; private set; }
    public DateTime? PublishedOn { get; private set; }
    public String Title { get; private set; }

    public void Approve(String by)
    {
        if (IsApproved)
            throw new InvalidOperationException("Already approved.");

        ApprovedBy = by;
        ApprovedOn = DateTime.Today;
        IsApproved = true;

        Raise(new ApprovedEvent(Title));
    }

    public void Publish(String by)
    {
        if (IsPublished)
            throw new InvalidOperationException("Already published.");

        if (!IsApproved)
            throw new InvalidOperationException("Cannot publish until approved.");

        PublishedBy = by;
        PublishedOn = DateTime.Today;
        IsPublished = true;

        Raise(new PublishedEvent(Title));
    }
}

Je veux écrire des tests unitaires qui vérifient:

  • Je ne peux publier que si le document a été approuvé
  • Je ne peux pas republier un document
  • Lorsqu'elles sont publiées, les valeurs PublishedBy et PublishedOn sont correctement définies
  • Une fois publié, le PublishedEvent est déclenché

Sans accès aux setters, je ne peux pas mettre l'objet dans l'état nécessaire pour effectuer les tests. Ouvrir l'accès aux colons va à l'encontre du but d'empêcher l'accès.

Comment (avez-vous) résolu (d) ce problème?

SonOfPirate
la source
Plus j'y pense, plus je pense que tout votre problème est d'avoir des méthodes avec des effets secondaires. Ou plutôt, un objet immuable mutable. Dans un monde DDD, ne devez-vous pas renvoyer un nouvel objet Document à la fois Approuver et Publier, plutôt que de mettre à jour l'état interne de cet objet?
pdr
1
Question rapide, quel O / RM utilisez-vous. Je suis un grand fan d'EF, mais déclarer les setters protégés ne me frotte pas un peu.
Michael Brown
Nous avons un mélange en ce moment en raison du développement en plein air dans lequel j'ai été chargé de me débattre. Certains ADO.NET utilisant AutoMapper pour s'hydrater à partir d'un DataReader, quelques modèles Linq-to-SQL (qui seront les prochains à remplacer ) et quelques nouveaux modèles EF.
SonOfPirate
Appeler Publier deux fois n'est pas malodorant du tout et c'est la façon de le faire.
Piotr Perak

Réponses:

27

Je ne peux pas mettre l'objet dans l'état requis pour effectuer les tests.

Si vous ne pouvez pas mettre l'objet dans l'état requis pour effectuer un test, vous ne pouvez pas mettre l'objet dans l'état dans le code de production, il n'est donc pas nécessaire de tester cet état. Évidemment, ce n'est pas vrai dans votre cas, vous pouvez mettre votre objet dans l'état requis, appelez simplement Approuver.

  • Je ne peux pas publier à moins que le document n'ait été approuvé: écrivez un test qui appelle publier avant d'appeler approuver provoque la bonne erreur sans changer l'état de l'objet.

    void testPublishBeforeApprove() {
        doc = new Document("Doc");
        AssertRaises(doc.publish, ..., NotApprovedException);
    }
    
  • Je ne peux pas republier un document: écrire un test qui approuve un objet, puis appeler publier une fois réussit, mais la deuxième fois provoque la bonne erreur sans changer l'état de l'objet.

    void testRePublish() {
        doc = new Document("Doc");
        doc.approve();
        doc.publish();
        AssertRaises(doc.publish, ..., RepublishException);
    }
    
  • Une fois publiées, les valeurs PublishedBy et PublishedOn sont correctement définies: écrire un test qui appelle approuver puis appeler publier, affirmer que l'état de l'objet change correctement

    void testPublish() {
        doc = new Document("Doc");
        doc.approve();
        doc.publish();
        Assert(doc.PublishedBy, ...);
        ...
    }
    
  • Une fois publié, le PublishedEvent est déclenché: accrochez-vous au système d'événements et définissez un indicateur pour vous assurer qu'il est appelé

Vous devez également écrire un test pour approuver.

En d'autres termes, ne testez pas la relation entre les champs internes et IsPublished et IsApproved, votre test serait assez fragile si vous le faites car changer votre champ signifierait changer votre code de test, donc le test serait tout à fait inutile. Au lieu de cela, vous devez tester la relation entre les appels de méthodes publiques, de cette façon, même si vous modifiez les champs, vous n'auriez pas besoin de modifier le test.

Lie Ryan
la source
Lorsque Approuver se casse, plusieurs tests se cassent. Vous ne testez plus une unité de code, vous testez l'implémentation complète.
pdr
Je partage l'inquiétude de pdr, c'est pourquoi j'ai hésité à aller dans cette direction. Oui, cela semble plus propre, mais je n'aime pas avoir plusieurs raisons pour lesquelles un test individuel peut échouer.
SonOfPirate
4
Je n'ai pas encore vu de test unitaire qui ne pourrait échouer que pour une seule raison possible. De plus, vous pouvez mettre les parties "manipulation d'état" du test dans une setup()méthode --- pas le test lui-même.
Peter K.
12
Pourquoi est dépendant d'une approve()manière ou d'une autre fragile, mais dépendant d'une setApproved(true)manière ou d'une autre non? approve()est une dépendance légitime dans les tests car c'est une dépendance dans les exigences. Si la dépendance n'existait que dans les tests, ce serait un autre problème.
Karl Bielefeldt
2
@pdr, comment testeriez-vous une classe de pile? Souhaitez-vous essayer de tester les méthodes push()et pop()indépendamment?
Winston Ewert
2

Une autre approche consiste à créer un constructeur de la classe qui permet de définir les propriétés internes lors de l'instanciation:

 public Document(
  String approvedBy,
  DateTime? approvedOn,
  Boolean isApproved,
  Boolean isPublished,
  String publishedBy,
  DateTime? publishedOn,
  String title)
{
  ApprovedBy = approvedBy;
  ApprovedOn = approvedOn;
  IsApproved = isApproved;
  IsApproved = isApproved;
  PublishedBy = publishedBy;
  PublishedOn = publishedOn;
}
Peter K.
la source
2
Cela n'évolue pas du tout bien. Mon objet pourrait avoir beaucoup plus de propriétés avec un nombre quelconque d'entre elles ayant ou non des valeurs à un moment donné du cycle de vie de l'objet. Je respecte le principe selon lequel les constructeurs contiennent des paramètres pour les propriétés qui sont nécessaires pour que l'objet soit dans un état initial valide ou les dépendances qu'un objet nécessite pour fonctionner. Le but des propriétés dans l'exemple est de capturer l'état actuel lorsque l'objet est manipulé. Avoir un constructeur avec chaque propriété ou surcharges avec différentes combinaisons est une énorme odeur et, comme je l'ai dit, n'est pas à l'échelle.
SonOfPirate
Compris. Votre exemple n'a pas mentionné beaucoup plus de propriétés, et le nombre dans l'exemple est «sur le point» d'avoir ceci comme approche valide. Il semble que cela vous indique quelque chose sur votre conception: vous ne pouvez pas mettre votre objet dans un état valide lors de l'instanciation. Cela signifie que vous devez le mettre dans un état initial valide et les manipuler dans le bon état pour le test. Cela implique que la réponse de Lie Ryan est la voie à suivre .
Peter K.
Même si l'objet a une propriété et ne changera jamais, cette solution est mauvaise. Qu'est-ce qui empêche quelqu'un d'utiliser ce constructeur en production? Comment allez-vous marquer ce constructeur [TestOnly]?
Piotr Perak
Pourquoi est-il mauvais en production? (Vraiment, j'aimerais savoir). Parfois, il est nécessaire de recréer l'état précis d'un objet à la création ... pas seulement un seul objet initial valide.
Peter K.
1
Ainsi, bien que cela aide à mettre l'objet dans un état initial valide, le test du comportement de l'objet lors de sa progression tout au long de son cycle de vie nécessite que l'objet soit changé de son état initial. Mon OP a à voir avec le test de ces états supplémentaires lorsque vous ne pouvez pas simplement définir des propriétés pour changer l'état de l'objet.
SonOfPirate
1

Une stratégie consiste à hériter de la classe (dans ce cas, Document) et à écrire des tests sur la classe héritée. La classe héritée permet de définir l’état de l’objet dans les tests.

En C #, une stratégie pourrait être de rendre les setters internes, puis d'exposer les internes au projet de test.

Vous pouvez également utiliser l'API de classe comme vous l'avez décrit ("Je peux certainement le faire en appelant Publier deux fois"). Ce serait définir l'état de l'objet en utilisant les services publics de l'objet, cela ne me semble pas trop malodorant. Dans le cas de votre exemple, ce serait probablement la façon dont je le ferais.

simoraman
la source
J'ai pensé à cela comme une solution possible, mais j'ai hésité à rendre mes propriétés remplaçables ou à exposer les poseurs comme protégés parce que j'avais l'impression d'ouvrir l'objet et de rompre l'encapsulation. Je pense que rendre les propriétés protégées est certainement mieux que public ou même interne / ami. Je vais certainement réfléchir davantage à cette approche. C'est simple et efficace. Parfois, c'est la meilleure approche. Si quelqu'un n'est pas d'accord, veuillez ajouter des commentaires avec des détails.
SonOfPirate
1

Pour tester dans l'isolement absolu les commandes et les requêtes que les objets de domaine reçoivent, je suis utilisé pour fournir à chaque test une sérialisation de l'objet dans l'état attendu. Dans la section d'arrangement du test, il charge l'objet à tester à partir d'un fichier que j'ai préparé précédemment. Au début, j'ai commencé avec des sérialisations binaires, mais json s'est révélé beaucoup plus facile à maintenir. Cela s'est avéré efficace, chaque fois que l'isolement absolu dans les tests fournit une valeur réelle.

éditez juste une note, parfois la sérialisation JSON échoue (comme dans le cas des graphiques d'objets cycliques, qui sont une odeur, au fait). Dans de telles situations, je sauve la sérialisation binaire. C'est un peu pragmatique, mais ça marche. :-)

Giacomo Tesio
la source
Et comment préparez-vous l'objet dans l'état attendu s'il n'y a pas de setter et que vous ne voulez pas appeler ses méthodes publiques pour le configurer?
Piotr Perak
J'ai écrit un petit outil pour ça. Il charge une classe par réflexion qui crée une nouvelle entité en utilisant son constructeur public (ne prenant généralement que l'identifiant) et appelle un tableau d'Action <TEntity> dessus, enregistrant un instantané après chaque opération (avec un nom conventionnel basé sur l'index de l'action) et le nom de l'entité). L'outil est exécuté manuellement à chaque refactorisation du code d'entité et les instantanés sont suivis par le DCVS. Évidemment, chaque action appelle une commande publique de l'entité, mais cela se fait à partir des cycles de tests qui sont de cette façon vraiment des tests unitaires .
Giacomo Tesio
Je ne comprends pas comment cela change quoi que ce soit. S'il continue d'appeler des méthodes publiques sur sut (système sous test), ce n'est pas différent, il suffit d'appeler ces méthodes dans le test.
Piotr Perak
Une fois les instantanés produits, ils sont stockés dans des fichiers. Chaque test ne dépend pas de la séquence des opérations nécessaires pour obtenir l'état de départ de l'entité, mais de l'état lui-même (chargé à partir de l'instantané). La méthode testée elle-même est ensuite isolée des modifications apportées aux autres méthodes.
Giacomo Tesio
Que se passe-t-il lorsque quelqu'un modifie la méthode publique utilisée pour préparer l'état sérialisé pour vos tests mais oublie d'exécuter l'outil pour régénérer l'objet sérialisé? Les tests sont toujours verts même s'il y a une erreur dans le code. Pourtant, je dis que cela ne change rien. Vous exécutez toujours des méthodes publiques afin de configurer les objets que vous testez. Mais vous les exécutez bien avant l'exécution des tests.
Piotr Perak
-7

Vous dites

essayez d'appliquer les meilleures pratiques autant que possible

et

l'ORM que nous utilisons pour hydrater les objets utilise la réflexion pour pouvoir accéder aux setters privés

et je dois penser que l'utilisation de la réflexion pour contourner les contrôles d'accès sur vos classes n'est pas ce que je qualifierais de "meilleure pratique". Ça va être horriblement lent aussi.


Personnellement, je supprimerais votre cadre de tests unitaires et j'irais avec quelque chose dans la classe - il semble que vous écrivez des tests du point de vue de tester la classe entière de toute façon, ce qui est bien. Dans le passé, pour certains composants délicats qui nécessitaient des tests, j'ai intégré les asserts et le code de configuration dans la classe elle-même (c'était un modèle de conception commun pour avoir une méthode test () dans chaque classe), donc vous créez un client qui instancie simplement un objet et appelle la méthode de test qui peut se configurer comme vous le souhaitez sans méchanceté comme des hacks de réflexion.

Si vous êtes préoccupé par le gonflement du code, enveloppez simplement les méthodes de test dans #ifdefs pour les rendre uniquement disponibles dans le code de débogage (probablement une meilleure pratique en soi)

gbjbaanb
la source
4
-1: La mise au rebut de votre cadre de test et le retour aux méthodes de test à l'intérieur de la classe reviendraient à l'âge sombre des tests unitaires.
Robert Johnson
9
Non -1 de ma part, mais inclure le code de test dans la production est généralement une mauvaise chose (TM) .
Peter K.
Que fait d'autre l'OP? Tenez-vous à visser avec des setters privés?! C'est comme choisir le poison que vous voulez boire. Ma suggestion à l'OP était de mettre le test unitaire en code de débogage, pas en production. D'après mon expérience, placer des tests unitaires dans un projet différent signifie simplement que le projet est étroitement lié à l'original de toute façon, donc d'un PoV dev, il n'y a pas de distinction.
gbjbaanb