Esiste un modo più semplice per testare la convalida degli argomenti e l'inizializzazione dei campi in un oggetto immutabile?


20

Il mio dominio è costituito da molte semplici classi immutabili come questa:

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;
    }
}

Scrivere i controlli null e l'inizializzazione delle proprietà sta già diventando molto noioso, ma attualmente scrivo unit test per ciascuna di queste classi per verificare che la convalida degli argomenti funzioni correttamente e che tutte le proprietà siano inizializzate. Sembra un lavoro estremamente noioso con vantaggi incommensurabili.

La vera soluzione sarebbe per C # supportare nativamente l'immutabilità e i tipi di riferimento non annullabili. Ma cosa posso fare per migliorare la situazione nel frattempo? Vale la pena scrivere tutti questi test? Sarebbe una buona idea scrivere un generatore di codice per tali classi per evitare di scrivere test per ognuna di esse?


Ecco quello che ho ora basato sulle risposte.

Potrei semplificare i controlli null e l'inizializzazione della proprietà in questo modo:

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));

Utilizzando la seguente implementazione di 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;
    }
}

Testare i controlli null è facile usando il GuardClauseAssertionda AutoFixture.Idioms(grazie per il suggerimento, Esben Skov Pedersen ):

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

Questo potrebbe essere ulteriormente compresso:

typeof(Address).ShouldNotAcceptNullConstructorArguments();

Utilizzando questo metodo di estensione:

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

    assertion.Verify(type.GetConstructors());
}

3
Il titolo / tag parla di test (unit) di valori di campo che implicano test unit post-costruzione dell'oggetto, ma lo snippet di codice mostra l'argomento input / validazione dei parametri; questi non sono gli stessi concetti.
Erik Eidt,

2
Cordiali saluti, potresti scrivere un modello T4 che renderebbe facile questo tipo di piastra di cottura. (Potresti anche considerare string.IsEmpty () beyond == null.)
Erik Eidt

1
Hai dato un'occhiata ai modi di dire autofixture? nuget.org/packages/AutoFixture.Idioms
Esben Skov Pedersen


1
Puoi anche usare Fody / NullGuard , anche se sembra che non abbia una modalità di autorizzazione predefinita.
Bob,

Risposte:


5

Ho creato un modello t4 esattamente per questo tipo di casi. Per evitare di scrivere un sacco di piatti per le classi Immutabili.

https://github.com/xaviergonz/T4Immutable T4Immutable è un modello T4 per app C # .NET che genera codice per classi immutabili.

In particolare parlando di test non nulli quindi se si utilizza questo:

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

Il costruttore sarà questo:

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));
}

Detto questo, se si utilizzano le annotazioni JetBrains per il controllo null, è anche possibile fare ciò:

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

E il costruttore sarà questo:

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));
}

Inoltre ci sono alcune funzionalità in più rispetto a questa.


Grazie. L'ho appena provato e ha funzionato perfettamente. Contrassegno come risposta accettata in quanto risolve tutti i problemi della domanda originale.
Botond Balázs,

16

Puoi ottenere un po 'di miglioramento con un semplice refactoring che può alleviare il problema di scrivere tutte quelle recinzioni. Innanzitutto, è necessario questo metodo di estensione:

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

    return o;
}

È quindi possibile scrivere:

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));
    }
}

Restituire il parametro originale nel metodo di estensione crea un'interfaccia fluida, quindi puoi estendere questo concetto con altri metodi di estensione se lo desideri, e unirli tutti insieme nel tuo compito.

Altre tecniche sono più eleganti nel concetto, ma progressivamente più complesse nell'esecuzione, come decorare il parametro con un [NotNull]attributo e usare Reflection in questo modo .

Detto questo, potresti non aver bisogno di tutti questi test, a meno che la tua classe non faccia parte di un'API pubblica.


3
Strano che questo sia stato retrocesso. È molto simile all'approccio fornito dalla risposta più votata, tranne per il fatto che non dipende da Roslyn.
Robert Harvey,

Quello che non mi piace di questo è che è vulnerabile alle modifiche al costruttore.
jpmc26,

@ jpmc26: cosa significa?
Robert Harvey,

1
Mentre leggo la tua risposta, mi sta suggerendo di non testare il costruttore , ma invece di creare un metodo comune chiamato su ciascun parametro e testarlo. Se aggiungi una nuova coppia proprietà / argomento e dimentichi di testare nulll'argomento sul costruttore, non otterrai un test fallito.
jpmc26,

