Existe-t-il un moyen plus simple de tester la validation des arguments et l'initialisation des champs dans un objet immuable?

Mon domaine se compose de beaucoup de classes simples et immuables comme ceci:

public class Person
{
    public string FullName { get; }
    public string NameAtBirth { get; }
    public string TaxId { get; }
    public PhoneNumber PhoneNumber { get; }
    public Address Address { get; }

    public Person(
        string fullName,
        string nameAtBirth,
        string taxId,
        PhoneNumber phoneNumber,
        Address address)
    {
        if (fullName == null)
            throw new ArgumentNullException(nameof(fullName));
        if (nameAtBirth == null)
            throw new ArgumentNullException(nameof(nameAtBirth));
        if (taxId == null)
            throw new ArgumentNullException(nameof(taxId));
        if (phoneNumber == null)
            throw new ArgumentNullException(nameof(phoneNumber));
        if (address == null)
            throw new ArgumentNullException(nameof(address));

        FullName = fullName;
        NameAtBirth = nameAtBirth;
        TaxId = taxId;
        PhoneNumber = phoneNumber;
        Address = address;
    }
}

L'écriture des contrôles nuls et de l'initialisation des propriétés devient déjà très fastidieuse, mais j'écris actuellement des tests unitaires pour chacune de ces classes afin de vérifier que la validation des arguments fonctionne correctement et que toutes les propriétés sont initialisées. Cela ressemble à un travail extrêmement ennuyeux avec des avantages incommensurables.

La vraie solution serait que C# prenne en charge nativement les types de référence immuabilité et non nullable. Mais que puis-je faire pour améliorer la situation entre-temps? Vaut-il la peine d'écrire tous ces tests? Serait-ce une bonne idée d'écrire un générateur de code pour de telles classes afin d'éviter d'écrire des tests pour chacune d'entre elles?


Voici ce que j'ai maintenant basé sur les réponses.

Je pourrais simplifier les contrôles null et l'initialisation de la propriété pour ressembler à ceci:

FullName = fullName.ThrowIfNull(nameof(fullName));
NameAtBirth = nameAtBirth.ThrowIfNull(nameof(nameAtBirth));
TaxId = taxId.ThrowIfNull(nameof(taxId));
PhoneNumber = phoneNumber.ThrowIfNull(nameof(phoneNumber));
Address = address.ThrowIfNull(nameof(address));

En utilisant l'implémentation suivante de Robert Harvey :

public static class ArgumentValidationExtensions
{
    public static T ThrowIfNull(this T o, string paramName) where T : class
    {
        if (o == null)
            throw new ArgumentNullException(paramName);

        return o;
    }
}

Il est facile de tester les vérifications nulles à l’aide de GuardClauseAssertion de AutoFixture.Idioms (merci pour la suggestion, Esben Skov Pedersen ):

var fixture = new Fixture().Customize(new AutoMoqCustomization());
var assertion = new GuardClauseAssertion(fixture);
assertion.Verify(typeof(Address).GetConstructors());

Cela pourrait être compressé encore plus loin:

typeof(Address).ShouldNotAcceptNullConstructorArguments();

En utilisant cette méthode d'extension:

public static void ShouldNotAcceptNullConstructorArguments(this Type type)
{
    var fixture = new Fixture().Customize(new AutoMoqCustomization());
    var assertion = new GuardClauseAssertion(fixture);

    assertion.Verify(type.GetConstructors());
}
14
Pour votre information, vous pourriez écrire un modèle T4 qui faciliterait ce genre de comportement. (Vous pouvez également envisager string.IsEmpty() au-delà de == null.)
ajouté l'auteur Ayyash, source
Le titre/la balise parle du test (unitaire) des valeurs de champ impliquant un test unitaire post-construction de l'objet, mais le fragment de code montre la validation des arguments/paramètres d'entrée; ce ne sont pas les mêmes concepts.
ajouté l'auteur Ayyash, source
Vous pouvez également utiliser Fody/NullGuard , même s'il semble ne pas disposer du mode autorisation par défaut. .
ajouté l'auteur Wesley, source
@EsbenSkovPedersen Génial, cela semble être la meilleure solution à ce jour! (n'hésitez pas à l'ajouter comme réponse)
ajouté l'auteur CreepStar, source
Le modèle de générateur est certes un bon moyen de simplifier l’interface publique, mais il présente peu d’avantages car (1) tous les arguments sont obligatoires. Vous devez donc vérifier si tous ont été spécifiés dans la méthode de implémentation assez lourde (2) C# a nommé des arguments (new Person (fullName: foo, taxId: bar, ...))
ajouté l'auteur CreepStar, source
@ jpmc26 c'est une nouveauté en C# 6. Ce sont des propriétés en lecture seule qui peuvent être initialisées dans le constructeur mais jamais modifiées ultérieurement. Ils facilitent un peu la mise en œuvre d'objets immuables. (Et plus sûr: même la classe propriétaire ne peut pas les modifier en dehors du constructeur)
ajouté l'auteur CreepStar, source
Découvrez la réponse acceptée à cette question: stackoverflow.com/questions/222214/… son explication est très jolie avec le modèle de générateur pour la construction de vos objets.
ajouté l'auteur xirt, source
Avez-vous jeté un coup d'oeil aux idiomes de l'auto-fixation? nuget.org/packages/AutoFixture.Idioms
ajouté l'auteur mkuse, source
ajouté l'auteur Peter LeFanu Lumsdaine, source
Ces propriétés n'ont-elles pas besoin d'un ensemble privé ? Ou est-ce quelque chose de nouveau?
ajouté l'auteur jmibanez, source

