web-dev-qa-db-fra.com

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 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'entre 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 par 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;
    }
}

Tester les vérifications nulles est facile en utilisant le 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 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());
}
21
Botond Balázs

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.

5
Javier G.

Vous pouvez obtenir un peu d'amélioration avec une simple refactorisation 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 en utilisant la réflexion comme this .

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.

16
Robert Harvey

À 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 y a de l'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 throw via la webapp Try Roslyn .

7
David Arno

Je suis surpris que personne n'ait mentionné NullGuard.Fody pour l'instant. Il est disponible via NuGet et tissera automatiquement ces contrôles nuls 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 à every méthode et argument constructeur, getter de propriété et setter et même vérifier les valeurs de retour de méthode sauf si vous explicitement autorisez une valeur nulle avec le [AllowNull] attribut.

7
Roman Reiner

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'entre elles?

Peut être? Ce genre de chose pourrait se faire 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.

5
Telastyn

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

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 me réfère 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;
    }

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

Non.
Parce que je suis 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 la propriété Name 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é), alors les tests de ArgumentNullException attendus peuvent ê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 référence null pour chaque paramètre et affirmer l’exception attendue.

2
Fabio

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 Java paramètres de validation annotés 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 est 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 principalement inaccessible pour l'instanciation 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.

0
oopexpert