@ jpmc26: naturalmente. Ma è anche vero se lo scrivi in ​​modo convenzionale, come illustrato nell'OP. Tutto il metodo comune fa è spostare l' throwesterno del costruttore; non stai trasferendo il controllo all'interno del costruttore da qualche altra parte. È solo un metodo di utilità e un modo per fare le cose in modo fluido , se lo desideri.
Robert Harvey,

7

A breve termine, non c'è molto che puoi fare per la noiosità di scrivere tali test. Tuttavia, c'è qualche aiuto in arrivo con le espressioni di lancio che dovrebbero essere implementate come parte della prossima versione di C # (v7), probabilmente prevista nei prossimi mesi:

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)); ;
    }
}

Puoi sperimentare le espressioni di lancio tramite la webapp Try Roslyn .


Molto più compatto, grazie. Metà del numero di righe. In attesa di C # 7!
Botond Balázs,

@ BotondBalázs puoi provarlo nell'anteprima di VS15 adesso se vuoi
user1306322

7

Sono sorpreso che nessuno abbia menzionato NullGuard . È disponibile tramite NuGet e tesserà automagicamente quei controlli null nell'IL durante la compilazione.

Quindi il tuo codice costruttore sarebbe semplicemente

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

e NullGuard aggiungerà quei controlli null per te trasformandolo esattamente in quello che hai scritto.

Si noti, tuttavia, che NullGuard è opt-out , vale a dire che aggiungerà quei controlli null a ogni argomento di metodo e costruttore, getter e setter di proprietà e persino controllerà i valori di ritorno del metodo a meno che non si consenta esplicitamente un valore null con l' [AllowNull]attributo.


Grazie, questa sembra una soluzione generale molto bella. Sebbene sia un peccato che modifichi il comportamento del linguaggio per impostazione predefinita. Mi piacerebbe molto di più se funzionasse aggiungendo un [NotNull]attributo invece di non consentire che null sia l'impostazione predefinita.
Botond Balázs,

@ BotondBalázs: PostSharp ha questo insieme ad altri attributi di convalida dei parametri. A differenza di Fody, tuttavia, non è gratuito. Inoltre, il codice generato chiamerà nelle DLL PostSharp quindi è necessario includerle con il prodotto. Fody eseguirà il suo codice solo durante la compilazione e non sarà richiesto in fase di esecuzione.
Roman Reiner,

@ BotondBalázs: All'inizio ho anche trovato strano che NullGuard si applichi per impostazione predefinita, ma ha davvero senso. In qualsiasi base di codice ragionevole, consentire null dovrebbe essere molto meno comune del controllo di null. Se vuoi davvero consentire null da qualche parte l'approccio opt-out ti costringe a essere molto accurato con esso.
Roman Reiner,

5
Sì, è ragionevole ma viola il principio del minimo stupore . Se un nuovo programmatore non sa che il progetto utilizza NullGuard, è una grande sorpresa! A proposito, non capisco neanche il downvote, questa è una risposta molto utile.
Botond Balázs,

1
@ BotondBalázs / Roman, sembra che NullGuard abbia una nuova modalità esplicita che cambia il modo in cui funziona di default
Kyle W

5

Vale la pena scrivere tutti questi test?

No, probabilmente no. Qual è la probabilità che tu lo rovini? Qual è la probabilità che alcune semantiche cambino da sotto di te? Qual è l'impatto se qualcuno lo rovina?

Se stai spendendo un sacco di tempo a fare test per qualcosa che raramente si romperà, ed è una soluzione banale se lo facesse ... forse non ne vale la pena.

Sarebbe una buona idea scrivere un generatore di codice per tali classi per evitare di scrivere test per ognuna di esse?

Può essere? Questo genere di cose potrebbe essere fatto facilmente con la riflessione. Qualcosa da considerare è la generazione di codice per il codice reale, quindi non hai N classi che potrebbero avere errori umani. Prevenzione dei bug> rilevamento dei bug.


Grazie. Forse non ero abbastanza chiaro nella mia domanda, ma intendevo scrivere un generatore che genera classi immutabili, non test per loro
Botond Balázs

2
Ho anche visto diverse volte invece di if...throwquanto avranno ThrowHelper.ArgumentNull(myArg, "myArg"), in questo modo la logica è ancora lì e non ti perdi nella copertura del codice. Dove il ArgumentNullmetodo farà il if...throw.
Matteo,

2

Vale la pena scrivere tutti questi test?

No.
Perché sono abbastanza sicuro che hai testato quelle proprietà attraverso alcuni altri test di logica in cui vengono utilizzate quelle classi.

Ad esempio, è possibile avere test per la classe Factory che hanno un'asserzione basata su tali proprietà (ad esempio un'istanza creata con Nameproprietà assegnata correttamente ).

