¿Hay una manera más fácil de probar la validación de argumentos y la inicialización de campo en un objeto inmutable?

14

Mi dominio consiste en muchas clases simples e inmutables 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;
    }
}

Escribir las comprobaciones nulas y la inicialización de la propiedad ya se está volviendo muy tedioso, pero actualmente escribo unidades de prueba para cada una de estas clases para verificar que la validación de los argumentos funciona correctamente y que todas las propiedades están inicializadas. Esto se siente como un trabajo extremadamente aburrido con un beneficio inconmensurable.

La solución real sería que C # admita los tipos de referencia de inmutabilidad y no anulables de forma nativa. Pero, ¿qué puedo hacer para mejorar la situación mientras tanto? ¿Vale la pena escribir todas estas pruebas? ¿Sería una buena idea escribir un generador de código para tales clases para evitar escribir pruebas para cada una de ellas?

Esto es lo que tengo ahora basado en las respuestas.

Podría simplificar los controles nulos y la inicialización de la propiedad para tener este aspecto:

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

Utilizando la siguiente implementación 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;
    }
}

Es fácil probar las comprobaciones nulas utilizando GuardClauseAssertion from AutoFixture.Idioms (gracias por la sugerencia, Esben Skov Pedersen ):

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

Esto podría ser comprimido aún más:

typeof(Address).ShouldNotAcceptNullConstructorArguments();

Usando este método de extensión:

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

    assertion.Verify(type.GetConstructors());
}
    
pregunta Botond Balázs 16.11.2016 - 16:53

8 respuestas

3

Creé una plantilla t4 exactamente para este tipo de casos. Para evitar escribir un montón de repetitivo para clases inmutables.

enlace T4Immutable es una plantilla T4 para aplicaciones C # .NET que genera código para clases inmutables.

Específicamente hablando de pruebas no nulas, si usa esto:

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

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

Habiendo dicho esto, si usa JetBrains Annotations para la comprobación de nulos, también puede hacer esto:

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

Y el constructor 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));
}

También hay algunas características más que esta.

    
respondido por el Javier G. 17.11.2016 - 21:43
12

Puede obtener un poco de mejora con una refactorización simple que puede aliviar el problema de escribir todas esas vallas. Primero, necesitas este método de extensión:

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

    return o;
}

Luego puedes escribir:

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

Devolver el parámetro original en el método de extensión crea una interfaz fluida, por lo que puede extender este concepto con otros métodos de extensión si lo desea y encadenarlos todos en su asignación.

Otras técnicas son más elegantes en concepto, pero progresivamente más complejas en ejecución, como decorar el parámetro con un atributo [NotNull] , y usar Reflection como esto .

Dicho esto, es posible que no necesites todas estas pruebas, a menos que tu clase sea parte de una API pública.

    
respondido por el Robert Harvey 16.11.2016 - 19:51
6

A corto plazo, no hay mucho que pueda hacer para evitar el tedio de escribir tales pruebas. Sin embargo, hay cierta ayuda que viene con las expresiones de lanzamiento que deben implementarse como parte de la próxima versión de C # (v7), que probablemente se realizará en los 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)); ;
    }
}

Puede experimentar con expresiones de tiro a través de .

    
6

Me sorprende que nadie haya mencionado NullGuard.Fody todavía. Está disponible a través de NuGet y tejerá automáticamente esos cheques nulos en el IL durante el tiempo de compilación.

Por lo tanto, su código de constructor simplemente sería

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

y NullGuard agregará esas comprobaciones nulas para que las transformes en exactamente lo que escribiste.

Sin embargo, tenga en cuenta que NullGuard es opt-out , es decir, agregará esas comprobaciones nulas a cada método y argumento del constructor, propiedad getter and setter e incluso check los valores de retorno del método a menos que permita explícitamente un valor nulo con el atributo [AllowNull] .

    
respondido por el Roman Reiner 16.11.2016 - 22:32
5
  

¿Vale la pena escribir todas estas pruebas?

No, probablemente no. ¿Cuál es la probabilidad de que lo arruines? ¿Cuál es la probabilidad de que alguna semántica cambie de debajo de usted? ¿Cuál es el impacto si alguien lo arruina?

Si pasas un montón de tiempo haciendo pruebas para algo que rara vez se romperá, y es una solución trivial si lo hiciera ... tal vez no valga la pena.

  