8 Réponses

Vous pouvez obtenir un peu d'amélioration avec une simple refactorisation qui peut faciliter le problème de l'écriture de toutes ces barrières. Tout d'abord, vous avez besoin de cette méthode d'extension:

internal static T ThrowIfNull(this T o, string paramName) where T : class
{
    if (o == null)
        throw new ArgumentNullException(paramName);

    return o;
}

Vous pouvez alors écrire:

public class Person
{
    public string FullName { get; }
    public string NameAtBirth { get; }
    public string TaxId { get; }
    public PhoneNumber PhoneNumber { get; }
    public Address Address { get; }

    public Person(
        string fullName,
        string nameAtBirth,
        string taxId,
        PhoneNumber phoneNumber,
        Address address)
    {
        FullName = fullName.ThrowIfNull(nameof(fullName));
        NameAtBirth = nameAtBirth.ThrowIfNull(nameof(nameAtBirth));
        TaxId = taxId.ThrowIfNull(nameof(taxId));
        PhoneNumber = phoneNumber.ThrowIfNull(nameof(fullName));
        Address = address.ThrowIfNull(nameof(address));
    }
}

Le renvoi du paramètre d'origine dans la méthode d'extension crée une interface fluide, vous pouvez donc étendre ce concept avec d'autres méthodes d'extension si vous le souhaitez et les chaîner dans votre affectation.

D'autres techniques sont plus élégantes dans leur concept, mais de plus en plus complexes dans leur exécution, telles que la décoration du paramètre avec un attribut [NotNull] et l'utilisation de Reflection comme this .

Cela dit, il se peut que vous n’ayez pas besoin de tous ces tests, à moins que votre classe ne fasse partie d’une API publique.

12
ajouté
@ jpmc26: naturellement. Mais c'est également vrai si vous l'écrivez de manière conventionnelle, comme illustré dans le PO. Toute la méthode habituelle est de déplacer le throw en dehors du constructeur; vous ne transférez pas vraiment le contrôle au sein du constructeur ailleurs. C’est juste une méthode utilitaire et un moyen de faire les choses couramment , si vous le souhaitez.
ajouté l'auteur sgwill, source
@ jpmc26: Qu'est-ce que cela signifie?
ajouté l'auteur sgwill, source
Bizarre que cela ait été voté. C'est très similaire à l'approche fournie par la réponse la plus votée, sauf que cela ne dépend pas de Roslyn.
ajouté l'auteur sgwill, source
En lisant votre réponse, cela suggère de ne pas tester le constructeur , mais de créer une méthode commune appelée pour chaque paramètre et de la tester. Si vous ajoutez une nouvelle paire propriété/argument et oubliez de tester null sur l'argument du constructeur, vous n'obtiendrez pas un test ayant échoué.
ajouté l'auteur jmibanez, source
Ce que je n'aime pas dans tout ça, c'est qu'il est vulnérable aux modifications du constructeur.
ajouté l'auteur jmibanez, source

Est-il utile d'écrire tous ces tests?

Non, probablement pas. Quelle est la probabilité que vous allez tout gâcher? Quelle est la probabilité que certaines sémantiques changent de statut? Quel est l'impact si quelqu'un le bousille?

Si vous passez beaucoup de temps à faire des tests pour quelque chose qui va rarement se casser, et que c'est une solution triviale si c'est le cas ... peut-être n'en vaut-il pas la peine.

