Compartir a través de


Procedimientos recomendados de pruebas unitarias para .NET

Existen numerosas ventajas de escribir pruebas unitarias. Ayudan con la regresión, proporcionan documentación y facilitan un buen diseño. Pero cuando las pruebas unitarias son difíciles de leer y frágiles, pueden causar daños en la base de código. En este artículo se describen algunas prácticas recomendadas para diseñar pruebas unitarias para apoyar los proyectos de .NET Core y .NET Standard. Aprenderás técnicas para mantener las pruebas resilientes y fáciles de entender.

Por John Reese con especial gracias a Roy Osherove

Beneficios de pruebas de unidad

En las secciones siguientes se describen varios motivos para escribir pruebas unitarias para los proyectos de .NET Core y .NET Standard.

Menos tiempo realizando pruebas funcionales

Las pruebas funcionales son costosas. Normalmente implican abrir la aplicación y realizar una serie de pasos que usted (o otra persona) debe seguir para validar el comportamiento esperado. Es posible que estos pasos no siempre se conozcan para el evaluador. Tienen que ponerse en contacto con alguien más experto en el área para llevar a cabo la prueba. Las pruebas en sí pueden tardar segundos en cambios triviales o minutos en cambios más grandes. Por último, este proceso debe repetirse para cada cambio que realice en el sistema. Las pruebas unitarias, por otro lado, toman milisegundos, se pueden ejecutar con solo pulsar un botón y no requieren necesariamente ningún conocimiento del sistema en general. El ejecutor de pruebas determina si la prueba pasa o falla, no el individuo.

Protección contra regresión

Los defectos de regresión son errores que se introducen cuando se realiza un cambio en la aplicación. Es habitual que los evaluadores no solo prueben su nueva característica, sino que también prueben las características que existían de antemano para comprobar que las características existentes siguen funcionando según lo previsto. Con las pruebas unitarias, puede volver a ejecutar todo el conjunto de pruebas después de cada compilación o incluso después de cambiar una línea de código. Este enfoque ayuda a aumentar la confianza de que el nuevo código no interrumpe la funcionalidad existente.

Documentación ejecutable

Es posible que no siempre sea obvio lo que hace un método determinado o cómo se comporta dada una determinada entrada. Puede preguntarse: ¿Cómo se comporta este método si lo paso una cadena en blanco o null? Cuando tenga un conjunto de pruebas unitarias bien denominadas, cada prueba debe explicar claramente la salida esperada para una entrada determinada. Además, la prueba debe ser capaz de comprobar que funciona realmente.

Menos código acoplado

Cuando el código está estrechamente acoplado, puede ser difícil realizar pruebas unitarias. Sin crear pruebas unitarias para el código que está escribiendo, el acoplamiento podría ser menos evidente. Escribir pruebas para su código desacopla de forma natural su código porque es más difícil de probar de otra manera.

Características de buenas pruebas unitarias

Hay varias características importantes que definen una buena prueba unitaria:

  • Rápido: no es raro que los proyectos maduros tengan miles de pruebas unitarias. Las pruebas unitarias deben tardar poco tiempo en ejecutarse. Milisegundos.
  • Aislado: las pruebas unitarias son independientes, se pueden ejecutar de forma aislada y no tienen dependencias en ningún factor externo, como un sistema de archivos o una base de datos.
  • Repetible: la ejecución de una prueba unitaria debe ser coherente con sus resultados. La prueba siempre devuelve el mismo resultado si no cambia nada entre ejecuciones.
  • Autocomprobación: la prueba debe detectar automáticamente si ha sido superada o fallada sin ninguna interacción humana.
  • Tiempo: una prueba unitaria no debe tardar mucho tiempo en escribirse en comparación con el código que se está probando. Si descubre que probar el código tarda mucho tiempo en comparación con escribirlo, considere un diseño más fácil de comprobar.

Cobertura de código y calidad de código

A menudo, un alto porcentaje de cobertura de código se asocia a una mayor calidad de código. Sin embargo, la propia medida no puede determinar la calidad del código. Establecer un objetivo de porcentaje de cobertura de código demasiado ambicioso puede ser contraproducente. Considere un proyecto complejo con miles de ramas condicionales, y suponga que se fija un objetivo de cobertura de código del 95%. Actualmente, el proyecto mantiene una cobertura de código del 90 %. La cantidad de tiempo que se necesita para tener en cuenta todos los casos extremos en el 5% restante puede ser una tarea enorme, y la propuesta de valor disminuye rápidamente.

