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

20

Mon domaine se compose de nombreuses classes immuables simples comme celle-ci:

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 vérifications nulles et de l'initialisation des propriétés devient déjà très fastidieuse mais actuellement j'écris des tests unitaires pour chacune de ces classes pour 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 immuables et non nullables. 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 pour éviter d'écrire des tests pour chacune d'elles?


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

Je pourrais simplifier les vérifications nulles et l'initialisation des propriétés 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<T>(this T o, string paramName) where T : class
    {
        if (o == null)
            throw new ArgumentNullException(paramName);

        return o;
    }
}

Test des contrôles null est facile à l' aide du GuardClauseAssertionde 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 encore plus compressé:

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());
}
Botond Balázs
la source
3
Le titre / la balise parle de tests (unitaires) des valeurs de champ impliquant des tests unitaires post-construction de l'objet, mais l'extrait de code montre la validation des paramètres / arguments d'entrée; ce ne sont pas les mêmes concepts.
Erik Eidt
2
Pour info, vous pourriez écrire un modèle T4 qui rendrait ce genre de passe-partout facile. (Vous pouvez également considérer string.IsEmpty () au-delà de == null.)
Erik Eidt
1
Avez-vous jeté un œil aux idiomes de montage automatique? nuget.org/packages/AutoFixture.Idioms
Esben Skov Pedersen
1
Vous pouvez également utiliser Fody / NullGuard , bien qu'il semble ne pas avoir de mode d'autorisation par défaut.
Bob

Réponses:

5

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 est un modèle T4 pour les applications C # .NET qui génère du code pour les classes immuables.

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

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

Le constructeur sera le suivant:

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 les annotations JetBrains pour la vérification nulle, vous pouvez également le faire:

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

Et le constructeur sera le suivant:

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 y a aussi quelques fonctionnalités de plus que celle-ci.

Javier G.
la source
Je vous remercie. Je viens de l'essayer et cela a parfaitement fonctionné. Je marque cela comme la réponse acceptée car elle aborde tous les problèmes de la question d'origine.
Botond Balázs
16

Vous pouvez obtenir un peu d'amélioration avec un simple refactoring qui peut atténuer le problème de l'écriture de toutes ces clôtures. Tout d'abord, vous avez besoin de cette méthode d'extension:

internal static T ThrowIfNull<T>(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 retour 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 tous ensemble dans votre affectation.

D'autres techniques sont plus élégantes dans leur concept, mais progressivement plus complexes dans leur exécution, comme la décoration du paramètre avec un [NotNull]attribut et l'utilisation de la réflexion comme celle-ci .

Cela dit, vous n'aurez peut-être pas besoin de tous ces tests, sauf si votre classe fait partie d'une API accessible au public.

Robert Harvey
la source
3
Bizarre que cela ait été rejeté. C'est très similaire à l'approche fournie par la réponse la plus votée, sauf qu'elle ne dépend pas de Roslyn.
Robert Harvey
Ce que je n'aime pas, c'est qu'il est vulnérable aux modifications du constructeur.
jpmc26
@ jpmc26: Qu'est-ce que cela signifie?
Robert Harvey
1
En lisant votre réponse, cela suggère que vous ne testez pas le constructeur , mais à la place, vous créez une méthode commune appelée sur chaque paramètre et testez cela. Si vous ajoutez une nouvelle paire propriété / argument et oubliez de tester nulll'argument du constructeur, vous n'obtiendrez pas de test échoué.
jpmc26
@ jpmc26: Naturellement. Mais cela est également vrai si vous l'écrivez de la manière conventionnelle illustrée dans l'OP. La méthode la plus courante consiste à déplacer l' throwextérieur 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.
Robert Harvey
7

À court terme, il n'y a pas grand-chose que vous puissiez faire au sujet de l'ennui de passer de tels tests. Cependant, il existe une aide à venir avec les expressions throw qui doivent être implémentées dans le cadre de la prochaine version de C # (v7), probablement au cours des 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 projection via la webapp Try Roslyn .

David Arno
la source
Beaucoup plus compact, merci. Deux fois moins de lignes. Dans l'attente de C # 7!
Botond Balázs
@ BotondBalázs, vous pouvez l'essayer dès maintenant dans l'aperçu
VS15
7

Je suis surpris que personne n'ait encore mentionné NullGuard.Fody . Il est disponible via NuGet et tissera automatiquement ces vérifications nulles dans l'IL pendant 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 que vous le transformiez exactement en ce que vous avez écrit.

Notez cependant que NullGuard est opt-out , c'est-à-dire qu'il ajoutera ces vérifications nulles à chaque méthode et argument constructeur, getter et setter de propriété et même vérifier les valeurs de retour de méthode sauf si vous autorisez explicitement une valeur nulle avec l' [AllowNull]attribut.

Roman Reiner
la source
Merci, cela ressemble à une très bonne solution générale. Bien qu'il soit très regrettable qu'il modifie le comportement du langage par défaut. J'aimerais beaucoup plus si cela fonctionnait en ajoutant un [NotNull]attribut au lieu de ne pas autoriser null comme valeur par défaut.
Botond Balázs
@ BotondBalázs: PostSharp a cela avec d'autres attributs de validation des paramètres. Contrairement à Fody, ce n'est pas gratuit. De plus, le code généré appellera 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 requis lors de l'exécution.
Roman Reiner
@ BotondBalázs: J'ai également trouvé un peu bizarre au début que NullGuard s'applique par défaut, mais cela a vraiment du sens. Dans toute base de code raisonnable, autoriser null devrait être beaucoup moins courant que rechercher null. Si vous voulez vraiment autoriser null quelque part, l'approche de retrait vous oblige à être très minutieux avec elle.
Roman Reiner
5
Oui, c'est raisonnable, mais cela viole le principe du moindre étonnement . Si un nouveau programmeur ne sait pas que le projet utilise NullGuard, il est pour une grande surprise! Au fait, je ne comprends pas non plus le downvote, c'est une réponse très utile.
Botond Balázs
1
@ BotondBalázs / Roman, on dirait que NullGuard a un nouveau mode explicite qui change la façon dont il fonctionne par défaut
Kyle W
5

Vaut-il la peine d'écrire tous ces tests?

Non, probablement pas. Quelle est la probabilité que vous allez foirer ça? Quelle est la probabilité que certaines sémantiques changent sous vous? Quel est l'impact si quelqu'un le fait foirer?

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

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

Peut être? Ce genre de chose pourrait être fait facilement avec réflexion. Quelque chose à considérer est la génération de code pour le vrai code, donc vous n'avez pas N classes qui peuvent avoir une erreur humaine. Prévention des bugs> détection des bugs.

Telastyn
la source
Je vous remercie. Peut-être que je n'étais pas assez clair dans ma question mais je voulais écrire un générateur qui génère des classes immuables, pas des tests pour eux
Botond Balázs
2
J'ai également vu plusieurs fois au lieu d'un if...throwqu'ils auront ThrowHelper.ArgumentNull(myArg, "myArg"), de cette façon la logique est toujours là et vous ne vous fiez pas à la couverture du code. Où la ArgumentNullméthode fera le if...throw.
Matthew
2

Vaut-il la peine d'écrire tous ces tests?

Non.
Parce que je suis quasiment sûr que vous avez testé ces propriétés à travers d'autres tests de logique où ces classes sont utilisées.

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

Si ces classes sont exposées à l'API publique utilisée par un tiers utilisateur / utilisateur final (@EJoshua merci de l'avoir remarqué), les tests attendus ArgumentNullExceptionpeuvent être utiles.

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 les tests, vous pouvez utiliser une méthode de test paramétrée qui utilise la réflexion pour créer une nullréférence pour chaque paramètre et affirmer l'exception attendue.