Serait-ce une bonne idée d'écrire un générateur de code pour de telles classes afin d'éviter d'écrire des tests pour chacune d'entre elles?

Maybe? That sort of thing could be done easily with reflection. Something to consider is doing code generation for the real code, so you don't have N classes that may have human error. Bug prevention > bug detection.

5
ajouté
Je vous remercie. Peut-être que ma question n'était pas assez claire mais je voulais écrire un générateur qui génère des classes immuables, pas des tests pour eux
ajouté l'auteur CreepStar, source
J'ai également vu à plusieurs reprises au lieu d'un si ... si vous jetez , ils auront ThrowHelper.ArgumentNull (myArg, "myArg") , ainsi la logique sera toujours là et vous ne vous fâchez pas sur la couverture de code. Où la méthode ArgumentNull fera le si ... throw .
ajouté l'auteur Matthew, source

À court terme, vous ne pouvez pas faire grand chose à propos de la pénibilité de rédiger de tels tests. Toutefois, certaines expressions de projection devraient être intégrées dans la prochaine version de C# (v7), probablement dans les prochains mois:

public class Person
{
    public string FullName { get; }
    public string NameAtBirth { get; }
    public string TaxId { get; }
    public PhoneNumber PhoneNumber { get; }
    public Address Address { get; }

    public Person(
        string fullName,
        string nameAtBirth,
        string taxId,
        PhoneNumber phoneNumber,
        Address address)
    {
        FullName = fullName ?? throw new ArgumentNullException(nameof(fullName));
        NameAtBirth = nameAtBirth ?? throw new ArgumentNullException(nameof(nameAtBirth));
        TaxId = taxId ?? throw new ArgumentNullException(nameof(taxId)); ;
        PhoneNumber = phoneNumber ?? throw new ArgumentNullException(nameof(phoneNumber)); ;
        Address = address ?? throw new ArgumentNullException(nameof(address)); ;
    }
}

Vous pouvez expérimenter avec des expressions de jet via l'application Web d'essai Try Roslyn .

5
ajouté
Beaucoup plus compact, merci. Deux fois moins de lignes. Dans l'attente de C# 7!
ajouté l'auteur CreepStar, source
@ BotondBalázs, vous pouvez l'essayer dès maintenant dans l'aperçu de VS15 si vous le souhaitez
ajouté l'auteur PUNEET JAIN, source

Je suis surpris que personne n'ait encore mentionné NullGuard.Fody . Il est disponible via NuGet et incorporera automatiquement ces contrôles nuls dans le livre lors de la compilation. .

Donc, votre code constructeur serait simplement

public Person(
    string fullName,
    string nameAtBirth,
    string taxId,
    PhoneNumber phoneNumber,
    Address address)
{
    FullName = fullName;
    NameAtBirth = nameAtBirth;
    TaxId = taxId;
    PhoneNumber = phoneNumber;
    Address = address;
}

et NullGuard ajoutera ces contrôles nuls pour vous en le transformant exactement en ce que vous avez écrit.

Notez cependant que NullGuard est opt-out , c'est-à-dire qu'il ajoutera ces vérifications nulles à toutes les méthodes et les arguments des constructeurs, les propriétés getter et setter des propriétés et même les valeurs de retour des méthodes de vérification sauf si vous autorisez une valeur null avec l'attribut [AllowNull] .

5
ajouté
Oui, c'est raisonnable, mais cela contrevient au principe de moindre surprise . Si un nouveau programmeur ignore que le projet utilise NullGuard, la surprise est grande! En passant, je ne comprends pas non plus le vote négatif, c’est une réponse très utile.
ajouté l'auteur CreepStar, source
Merci, cela ressemble à une très bonne solution générale. Il est cependant très regrettable qu’il modifie le comportement du langage par défaut. J'aimerais beaucoup plus si cela fonctionnait en ajoutant un attribut [NotNull] au lieu de ne pas autoriser la valeur null à être la valeur par défaut.
ajouté l'auteur CreepStar, source
@ BotondBalázs: PostSharp a cela avec d'autres attributs de validation de paramètre. Contrairement à Fody, ce n’est pas gratuit. En outre, le code généré appelle les DLL PostSharp, vous devez donc les inclure dans votre produit. Fody n'exécutera son code que lors de la compilation et ne sera pas nécessaire à l'exécution.
ajouté l'auteur Roman Reiner, source
@ BotondBalázs: Au début, j'ai aussi trouvé étrange que NullGuard s'applique par défaut, mais cela a du sens. Dans tout code de base raisonnable, autoriser la valeur null devrait être beaucoup moins courant que la vérification de la valeur null. Si vous vraiment souhaitez autoriser la suppression de null, l’approche de retrait vous oblige à faire preuve de beaucoup de minutie.
ajouté l'auteur Roman Reiner, source
Pourquoi le vote à la baisse?
ajouté l'auteur Roman Reiner, source

