Existe uma maneira mais fácil de testar a validação de argumentos e a inicialização de campos em um objeto imutável?

15

Meu domínio consiste em várias classes imutáveis simples como esta:

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

Escrever as verificações de nulos e a inicialização da propriedade já está ficando muito entediante, mas atualmente escrevo testes de unidade para cada uma dessas classes para verificar se a validação do argumento funciona corretamente e se todas as propriedades foram inicializadas. Isso parece um trabalho extremamente chato com benefícios incomensuráveis.

A solução real seria para o C # suportar nativamente tipos de referência imutáveis e não anuláveis. Mas o que posso fazer para melhorar a situação nesse meio tempo? Vale a pena escrever todos esses testes? Seria uma boa ideia escrever um gerador de código para essas classes para evitar escrever testes para cada uma delas?

Aqui está o que eu baseei agora nas respostas.

Eu poderia simplificar as verificações de nulos e a inicialização da propriedade para ficar assim:

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

Usando a seguinte implementação por 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;
    }
}

Testar as verificações de nulos é fácil usando o GuardClauseAssertion de AutoFixture.Idioms (obrigado pela sugestão, Esben Skov Pedersen ):

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

Isso pode ser ainda mais compactado:

typeof(Address).ShouldNotAcceptNullConstructorArguments();

Usando este método de extensão:

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

    assertion.Verify(type.GetConstructors());
}
    
por Botond Balázs 16.11.2016 / 16:53
fonte

8 respostas

3

Eu criei um modelo t4 exatamente para esse tipo de caso. Para evitar escrever muito clichê para classes imutáveis.

link T4Immutable é um modelo T4 para aplicativos C # .NET que gera código para classes imutáveis.

Especificamente falando sobre testes não nulos, se você usar isto:

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

O construtor será este:

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

Dito isto, se você usar as anotações do JetBrains para verificação de nulos, você também pode fazer isso:

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

E o construtor será este:

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

Além disso, há mais alguns recursos que este.

    
por 17.11.2016 / 21:43
fonte
13

Você pode obter um pouco de melhoria com uma refatoração simples que pode facilitar o problema de escrever todas essas cercas. Primeiro, você precisa deste método de extensão:

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

    return o;
}

Você pode escrever:

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

Retornar o parâmetro original no método de extensão cria uma interface fluente, para que você possa estender esse conceito com outros métodos de extensão, se desejar, e encadear todos eles em sua atribuição.

Outras técnicas são mais elegantes em conceito, mas progressivamente mais complexas na execução, como decorar o parâmetro com um atributo [NotNull] e usar o Reflection como isso .

Dito isso, talvez você não precise de todos esses testes, a menos que sua turma faça parte de uma API voltada ao público.

    
por 16.11.2016 / 19:51
fonte
6

A curto prazo, não há muito o que fazer sobre o tédio de escrever esses testes. No entanto, há alguma ajuda que vem com as expressões de lançamento que devem ser implementadas como parte da próxima versão do C # (v7), provavelmente prevista para os próximos meses:

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

Você pode experimentar com expressões lance via a aplicação Web Try Roslyn .

    
por 16.11.2016 / 17:42
fonte
6

Estou surpreso que ninguém tenha mencionado NullGuard.Fody ainda. Ele está disponível por meio do NuGet e vai automaticamente mesclar esses controles nulos no IL durante o tempo de compilação.

Então, seu código de construtor seria simplesmente

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

e o NullGuard adicionará essas verificações de nulos para você transformá-las exatamente no que você escreveu.

Note, porém, que o NullGuard é opt-out , ou seja, ele adicionará as verificações nulas a todos os métodos e argumento de construtor, getter e setter e até checará valores de retorno de método, a menos que você explicitamente permita um valor nulo com o atributo [AllowNull] .

    
por 16.11.2016 / 22:32
fonte
5

Is it worth writing all these tests?

Não, provavelmente não. Qual é a probabilidade de você estragar tudo? Qual é a probabilidade de alguma semântica mudar de você? Qual o impacto se alguém estragar tudo?