¿Sería una buena idea escribir un generador de código para dichas clases para evitar escribir pruebas para cada una de ellas?

Tal vez? Ese tipo de cosas se podrían hacer fácilmente con la reflexión. Algo a considerar es la generación de código para el código real, por lo que no tienes N clases que puedan tener un error humano. Prevención de errores > detección de errores.

    
respondido por el Telastyn 16.11.2016 - 16:58
2

¿Vale la pena escribir todas estas pruebas?

No.
Porque estoy bastante seguro de que has probado esas propiedades a través de otras pruebas de lógica donde se usan esas clases.

Por ejemplo, puede tener pruebas para la clase Factory que tienen aserción basada en esas propiedades (la instancia creada con la propiedad Name asignada correctamente, por ejemplo).

Si esas clases están expuestas a la API pública que utiliza un tercero / usuario final (@EJoshua gracias por darse cuenta), las pruebas para el ArgumentNullException esperado pueden ser útiles.

Mientras espera por C # 7, puede usar el método de extensión

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 las pruebas, puede usar un método de prueba parametrizado que use la reflexión para crear null de referencia para cada parámetro y afirmar la excepción esperada.

    
respondido por el Fabio 16.11.2016 - 18:58
2

Siempre se puede escribir un método como el siguiente:

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

Como mínimo, eso le ahorrará algo de escritura si tiene varios métodos que requieren este tipo de validación. Por supuesto, esta solución supone que ninguno de los parámetros de su método puede ser null , pero puede modificarlo para cambiarlo si así lo desea.

También puede extender esto para realizar otra validación específica del tipo. Por ejemplo, si tiene una regla de que las cadenas no pueden ser puramente espacios en blanco o vacíos, puede agregar la siguiente condición:

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

El método GetParameterInfo al que me refiero básicamente hace la reflexión (para no tener que seguir escribiendo lo mismo una y otra vez, lo que sería una violación de 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;
    }
    
respondido por el EJoshuaS 16.11.2016 - 18:58
0

No sugiero lanzar excepciones desde el constructor. El problema es que tendrá dificultades para probar esto, ya que tiene que pasar parámetros válidos, incluso si son irrelevantes para su prueba.

Por ejemplo: si desea probar si el tercer parámetro produce una excepción, debe pasar la primera y la segunda correctamente. Entonces tu prueba ya no está aislada. cuando las restricciones del primer y segundo parámetro cambien, este caso de prueba fallará incluso si prueba el tercer parámetro.

Sugiero utilizar la API de validación de Java y externalizar el proceso de validación. Sugiero tener cuatro responsabilidades (clases) involucradas:

  1. Un objeto de sugerencia con parámetros de anotación de validación de Java con un método de validación que devuelve un conjunto de restricciones de restricciones. Una ventaja es que puede pasar estos objetos sin que la aserción sea válida. Puede retrasar la validación hasta que sea necesario sin tener que intentar crear una instancia del objeto de dominio. El objeto de validación se puede utilizar en diferentes capas, ya que es un POJO sin conocimiento específico de la capa. Puede ser parte de tu API pública.

  2. Una fábrica para el objeto de dominio responsable de crear objetos válidos. Usted pasa el objeto de sugerencia y la fábrica llamará a la validación y creará el objeto de dominio si todo está bien.

  3. El objeto de dominio en sí mismo, que debería ser casi inaccesible para la creación de instancias para otros desarrolladores. Para propósitos de prueba, sugiero tener esta clase en el alcance del paquete.

  4. Una interfaz de dominio público para ocultar el objeto de dominio concreto y dificultar el uso indebido.

No sugiero verificar los valores nulos. Tan pronto como ingresa a la capa de dominio, puede deshacerse de pasar nulo o devolver nulo siempre y cuando no tenga cadenas de objetos que tengan extremos o funciones de búsqueda para objetos individuales.

Otro punto: escribir la menor cantidad de código posible no es una métrica para la calidad del código. Piensa en competiciones de juegos 32k. Ese código es el más compacto, pero también el más desordenado que puede tener, ya que solo se preocupa por los problemas técnicos y no por la semántica. Pero la semántica son los puntos que hacen que las cosas sean comprensivas.

    
respondido por el oopexpert 17.11.2016 - 07:51

Lea otras preguntas en las etiquetas