J'ai créé un modèle t4 exactement pour ce type de cas. Pour éviter d'écrire beaucoup de passe-partout pour les classes immuables.

https://github.com/xaviergonz/T4Immutable T4Immutable is a T4 template for C# .NET apps that generates code for immutable classes.

Parlant spécifiquement de tests non nuls, alors si vous utilisez ceci:

[PreNotNullCheck, PostNotNullCheck]
public string FirstName { get; }

Le constructeur sera ceci:

public Person(string firstName) {
 //pre not null check
  if (firstName == null) throw new ArgumentNullException(nameof(firstName));

 //assignations + PostConstructor() if needed

 //post not null check
  if (this.FirstName == null) throw new NullReferenceException(nameof(this.FirstName));
}

Cela dit, si vous utilisez JetBrains Annotations pour la vérification de null, vous pouvez également le faire:

[JetBrains.Annotations.NotNull, ConstructorParamNotNull]
public string FirstName { get; }

And Le constructeur sera ceci:

public Person([JetBrains.Annotations.NotNull] string firstName) {
 //pre not null check is implied by ConstructorParamNotNull
  if (firstName == null) throw new ArgumentNullException(nameof(firstName));

  FirstName = firstName;
 //+ PostConstructor() if needed

 //post not null check implied by JetBrains.Annotations.NotNull on the property
  if (this.FirstName == null) throw new NullReferenceException(nameof(this.FirstName));
}

Il existe également quelques fonctionnalités supplémentaires.

3
ajouté
Je vous remercie. Je viens d'essayer et cela a fonctionné parfaitement. Je marque ceci comme la réponse acceptée car elle aborde toutes les questions de la question initiale.
ajouté l'auteur CreepStar, source

Vous pouvez toujours écrire une méthode comme celle-ci:

// Just to illustrate how to call this
private static void SomeMethod(string a, string b, string c, string d)
    {
        ValidateArguments(a, b, c, d);
       //...
    }

   //This is the one to use as a utility function
    private static void ValidateArguments(params object[] args)
    {
        for (int i = 0; i < args.Length; i++)
        {
            if (args[i] == null)
            {
                StackTrace trace = new StackTrace();
               //Get the method that called us
                MethodBase info = trace.GetFrame(1).GetMethod();

               //Get information on the parameter that is null so we can add its name to the exception
                ParameterInfo param = info.GetParameters()[i];

               //Raise the exception on behalf of the caller
                throw new ArgumentNullException(param.Name);
            }
        }
    }

Au minimum, cela vous évitera de taper si vous avez plusieurs méthodes nécessitant ce type de validation. Bien entendu, cette solution suppose que aucun des paramètres de votre méthode peut être null , mais vous pouvez le modifier pour le modifier si vous le souhaitez.

Vous pouvez également l'étendre pour effectuer d'autres validations spécifiques au type. Par exemple, si vous avez une règle selon laquelle les chaînes ne peuvent pas être uniquement des espaces, vous pouvez ajouter la condition suivante:

// Note that we already know based on the previous condition that args[i] is not null
else if (args[i].GetType() == typeof(string))
            {
                string argCast = arg as string;

                if (!argCast.Trim().Any())
                {
                    ParameterInfo param = GetParameterInfo(i);

                    throw new ArgumentException(param.Name + " is empty or consists only of whitespace");
                }
            }

La méthode GetParameterInfo à laquelle je me réfère fait essentiellement la réflexion (de sorte que je n'ai pas à taper la même chose encore et encore, ce qui constituerait une violation de la Principe de DRY ):

private static ParameterInfo GetParameterInfo(int index)
    {
        StackTrace trace = new StackTrace();

       //Note that we have to go 2 methods back to get the ValidateArguments method's caller
        MethodBase info = trace.GetFrame(2).GetMethod();

       //Get information on the parameter that is null so we can add its name to the exception
        ParameterInfo param = info.GetParameters()[index];

        return param;
    }
2
ajouté
Pourquoi cela est-il voté? Ce n'est pas si mal
ajouté l'auteur Gaspa79, source

Est-il utile d'écrire tous ces tests?

No.
Parce que je suis à peu près sûr que vous avez testé ces propriétés au moyen d'autres tests de logique où ces classes sont utilisées.

Par exemple, vous pouvez avoir des tests pour la classe Factory ayant une assertion basée sur ces propriétés (Instance créée avec la propriété Nom affectée correctement, par exemple).

Si ces classes sont exposées à l'API publique utilisée par un tiers partie/utilisateur final (@EJoshua, merci de l'avoir remarqué), il peut être utile de tester le ArgumentNullException attendu//>.