Un porcentaje de cobertura de código alto no es un indicador de éxito y no implica una alta calidad del código. Solo representa la cantidad de código cubierto por pruebas unitarias. Para obtener más información, consulte Cobertura de código de prueba unitaria.

Terminología de pruebas unitarias

Varios términos se usan con frecuencia en el contexto de las pruebas unitarias: falso, mock y stub. Desafortunadamente, estos términos se pueden aplicar incorrectamente, por lo que es importante comprender el uso correcto.

  • Emulación: una emulación es un término genérico que se puede usar para describir un stub o un objeto ficticio. Que el objeto sea un stub o un objeto ficticio depende del contexto en el que se utilice el objeto. En otras palabras, un fake puede ser un stub o un objeto ficticio.

  • Simulado: Un objeto simulado es un objeto falso en el sistema que decide si una prueba unitaria aprueba o falla. Un objeto ficticio comienza siendo falso y permanece falso hasta que entra en una Assert operación.

  • Stub: Un stub es un reemplazo controlable de una dependencia existente (o colaborador) en el sistema. Utilizando un stub, puede probar su código sin tratar directamente con la dependencia. Por defecto, un stub comienza como un fake.

Observe el código siguiente:

var mockOrder = new MockOrder();
var purchase = new Purchase(mockOrder);

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

Este código muestra un stub referido como un objeto ficticio. Pero en este escenario, el stub es realmente un stub. El propósito del código es pasar el orden como medio para crear instancias del Purchase objeto (el sistema sometido a prueba). El nombre de la clase MockOrder es engañoso porque la orden es un stub y no un objeto ficticio.

El código siguiente muestra un diseño más preciso:

var stubOrder = new FakeOrder();
var purchase = new Purchase(stubOrder);

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

Cuando se cambia el nombre de la clase a FakeOrder, la clase es más genérica. La clase puede utilizarse como mock o como stub, según los requisitos del caso de prueba. En el primer ejemplo, la FakeOrder clase se usa como código auxiliar y no se usa durante la Assert operación. El código pasa la FakeOrder clase a la Purchase clase solo para satisfacer los requisitos del constructor.

Para usar la clase como simulación, puede actualizar el código:

var mockOrder = new FakeOrder();
var purchase = new Purchase(mockOrder);

purchase.ValidateOrders();

Assert.True(mockOrder.Validated);

En este diseño, el código comprueba una propiedad en el falso (afirmando contra ella), y por lo tanto, la clase mockOrder es un mock.

Importante

Es importante implementar correctamente la terminología. Si se llama a los stubs "objetos ficticios", los demás desarrolladores van a hacer suposiciones falsas sobre su intención.

Lo principal a recordar sobre los mocks frente a los stubs es que los mocks son como los stubs, excepto por el proceso Assert. Puede ejecutar operaciones Assert contra un objeto mock, pero no contra un stub.

procedimientos recomendados

Hay varios procedimientos recomendados importantes que se deben seguir al escribir pruebas unitarias. En las secciones siguientes se proporcionan ejemplos que muestran cómo aplicar los procedimientos recomendados al código.

Evitar dependencias de infraestructura

Intente no introducir dependencias en la infraestructura al escribir pruebas unitarias. Las dependencias hacen que las pruebas sean lentas y frágiles y deben reservarse para las pruebas de integración. Puede evitar estas dependencias en la aplicación siguiendo el principio de dependencias explícitas y mediante la inserción de dependencias de .NET. También puede mantener las pruebas unitarias en un proyecto independiente de las pruebas de integración. Este enfoque garantiza que el proyecto de prueba unitaria no tenga referencias a paquetes de infraestructura ni dependencias.

Seguir los estándares de nomenclatura de pruebas

El nombre de la prueba debe constar de tres partes:

  • Nombre del método que se está probando
  • Escenario en el que se está probando el método
  • Comportamiento esperado cuando se invoca el escenario