Fabio
la source
1
C'est un argument convaincant pour ne pas tester l'initialisation des propriétés.
Botond Balázs
Cela dépend de la façon dont vous utilisez le code. Si le code fait partie d'une bibliothèque accessible au public, vous ne devez jamais faire aveuglément confiance à d'autres personnes pour savoir comment utiliser votre bibliothèque (surtout si elles n'ont pas accès au code source); cependant, si c'est quelque chose que vous utilisez uniquement en interne pour faire fonctionner votre propre code, vous devez savoir comment utiliser votre propre code, de sorte que les vérifications ont moins d'intérêt.
EJoshuaS
2

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 qui nécessitent ce type de validation. Bien sûr, cette solution suppose qu'aucun des paramètres de votre méthode ne peut l'être null, mais vous pouvez le modifier pour le changer si vous le souhaitez.

Vous pouvez également l'étendre pour effectuer une autre validation spécifique au type. Par exemple, si vous avez une règle selon laquelle les chaînes ne peuvent pas être purement des espaces ou vides, 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 fais référence fait essentiellement la réflexion (de sorte que je n'ai pas à continuer de taper la même chose encore et encore, ce qui serait une violation du principe 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;
    }
EJoshuaS - Réinstallez Monica
la source
1
Pourquoi ce vote est-il sous-estimé? Ce n'est pas trop mal
Gaspa79
0

Je ne suggère pas de lever 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 tester si le troisième paramètre lève une exception, vous devez passer correctement le premier et le second. Votre test n'est donc plus isolé. lorsque les contraintes des premier et deuxième paramètres changent, ce scénario de test échoue même s'il teste le troisième paramètre.

Je suggère d'utiliser l'API de validation Java et d'externaliser le processus de validation. Je suggère d'avoir quatre responsabilités (classes) 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 violations de contraintes. Un avantage est que vous pouvez contourner ces objets sans l'assertion pour être valide. Vous pouvez retarder la validation jusqu'à ce qu'elle soit nécessaire sans avoir à essayer 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 couche. Il peut faire partie de votre API publique.

  2. Une fabrique pour l'objet de domaine qui est responsable de la création d'objets valides. Vous passez l'objet de suggestion et l'usine 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 inaccessible pour les autres développeurs. À des fins de test, je suggère d'avoir cette classe dans la portée du package.

  4. Une interface du domaine public pour masquer l'objet de domaine concret et rendre difficile l'utilisation abusive.

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 passer null ou de retourner null tant que vous n'avez pas de chaînes d'objets qui ont des extrémités ou des fonctions de recherche pour des objets uniques.

Un autre point: écrire le moins de code possible n'est pas une mesure de la qualité du code. Pensez aux compétitions de jeux 32k. Ce code est le code le plus compact mais aussi le plus salissant que vous puissiez avoir car il ne se soucie que des problèmes techniques et ne se soucie pas de la sémantique. Mais la sémantique sont les points qui rendent les choses complètes.

oopexpert
la source
1
Je suis respectueusement en désaccord avec votre dernier point. Ne pas avoir à taper le même passe-partout pour la 100e fois permet de réduire les risques d'erreur. Imaginez que ce soit Java, pas C #. Je devrais définir un champ de support et une méthode getter pour chaque propriété. Le code deviendrait complètement illisible et ce qui est important serait obscurci par l'infrastructure maladroite.
Botond Balázs