En attendant C# 7, vous pouvez utiliser la méthode d’extension.

public MyClass(string name)
{
    name.ThrowArgumentNullExceptionIfNull(nameof(name));
}

public static void ThrowArgumentNullExceptionIfNull(this object value, string paramName)
{
    if(value == null)
        throw new ArgumentNullException(paramName);
}

Pour tester, vous pouvez utiliser une méthode de test paramétrée qui utilise la réflexion pour créer une référence null pour chaque paramètre et assert pour l’exception attendue.

2
ajouté
C'est un argument convaincant pour ne pas tester l'initialisation de la propriété.
ajouté l'auteur CreepStar, source
Cela dépend de la façon dont vous utilisez le code. Si le code fait partie d'une bibliothèque publique, vous ne devez jamais faire aveuglément confiance à d'autres personnes pour savoir comment accéder à votre bibliothèque (surtout si elles n'ont pas accès au code source); Cependant, si vous utilisez simplement quelque chose en interne pour faire fonctionner votre propre code, vous devez savoir comment utiliser votre propre code afin que les contrôles aient un but moindre.
ajouté l'auteur Joan Barros, source

Je ne suggère pas de lancer des exceptions du constructeur. Le problème est que vous aurez du mal à tester cela car vous devez passer des paramètres valides même s'ils ne sont pas pertinents pour votre test.

Par exemple: Si vous voulez vérifier si le troisième paramètre lève une exception, vous devez transmettre correctement le premier et le second. Donc, votre test n'est plus isolé. lorsque les contraintes des premier et deuxième paramètres changent, ce cas de test échouera même si le troisième paramètre est testé.

Je suggère d'utiliser l'API de validation Java et d'externaliser le processus de validation. Je suggère que quatre responsabilités (classes) soient impliquées:

  1. Un objet de suggestion avec des paramètres annotés de validation Java avec une méthode de validation qui renvoie un ensemble de constraintviolations. Un avantage est que vous pouvez faire circuler ces objets sans que l'assertion ne soit valide. Vous pouvez retarder la validation jusqu'à ce que cela soit nécessaire sans avoir à tenter d'instancier l'objet de domaine. L'objet de validation peut être utilisé dans différentes couches car il s'agit d'un POJO sans connaissance spécifique de la couche. Cela peut faire partie de votre API publique.

  2. Une fabrique pour l'objet de domaine responsable de la création d'objets valides. Vous transmettez l'objet de suggestion et la fabrique appellera la validation et créera l'objet de domaine si tout va bien.

  3. L'objet de domaine lui-même, qui devrait être en grande partie inaccessible pour une instanciation par d'autres développeurs. À des fins de test, je suggère d’avoir cette classe dans la portée du paquet.

  4. Une interface de domaine public permettant de masquer l'objet de domaine concret et d'empêcher les abus.

Je ne suggère pas de vérifier les valeurs nulles. Dès que vous entrez dans la couche de domaine, vous vous débarrassez de la valeur null ou de la valeur null, tant que vous n'avez pas de chaînes d'objet comportant des fins ou des fonctions de recherche pour des objets isolés.

Autre point: écrire le moins de code possible n'est pas un indicateur de la qualité du code. Pensez aux compétitions de jeux 32k. Ce code est le code le plus compact mais aussi le plus désordonné que vous puissiez avoir car il ne se préoccupe que des problèmes techniques et ne se soucie pas de la sémantique. Mais la sémantique est ce qui rend les choses compréhensives.

0
ajouté
Je suis respectueusement en désaccord avec votre dernier point. Ne pas avoir à taper le même caractère pour la centième fois que aide à réduire le risque de se tromper. Imaginez s'il s'agissait de Java, pas de C #. Je devrais définir un champ de sauvegarde et une méthode getter pour chaque propriété. Le code deviendrait complètement illisible et ce qui importait serait masqué par l’infrastructure lourde.
ajouté l'auteur CreepStar, source