Los estándares de nomenclatura son importantes porque ayudan a expresar el propósito de prueba y la aplicación. Las pruebas son más que asegurarse de que el código funciona. También proporcionan documentación. Simplemente examinando el conjunto de pruebas unitarias, debería poder deducir el comportamiento del código y no tener que examinar el propio código. Además, cuando se produce un error en las pruebas, puede ver exactamente qué escenarios no cumplen sus expectativas.

Código original

[Fact]
public void Test_Single()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Aplicación del procedimiento recomendado

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Organizar las pruebas

El patrón de "Organizar, Actuar, Afirmar" es un enfoque común para escribir pruebas unitarias. Como indica el nombre, el patrón consta de tres tareas principales:

  • Organizar los objetos, crearlos y configurarlos según sea necesario
  • Actuar en un objeto
  • Afirmar que algo es como se espera

Cuando sigues el patrón, puedes separar claramente lo que se está evaluando de las tareas de Configuración y Aserción. El patrón también ayuda a reducir la oportunidad de que las aserciones se mezclen con código en la tarea Act.

La legibilidad es uno de los aspectos más importantes al escribir una prueba unitaria. Separar cada acción del patrón dentro de la prueba destaca claramente las dependencias necesarias para llamar a su código, cómo se llama a su código, y lo que está tratando de afirmar. Aunque es posible combinar algunos pasos y reducir el tamaño de la prueba, el objetivo general es hacer que la prueba sea lo más legible posible.

Código original

[Fact]
public void Add_EmptyString_ReturnsZero()
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Assert
    Assert.Equal(0, stringCalculator.Add(""));
}

Aplicación del procedimiento recomendado

[Fact]
public void Add_EmptyString_ReturnsZero()
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Act
    var actual = stringCalculator.Add("");

    // Assert
    Assert.Equal(0, actual);
}

Escribir pruebas correctas lo más sencillas posible

La entrada de una prueba unitaria debe ser la información más sencilla necesaria para comprobar el comportamiento que está probando actualmente. El enfoque minimalista ayuda a las pruebas a ser más resistentes a los cambios futuros en el código base y centrarse en comprobar el comportamiento sobre la implementación.

Las pruebas que incluyen más información de la necesaria para pasar la prueba actual tienen una mayor probabilidad de introducir errores en la prueba y pueden hacer que la intención de la prueba sea menos clara. Al escribir pruebas, es importante enfocarse en el comportamiento. Establecer propiedades adicionales en modelos o usar valores distintos de cero cuando no es necesario, solo se destrae de lo que intenta confirmar.

Código original

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("42");

    Assert.Equal(42, actual);
}

Aplicación del procedimiento recomendado

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Evitar cadenas mágicas

Las cadenas mágicas son valores de cadena codificados de forma rígida directamente en las pruebas unitarias sin ningún comentario o contexto adicionales de código. Estos valores hacen que el código sea menos legible y difícil de mantener. Las cadenas mágicas pueden causar confusión al lector de las pruebas. Si una cadena se ve fuera de lo normal, es posible que se pregunte por qué se ha elegido un valor determinado para un parámetro o un valor devuelto. Este tipo de valor de cadena puede llevarles a echar un vistazo más detallado a los detalles de implementación, en lugar de centrarse en la prueba.

Sugerencia

Haga que su objetivo sea expresar la mayor cantidad de intención posible en el código de pruebas unitarias. En lugar de usar cadenas mágicas, asigne valores codificados de forma rígida a constantes.

Código original

[Fact]
public void Add_BigNumber_ThrowsException()
{
    var stringCalculator = new StringCalculator();

    Action actual = () => stringCalculator.Add("1001");

    Assert.Throws<OverflowException>(actual);
}

Aplicación del procedimiento recomendado

[Fact]
void Add_MaximumSumResult_ThrowsOverflowException()
{
    var stringCalculator = new StringCalculator();
    const string MAXIMUM_RESULT = "1001";

    Action actual = () => stringCalculator.Add(MAXIMUM_RESULT);

    Assert.Throws<OverflowException>(actual);
}

Evitar la lógica de codificación en pruebas unitarias

Al escribir las pruebas unitarias, evite la concatenación manual de cadenas, las condiciones lógicas, como if, while, for, switch y otras condiciones. Si incluye lógica en el conjunto de pruebas, la posibilidad de introducir errores aumenta considerablemente. El último lugar en el que se quiere encontrar un error es el conjunto de pruebas. Debe tener un alto nivel de confianza para que las pruebas funcionen; de lo contrario, no puede confiar en ellas. Pruebas en las que no confías no tienen ningún valor. Cuando una prueba falla, debería tener la sensación de que algo está mal con el código y que no se puede pasar por alto.