Se tali classi sono esposte all'API pubblica utilizzata da un utente di terze parti / finale (grazie @EJoshua per averlo notato), i test per l'atteso ArgumentNullExceptionpossono essere utili.

Durante l'attesa di C # 7 è possibile utilizzare il metodo di estensione

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

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

Per i test è possibile utilizzare un metodo di test con parametri che utilizza la riflessione per creare nullriferimenti per ogni parametro e affermare l'eccezione prevista.


1
Questo è un argomento convincente per non testare l'inizializzazione della proprietà.
Botond Balázs,

Dipende da come stai usando il codice. Se il codice fa parte di una biblioteca pubblica non dovresti mai fidarti ciecamente di altre persone che sappiano come la tua biblioteca (specialmente se non hanno accesso al codice sorgente); tuttavia, se è qualcosa che usi solo internamente per far funzionare il tuo codice, dovresti sapere come usare il tuo codice, quindi i controlli hanno uno scopo inferiore.
EJoshuaS - Ripristina Monica il

2

Puoi sempre scrivere un metodo come il seguente:

// 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);
            }
        }
    }

Come minimo, questo ti farà risparmiare un po 'di battitura se hai diversi metodi che richiedono questo tipo di validazione. Naturalmente, questa soluzione presuppone che nessuno dei parametri del tuo metodo possa essere null, ma puoi modificarlo per cambiarlo se lo desideri.

Puoi anche estenderlo per eseguire altre convalide specifiche del tipo. Ad esempio, se si dispone di una regola secondo cui le stringhe non possono essere puramente bianche o vuote, è possibile aggiungere la seguente condizione:

// 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");
                }
            }

Il metodo GetParameterInfo a cui mi riferisco sostanzialmente fa la riflessione (quindi non devo continuare a digitare la stessa cosa più e più volte, il che costituirebbe una violazione del principio 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;
    }

1
Perché questo viene sottoposto a downgrade? Non è poi così male
Gaspa79,

0

Non suggerisco di lanciare eccezioni dal costruttore. Il problema è che avrai difficoltà a testarlo perché devi passare parametri validi anche se sono irrilevanti per il tuo test.

Ad esempio: se si desidera verificare se il terzo parametro genera un'eccezione, è necessario passare correttamente il primo e il secondo. Quindi il tuo test non è più isolato. quando i vincoli del primo e del secondo parametro cambiano questo caso di test fallirà anche se verifica il terzo parametro.

Suggerisco di utilizzare l'API di convalida Java e di esternalizzare il processo di convalida. Suggerisco di avere quattro responsabilità (classi) coinvolte:

  1. Un oggetto suggerimento con parametri annotati di convalida Java con un metodo validato che restituisce un insieme di vincoli vincolanti. Un vantaggio è che puoi passare attorno a questi oggetti senza che l'affermazione sia valida. È possibile ritardare la convalida fino a quando non è necessario senza provare a creare un'istanza dell'oggetto di dominio. L'oggetto di convalida può essere utilizzato in diversi livelli in quanto è un POJO senza conoscenza specifica del livello. Può far parte dell'API pubblica.

  2. Una factory per l'oggetto dominio responsabile della creazione di oggetti validi. Si passa l'oggetto del suggerimento e la factory chiamerà la validazione e creerà l'oggetto di dominio se tutto va bene.

  3. L'oggetto dominio stesso che dovrebbe essere per lo più inaccessibile per l'istanza per altri sviluppatori. A scopo di test, suggerisco di avere questa classe nell'ambito del pacchetto.

  4. Un'interfaccia di dominio pubblico per nascondere l'oggetto di dominio concreto e rendere difficile l'uso improprio.

Non consiglio di verificare la presenza di valori null. Non appena si entra nel livello di dominio, ci si sbarazza del passaggio in null o della restituzione di null purché non si disponga di catene di oggetti con estremità o funzioni di ricerca per singoli oggetti.

Un altro punto: scrivere meno codice possibile non è una metrica per la qualità del codice. Pensa alle competizioni di giochi a 32k. Quel codice è il codice più compatto ma anche il più disordinato che puoi avere perché si occupa solo di problemi tecnici e non di semantica. Ma la semantica sono i punti che rendono le cose globali.


1
Non sono rispettosamente d'accordo con il tuo ultimo punto. Non dover digitare la stessa boilerplate per il momento 100 non ridurre le probabilità di commettere un errore. Immagina se fosse Java, non C #. Dovrei definire un campo di supporto e un metodo getter per ogni proprietà. Il codice diventerebbe completamente illeggibile e ciò che è importante sarebbe oscurato dall'infrastruttura ingombrante.
Botond Balázs,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.