¿Es una buena idea usar pruebas unitarias para contar una historia?

13

Entonces, tengo un módulo de autenticación que escribí hace algún tiempo. Ahora veo los errores de mi camino y escribo pruebas unitarias para ello. Mientras escribo las pruebas unitarias, me resulta difícil encontrar buenos nombres y buenas áreas para probar. Por ejemplo, tengo cosas como

  • Requiere Login_should_redirect_when_not_logged_in
  • Requiere inicio de sesión debe pasar a través de cuando se registra
  • Login_should_work_when_given_proper_credentials

Personalmente, creo que es un poco feo, aunque parezca "correcto". También tengo problemas para diferenciar entre pruebas simplemente escaneando sobre ellas (tengo que leer el nombre del método al menos dos veces para saber qué acaba de fallar)

Entonces, pensé que tal vez en lugar de escribir pruebas que simplemente prueben la funcionalidad, tal vez escriba un conjunto de pruebas que cubran escenarios.

Por ejemplo, este es un trozo de prueba que se me ocurrió:

public class Authentication_Bill
{
    public void Bill_has_no_account() 
    { //assert username "bill" not in UserStore
    }
    public void Bill_attempts_to_post_comment_but_is_redirected_to_login()
    { //Calls RequiredLogin and should redirect to login page
    }
    public void Bill_creates_account()
    { //pretend the login page doubled as registration and he made an account. Add the account here
    }
    public void Bill_logs_in_with_new_account()
    { //Login("bill", "password"). Assert not redirected to login page
    }
    public void Bill_can_now_post_comment()
    { //Calls RequiredLogin, but should not kill request or redirect to login page
    }
}

¿Es esto un patrón escuchado? He visto historias de aceptación y cosas así, pero esto es fundamentalmente diferente. La gran diferencia es que estoy ideando escenarios para "forzar" las pruebas. En lugar de intentar manualmente encontrar posibles interacciones que tendré que probar. Además, sé que esto fomenta las pruebas unitarias que no prueban exactamente un método y clase. Aunque creo que esto está bien. Además, soy consciente de que esto causará problemas al menos para algunos marcos de prueba, ya que generalmente asumen que las pruebas son independientes entre sí y el orden no importa (donde sería en este caso).

De todos modos, ¿es este un patrón aconsejable? ¿O sería esto un ajuste perfecto para las pruebas de integración de mi API en lugar de las pruebas de "unidad"? Esto es solo en un proyecto personal, así que estoy abierto a experimentos que pueden o no funcionar bien.

Earlz
fuente
44
Las líneas entre la unidad, la integración y las pruebas funcionales son borrosas, si tuviera que elegir un nombre para su comprobante de prueba, sería funcional.
Yannis
Creo que es cuestión de gustos. Personalmente, uso el nombre de lo que pruebo con el _testanexo y uso los comentarios para anotar qué resultados espero. Si es un proyecto personal, encuentre un estilo con el que se sienta cómodo y manténgalo.
Sr. Lister el
1
He escrito una respuesta con detalles sobre una forma más tradicional de escribir pruebas unitarias usando el patrón Arrange / Act / Assert, pero un amigo ha tenido mucho éxito usando github.com/cucumber/cucumber/wiki/Gherkin , que es utilizado para especificaciones y afaik puede generar pruebas de pepino.
StuperUser
Si bien no usaría el método que ha mostrado con nunit o similar, nspec tiene soporte para construir contexto y probar en una naturaleza más guiada por la historia: nspec.org
Mike
1
cambie "Bill" a "Usuario" y listo
Steven A. Lowe

Respuestas:

15

Sí, es una buena idea dar a sus pruebas nombres de los escenarios de ejemplo que está probando. Y el uso de su herramienta de prueba de unidad para algo más que solo pruebas de unidad puede estar bien, también, muchas personas hacen esto con éxito (yo también).

Pero no, definitivamente no es una buena idea escribir sus pruebas de manera tal que el orden de ejecución de las pruebas sea importante. Por ejemplo, NUnit permite al usuario seleccionar interactivamente qué prueba desea ejecutar, por lo que esto ya no funcionará de la manera prevista.

Puede evitar esto fácilmente aquí separando la parte principal de la prueba de cada prueba (incluida la "afirmación") de las partes que establecen su sistema en el estado inicial correcto. Usando su ejemplo anterior: escriba métodos para crear una cuenta, iniciar sesión y publicar un comentario, sin ninguna afirmación. Luego reutilice esos métodos en diferentes pruebas. También deberá agregar algún código al [Setup]método de sus dispositivos de prueba para asegurarse de que el sistema esté en un estado inicial correctamente definido (por ejemplo, no hay cuentas hasta ahora en la base de datos, nadie está conectado hasta ahora, etc.).

EDITAR: Por supuesto, esto parece estar en contra de la naturaleza de "historia" de sus pruebas, pero si le da nombres significativos a sus métodos auxiliares, encontrará sus historias dentro de cada prueba.

Entonces, debería verse así:

[TestFixture]
public class Authentication_Bill
{
    [Setup]
    public void Init()
    {  // bring the system in a predefined state, with noone logged in so far
    }

    [Test]
    public void Test_if_Bill_can_create_account()
    {
         CreateAccountForBill();
         // assert that the account was created properly 
    }

    [Test]
    public void Test_if_Bill_can_post_comment_after_login()
    { 
         // here is the "story" now
         CreateAccountForBill();
         LoginWithBillsAccount();
         AddCommentForBill();
        //  assert that the right things happened
    }

    private void CreateAccountForBill()
    {
        // ...
    }
    // ...
}
Doc Brown
fuente
Iría más allá y diría que usar una herramienta xUnit para ejecutar pruebas funcionales está bien, siempre y cuando no confundas las herramientas con el tipo de prueba, y mantengas estas pruebas separadas de las pruebas unitarias reales, para que los desarrolladores puedan todavía ejecuta pruebas unitarias rápidamente en el momento de la confirmación. Es probable que estos sean mucho más lentos que las pruebas unitarias.
bdsl
4

Un problema al contar una historia con las pruebas unitarias es que no hace explícito que las pruebas unitarias deben organizarse y ejecutarse de manera totalmente independiente entre sí.

Una buena prueba de unidad debe estar completamente aislada de todos los demás códigos dependientes, es la unidad de código más pequeña que se puede probar.

Esto brinda el beneficio de que, además de confirmar que el código funciona, si una prueba falla, obtienes el diagnóstico de dónde exactamente está mal el código de forma gratuita. Si una prueba no está aislada, tiene que ver de qué depende para saber exactamente qué salió mal y perder un beneficio importante de las pruebas unitarias. Tener el orden de ejecución también puede generar muchos falsos negativos, si una prueba falla, es posible que las siguientes pruebas fallen a pesar de que el código que prueban funciona perfectamente bien.

Un buen artículo con más profundidad es el clásico sobre pruebas híbridas sucias .

Para que las clases, los métodos y los resultados sean legibles, la gran prueba de Arte de la Unidad utiliza la convención de nomenclatura

Clase de prueba:

ClassUnderTestTests

Métodos de prueba:

MethodUnderTest_Condition_ExpectedResult

Para copiar el ejemplo de @Doc Brown, en lugar de usar [Configuración] que se ejecuta antes de cada prueba, escribo métodos auxiliares para construir objetos aislados para probar.

[TestFixture]
public class AuthenticationTests
{
    private Authentication GetAuthenticationUnderTest()
    {
        // create an isolated Authentication object ready for test
    }

    [Test]
    public void CreateAccount_WithValidCredentials_CreatesAccount()
    {
         //Arrange
         Authentication codeUnderTest = GetAuthenticationUnderTest();
         //Act
         Account result = codeUnderTest.CreateAccount("some", "valid", "data");
         //Assert
         //some assert
    }

    [Test]
    public void CreateAccount_WithInvalidCredentials_ThrowsException()
    {
         //Arrange
         Authentication codeUnderTest = GetAuthenticationUnderTest();
         Exception result;
         //Act
         try
         {
             codeUnderTest.CreateAccount("some", "invalid", "data");
         }
         catch(Exception e)
         {
             result = e;
         }
         //Assert
         //some assert
    }
}

Por lo tanto, las pruebas que fallan tienen un nombre significativo que le da una narración sobre exactamente qué método falló, la condición y el resultado esperado.

Así es como siempre he escrito pruebas unitarias, pero un amigo ha tenido mucho éxito con Gerkin .

StuperUser
fuente
1
Aunque creo que esta es una buena publicación, no estoy de acuerdo con lo que dice el artículo vinculado sobre la prueba "híbrida". En mi humilde opinión, tener pruebas de integración "pequeñas" (además, no como alternativa a las pruebas unitarias puras ) puede ser muy útil, incluso si no pueden decirle exactamente qué método contiene el código incorrecto. Si esas pruebas son mantenibles depende de qué tan limpio esté escrito el código para esas pruebas, no están "sucias" per se. Y creo que el objetivo de esas pruebas puede ser muy claro (como en el ejemplo del OP).
Doc Brown
3

Lo que estás describiendo suena más como el Diseño Conducido por el Comportamiento (BDD) que las pruebas unitarias para mí. Eche un vistazo a SpecFlow, que es una tecnología .NET BDD basada en Gherkin DSL.

Cosas poderosas que cualquier humano puede leer / escribir sin saber nada sobre codificación. Nuestro equipo de prueba está disfrutando de un gran éxito al aprovecharlo para nuestros paquetes de pruebas de integración.

En cuanto a las convenciones para las pruebas unitarias, la respuesta de @ DocBrown parece sólida.

Drew Marsh
fuente
Para información, BDD es exactamente como TDD, es solo el estilo de escritura el que cambia. Ejemplo: TDD = assert(value === expected)BDD = value.should.equals(expected)+ usted describe las características en capas que resuelven el problema de "independencia de prueba unitaria". Este es un gran estilo!
Offirmo