Sugerencia

Si agregar lógica en la prueba parece inevitable, considere la posibilidad de dividir la prueba en dos o más pruebas diferentes para limitar los requisitos lógicos.

Código original

[Fact]
public void Add_MultipleNumbers_ReturnsCorrectResults()
{
    var stringCalculator = new StringCalculator();
    var expected = 0;
    var testCases = new[]
    {
        "0,0,0",
        "0,1,2",
        "1,2,3"
    };

    foreach (var test in testCases)
    {
        Assert.Equal(expected, stringCalculator.Add(test));
        expected += 3;
    }
}

Aplicación del procedimiento recomendado

[Theory]
[InlineData("0,0,0", 0)]
[InlineData("0,1,2", 3)]
[InlineData("1,2,3", 6)]
public void Add_MultipleNumbers_ReturnsSumOfNumbers(string input, int expected)
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add(input);

    Assert.Equal(expected, actual);
}

Usar métodos auxiliares en lugar de configurar y desmontar

Si necesita un estado u objeto similar para sus pruebas, use un método auxiliar en lugar de los atributos Setup y Teardown, si existen. Los métodos auxiliares se prefieren sobre estos atributos por varias razones:

  • Menos confusión al leer las pruebas porque todo el código es visible desde cada prueba
  • Menor posibilidad de configurar demasiado o demasiado poco para la prueba dada
  • Menor posibilidad de compartir el estado entre las pruebas, lo que crea dependencias no deseadas entre ellas

En los frameworks de pruebas unitarias, el atributo Setup se llama antes de todas y cada una de las pruebas unitarias dentro de su conjunto de pruebas. Algunos programadores ven este comportamiento como útil, pero a menudo resulta en pruebas infladas y difíciles de leer. Cada prueba generalmente tiene requisitos diferentes para la configuración y la ejecución. Desafortunadamente, el Setup atributo obliga a usar los mismos requisitos para cada prueba.

Nota:

Los atributos SetUp y TearDown se eliminan en la versión 2.x y posteriores de xUnit.

Código original

Aplicación del procedimiento recomendado

private readonly StringCalculator stringCalculator;
public StringCalculatorTests()
{
    stringCalculator = new StringCalculator();
}
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var stringCalculator = CreateDefaultStringCalculator();

    var actual = stringCalculator.Add("0,1");

    Assert.Equal(1, actual);
}
// More tests...
// More tests...
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var result = stringCalculator.Add("0,1");

    Assert.Equal(1, result);
}
private StringCalculator CreateDefaultStringCalculator()
{
    return new StringCalculator();
}

Evite múltiples tareas Act

Cuando escriba sus pruebas, intente incluir solo una tarea Act por prueba. Algunos enfoques comunes para implementar una única tarea Act incluyen la creación de una prueba separada para cada Act o el uso de pruebas parametrizadas. Utilizar una única tarea Act para cada prueba tiene varias ventajas:

  • Puede discernir fácilmente qué tarea Act está fallando si la prueba falla.
  • Puede asegurarse de que la prueba se centra solo en un solo caso.
  • Obtiene una imagen clara sobre por qué se producen errores en las pruebas.

Es necesario afirmar varias tareas Act individualmente, y no se puede garantizar que todas las tareas Assert se ejecuten. En la mayoría de los frameworks de pruebas unitarias, después de que se produzca un error en una tarea de aserción en una prueba unitaria, todas las pruebas posteriores se consideran automáticamente fallidas. El proceso puede resultar confuso porque es posible que algunas funciones de trabajo se interpreten como con errores.

Código original

[Fact]
public void Add_EmptyEntries_ShouldBeTreatedAsZero()
{
    // Act
    var actual1 = stringCalculator.Add("");
    var actual2 = stringCalculator.Add(",");

    // Assert
    Assert.Equal(0, actual1);
    Assert.Equal(0, actual2);
}

Aplicación del procedimiento recomendado