Se você está gastando muito tempo fazendo testes para algo que raramente vai quebrar, e é uma correção trivial se isso acontecer ... talvez não valha a pena.

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

Talvez? Esse tipo de coisa poderia ser feito facilmente com reflexão. Algo a considerar é fazer a geração de código para o código real, para que você não tenha N classes que possam ter erro humano. Prevenção de bugs > detecção de bugs.

    
por 16.11.2016 / 16:58
fonte
2

Vale a pena escrever todos esses testes?

Não.
Porque tenho certeza que você testou essas propriedades através de outros testes de lógica onde essas classes são usadas.

Por exemplo, você pode ter testes para a classe Factory que possuem asserção baseada nessas propriedades (Instância criada com a propriedade Name atribuída adequadamente, por exemplo).

Se essas classes forem expostas à API pública que é usada por terceiros / usuários finais (o @Eososhua agradece por notar), então os testes para o esperado ArgumentNullException podem ser úteis.

Enquanto espera pelo C # 7, você pode usar o método de extensão

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

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

Para testes, você pode usar um método de teste parametrizado que use reflexão para criar null de referência para cada parâmetro e declarar para exceção esperada.

    
por 16.11.2016 / 18:58
fonte
2

Você sempre pode escrever um método como o seguinte:

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

No mínimo, isso economizará um pouco de digitação se você tiver vários métodos que exigem esse tipo de validação. Obviamente, esta solução assume que nenhum dos parâmetros do seu método pode ser null , mas você pode modificá-lo para mudar isso se assim desejar.

Você também pode estender isso para executar outra validação específica do tipo. Por exemplo, se você tiver uma regra de que as strings não podem ser apenas espaços em branco ou vazias, adicione a seguinte condição:

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

O método GetParameterInfo ao qual eu me refiro basicamente faz o reflexo (para que eu não tenha que continuar digitando a mesma coisa repetidas vezes, o que seria uma violação do Princípio 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;
    }
    
por 16.11.2016 / 18:58
fonte
0

Eu não sugiro lançar exceções do construtor. O problema é que você terá dificuldades para testar isso, já que você precisa passar parâmetros válidos, mesmo que sejam irrelevantes para o teste.

Por exemplo: Se você quiser testar se o terceiro parâmetro lança uma exceção, você deve passar primeiro e o segundo corretamente. Então seu teste não está mais isolado. quando as restrições do primeiro e do segundo parâmetro mudam, este caso de teste falhará mesmo se testar o terceiro parâmetro.

Sugiro usar a API de validação java e externalizar o processo de validação. Eu sugiro ter quatro responsabilidades (classes) envolvidas:

  1. Um objeto de sugestão com parâmetros anotados de validação Java com um método validate que retorna um Conjunto de ConstraintViolations. Uma vantagem é que você pode passar esses objetos sem que a declaração seja válida. Você pode atrasar a validação até que seja necessário sem tentar instanciar o objeto de domínio. O objeto de validação pode ser usado em diferentes camadas, pois é um POJO sem conhecimento específico da camada. Pode fazer parte da sua API pública.

  2. Uma fábrica para o objeto de domínio responsável pela criação de objetos válidos. Você passa no objeto de sugestão e a fábrica chamará a validação e criará o objeto de domínio se tudo estiver bem.

  3. O objeto de domínio em si, que deve estar praticamente inacessível para instanciação para outros desenvolvedores. Para fins de teste, sugiro ter essa classe no escopo do pacote.

  4. Uma interface de domínio público para ocultar o objeto de domínio concreto e dificultar o uso incorreto do material.

Eu não sugiro verificar valores nulos. Assim que você entrar na camada de domínio, você se livrará de passar em null ou retornar null, desde que você não tenha cadeias de objetos que tenham fins ou funções de busca para objetos únicos.

Um outro ponto: escrever com menos código possível não é uma métrica para a qualidade do código. Pense em 32k competições de jogos. Esse código é o mais compacto, mas também o mais confuso que você pode ter, porque se importa apenas com problemas técnicos e não se importa com a semântica. Mas a semântica são os pontos que tornam as coisas mais abrangentes.

    
por 17.11.2016 / 07:51
fonte