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

15

Il mio dominio è costituito da molte classi semplici e 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 al momento scrivo unit test per ciascuna di queste classi per verificare che la convalida degli argomenti funzioni correttamente e che tutte le proprietà siano inizializzate. Questo sembra un lavoro estremamente noioso con un vantaggio incommensurabile.

La vera soluzione sarebbe per C # supportare i tipi di riferimento immutabilità e non annullabili in modo nativo. 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 ognuno di essi?

Ecco cosa ho ora basato sulle risposte.

Potrei semplificare i controlli nulli e l'inizializzazione delle proprietà per assomigliare a questo:

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 da 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 GuardClauseAssertion da 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();

Utilizzo di 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());
}
    
posta Botond Balázs 16.11.2016 - 16:53
fonte

8 risposte

3

Ho creato un template t4 esattamente per questo tipo di casi. Per evitare di scrivere molti piatti standard per le classi Immutable.

link T4Immutable è un modello T4 per app C # .NET che genera codice per classi immutabili.

In particolare, si parla 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 usi le annotazioni JetBrains per il controllo null, puoi anche fare questo:

[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.

    
risposta data 17.11.2016 - 21:43
fonte
13

Puoi ottenere un po 'di miglioramento con un semplice refactoring che può facilitare il problema di scrivere tutte quelle recinzioni. Innanzitutto, hai bisogno di 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;
}

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

La restituzione del parametro originale nel metodo di estensione crea un'interfaccia fluente, quindi puoi estendere questo concetto con altri metodi di estensione, se lo desideri, e riunirli tutti insieme nel tuo compito.

Altre tecniche sono più eleganti nel concetto, ma progressivamente più complesse nell'esecuzione, come ad esempio la decorazione del parametro con un attributo [NotNull] e l'utilizzo di Reflection come questo .

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

    
risposta data 16.11.2016 - 19:51
fonte
6

A breve termine, non c'è molto che puoi fare per la noiosità di scrivere questi test. Tuttavia, c'è un po 'di aiuto 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)); ;
    }
}

Si può sperimentare con le espressioni del tiro tramite la webapp Try Roslyn .

    
risposta data 16.11.2016 - 17:42
fonte
6

Sono sorpreso che nessuno abbia menzionato NullGuard.Fody ancora. È disponibile tramite NuGet e intreccerà automagicamente questi controlli nulli 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 nulli per trasformarlo esattamente in quello che hai scritto.

Nota che NullGuard è opt-out , cioè aggiungerà quei controlli nulli al metodo ogni e all'argomento costruttore, proprietà getter e setter e persino controllo metodo restituisce valori a meno che tu espliciti un valore nullo con l'attributo [AllowNull] .

    
risposta data 16.11.2016 - 22:32
fonte
5

Is it worth writing all these tests?

No, probabilmente no. Qual è la probabilità che stai per rovinare tutto? Qual è la probabilità che qualche semantica cambierà da sotto di te? Qual è l'impatto se qualcuno lo rovina?

Se passi un po 'di tempo a fare dei test per qualcosa che raramente si interrompe, ed è una soluzione banale se lo facesse ... forse non ne vale la pena.

Would it be a good idea to write a code generator for such classes to avoid writing tests for each one of them?

Forse? Quel genere di cose potrebbe essere fatto facilmente con la riflessione. Qualcosa da considerare è creare codice per il codice reale, quindi non si hanno classi N che potrebbero avere errori umani. Prevenzione dei bug > rilevamento dei bug.

    
risposta data 16.11.2016 - 16:58
fonte
2

Vale la pena scrivere tutti questi test?

No.
Perché sono abbastanza sicuro di aver testato queste proprietà attraverso altri test di logica in cui vengono utilizzate tali classi.

Ad esempio, puoi avere test per la classe Factory che hanno un'asserzione basata su tali proprietà (Istanza creata con proprietà Name assegnata correttamente per esempio).

Se tali classi sono esposte all'API pubblica utilizzata da terze parti / utenti finali (@EJoshua grazie per averlo notato), i test per ArgumentNullException previsti possono essere utili.

Mentre aspetti C # 7 puoi usare 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 puoi utilizzare un metodo di prova parametrizzato che usa la riflessione per creare il riferimento null per ogni parametro e asserire per l'eccezione prevista.

    
risposta data 16.11.2016 - 18:58
fonte
2

Potresti 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, ciò ti farà risparmiare un po 'di digitazione se hai diversi metodi che richiedono questo tipo di convalida. Ovviamente, questa soluzione presuppone che nessuno dei parametri del tuo metodo possa essere null , ma puoi modificarlo per modificarlo se lo desideri.

Puoi anche estenderlo per eseguire altre convalide specifiche del tipo. Ad esempio, se si dispone di una regola che le stringhe non possono essere solo spazi vuoti o vuoti, è 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 fondamentalmente fa il reflection (in modo che non debba continuare a digitare la stessa cosa più e più volte, il che sarebbe una violazione del Principio di 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;
    }
    
risposta data 16.11.2016 - 18:58
fonte
0

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

Ad esempio: se vuoi verificare se il terzo parametro genera un'eccezione devi 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 test fallirà anche se testerà 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 di convalida che restituisce un insieme di ConstraintViolations. Un vantaggio è che puoi aggirare 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 dominio. L'oggetto di validazione può essere utilizzato in diversi livelli in quanto è un POJO senza conoscenza specifica del livello. Può far parte della tua API pubblica.

  2. Un factory per l'oggetto dominio responsabile della creazione di oggetti validi. Passi nell'oggetto del suggerimento e la fabbrica chiamerà la convalida e creerà l'oggetto dominio se tutto va bene.

  3. L'oggetto dominio stesso che dovrebbe essere per lo più inaccessibile per la creazione di istanze per altri sviluppatori. A scopo di test suggerisco di avere questa classe nello scope del pacchetto.

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

Non suggerisco di verificare i valori nulli. Non appena entri nel livello del dominio, ti sei liberato di passare in null o restituire null finché non hai catene di oggetti che hanno funzioni di fine o ricerca per singoli oggetti.

Un altro punto: scrivere meno codice possibile non è una metrica per la qualità del codice. Pensa alle gare di 32k di giochi. Quel codice è il codice più compatto ma anche il più confuso possibile perché si preoccupa solo di problemi tecnici e non si preoccupa della semantica. Ma la semantica sono i punti che rendono le cose complete.

    
risposta data 17.11.2016 - 07:51
fonte

Leggi altre domande sui tag