[Theory]
[InlineData("", 0)]
[InlineData(",", 0)]
public void Add_EmptyEntries_ShouldBeTreatedAsZero(string input, int expected)
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Act
    var actual = stringCalculator.Add(input);

    // Assert
    Assert.Equal(expected, actual);
}

Validación de métodos privados con métodos públicos

En la mayoría de los casos, no es necesario probar un método privado en el código. Los métodos privados son un detalle de implementación y nunca existen de forma aislada. En algún momento del proceso de desarrollo, se introduce un método público para llamar al método privado como parte de su implementación. Cuando escribe sus pruebas unitarias, lo que te importa es el resultado final del método público que llama al privado.

Tenga en cuenta el siguiente escenario de código:

public string ParseLogLine(string input)
{
    var sanitizedInput = TrimInput(input);
    return sanitizedInput;
}

private string TrimInput(string input)
{
    return input.Trim();
}

En términos de pruebas, la primera reacción podría ser escribir una prueba para el TrimInput método para asegurarse de que funciona según lo previsto. Sin embargo, es posible que el ParseLogLine método manipule el sanitizedInput objeto de una manera que no espere. El comportamiento desconocido podría hacer que la prueba contra el método TrimInput sea inútil.

Una mejor prueba en este escenario es verificar el método público ParseLogLine.

public void ParseLogLine_StartsAndEndsWithSpace_ReturnsTrimmedResult()
{
    var parser = new Parser();

    var result = parser.ParseLogLine(" a ");

    Assert.Equals("a", result);
}

Cuando encuentre un método privado, busque el método público que llama al método privado y realice las pruebas sobre el método público. Solo porque un método privado devuelve un resultado esperado, no significa que el sistema que finalmente llama al método privado use el resultado correctamente.

Manejar referencias estáticas stub con costuras

Un principio de una prueba unitaria es que debe tener control total del sistema bajo prueba. Sin embargo, este principio puede ser problemático cuando el código de producción incluye llamadas a referencias estáticas (por ejemplo, DateTime.Now).

Examine el siguiente escenario de código:

public int GetDiscountedPrice(int price)
{
    if (DateTime.Now.DayOfWeek == DayOfWeek.Tuesday)
    {
        return price / 2;
    }
    else
    {
        return price;
    }
}

¿Puede escribir una prueba unitaria para este código? Puede intentar ejecutar una tarea Assert en el price:

public void GetDiscountedPrice_NotTuesday_ReturnsFullPrice()
{
    var priceCalculator = new PriceCalculator();

    var actual = priceCalculator.GetDiscountedPrice(2);

    Assert.Equals(2, actual)
}

public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice()
{
    var priceCalculator = new PriceCalculator();

    var actual = priceCalculator.GetDiscountedPrice(2);

    Assert.Equals(1, actual);
}

Desafortunadamente, rápidamente se da cuenta de que hay algunos problemas con la prueba:

  • Si el conjunto de pruebas se ejecuta el martes, se supera la segunda prueba, pero se produce un error en la primera prueba.
  • Si el conjunto de pruebas se ejecuta en cualquier otro día, se supera la primera prueba, pero se produce un error en la segunda prueba.

Para solucionar estos problemas, debe incorporar un arreglo en el código de producción. Un enfoque consiste en ajustar el código que necesita controlar en una interfaz y hacer que el código de producción dependa de esa interfaz:

public interface IDateTimeProvider
{
    DayOfWeek DayOfWeek();
}

public int GetDiscountedPrice(int price, IDateTimeProvider dateTimeProvider)
{
    if (dateTimeProvider.DayOfWeek() == DayOfWeek.Tuesday)
    {
        return price / 2;
    }
    else
    {
        return price;
    }
}

También debe escribir una nueva versión del conjunto de pruebas:

public void GetDiscountedPrice_NotTuesday_ReturnsFullPrice()
{
    var priceCalculator = new PriceCalculator();
    var dateTimeProviderStub = new Mock<IDateTimeProvider>();
    dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Monday);

    var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);

    Assert.Equals(2, actual);
}

public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice()
{
    var priceCalculator = new PriceCalculator();
    var dateTimeProviderStub = new Mock<IDateTimeProvider>();
    dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Tuesday);

    var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);

    Assert.Equals(1, actual);
}

Ahora el conjunto de pruebas tiene control total sobre el valor DateTime.Now, y puede hacer un stub de cualquier valor cuando llame al método.