Compartir a través de


Puntos de datos

Diseño controlado por comportamiento con SpecFlow

Julie Lerman

Descargar el ejemplo de código

Julie LermanA estas alturas, ya debería estar familiarizado con mi inclinación a invitar a los desarrolladores a dar charlas sobre temas que me interesan, en el grupo de usuarios que lidero en Vermont. Esto ha producido columnas sobre temas como Knockout.js y Breeze.js. Todavía existen algunos temas que he tenido en mente desde hace tiempo, como la Segregación de responsabilidades de consultas de comandos (CQRS). Pero, recientemente, el arquitecto y evaluador Dennis Doire habló sobre SpecFlow y Selenium, dos herramientas para evaluadores que se dedican al desarrollo controlado por comportamiento (BDD). Una vez más, esto captó toda mi atención y mi mente comenzó a crear excusas para jugar con estas herramientas, aunque lo que realmente me interesó fue BDD. Si bien soy una persona de datos, mis días de diseño de aplicaciones desde las bases de datos ya están en el pasado y he volcado mi interés hacia el dominio.

BDD es una variación del desarrollo controlado por pruebas (TDD) que se enfoca en casos de usuarios y en la creación de lógica y pruebas alrededor de dichos casos. En vez de satisfacer solo una regla, se satisface un conjunto de actividades. Esto resulta bastante holístico, algo que me agrada y que finalmente hace que me interese mucho esta perspectiva. La idea consiste en que mientras una prueba unitaria típica puede garantizar que un evento único en un objeto del cliente funcione apropiadamente, BDD se enfoca en el caso más amplio del comportamiento que yo, como usuario, espero cuando uso el sistema que están creando para mí. BDD se suele usar para definir criterios de aceptación durante conversaciones con los clientes. Por ejemplo, cuando me siento frente a un equipo y completo un formulario de cliente nuevo y luego presiono el botón Guardar, el sistema debería almacenar la información del cliente y luego mostrarme un mensaje que indique que el cliente se almacenó correctamente.

O, tal vez, cuando activo la porción de Administración de clientes del software, debería automáticamente abrir el Cliente más reciente con el que trabajé en mi última sesión.

A partir de estos casos de usuario, se puede ver que BDD puede ser una técnica orientada a la IU para diseñar pruebas automatizadas, pero muchos de los escenarios se escriben antes del diseño de las IU. Además, gracias a herramientas como Selenium (docs.seleniumhq.org) y WatiN (watin.org), es posible automatizar pruebas en el explorador. Pero BDD es más que la descripción de la interacción del usuario. Para comprender mejor la idea general de BDD, revise el panel de conversación de InfoQ sobre algunas de las autoridades en BDD, TDD y Especificación por ejemplo en bit.ly/10jp6ve.

Ahora me quiero alejar de todo lo relacionado con hacer clic en botones, para poder redefinir los casos de usuario. Puedo eliminar los elementos que dependen de la IU en el caso y centrarme en la parte del proceso que no depende de la pantalla. Por supuesto, los casos que me interesan son aquellos relacionados con el acceso a datos.

Crear la lógica para probar que se cumple con comportamiento en especial puede ser una tarea tediosa. Una de las herramientas que Doire mostró en su presentación fue SpecFlow (specflow.org). Esta herramienta se integra con Visual Studio y permite definir casos de usuario (llamados escenarios) mediante el uso de sus sencillas reglas. Luego automatiza parte de la creación y ejecución de los métodos (parte con pruebas y parte sin ellas). El objetivo es validar que se cumpla con las reglas del caso.

Voy a guiarlo paso a paso en la creación de algunos comportamientos para abrir su apetito y posteriormente, si se despierta su interés, podrá encontrar algunos recursos al final del artículo.

En primer lugar, debe instalar SpecFlow en Visual Studio, tarea que puede realizar desde el administrador de extensiones y actualizaciones de Visual Studio. Debido a que el objetivo de BDD consiste en comenzar el desarrollo de su proyecto con la descripción de comportamientos, el primer proyecto de su solución es un proyecto de prueba donde describirá estos comportamientos. El resto de la solución se desarrolla a partir de ese punto.

Cree un nuevo proyecto mediante el uso de la plantilla de prueba unitaria. Su proyecto necesitará una referencia para TechTalk.SpecFlow.dll, la que puede instalar mediante NuGet. Luego cree una carpeta llamada Features (Funciones) dentro de este proyecto.

Mi primera función se basará en un caso de usuario relacionado con agregar un nuevo cliente, por lo que dentro de la carpeta Features creo otra más llamada Add (Agregar) (vea la Figura 1). Aquí es donde definiré mi escenario y donde necesitaré la ayuda de SpecFlow.

Test Project with Features and Add Sub-FoldersFigura 1. Proyecto de prueba con las subcarpetas Features y Add

SpecFlow sigue un patrón específico que depende de palabras clave que ayudan a describir la función cuyo comportamiento está definiendo. Las palabras clave se derivan de un lenguaje llamado Gherkin (igual que un tipo de pepinillo) y todo esto se origina en una herramienta llamada Cucumber (cukes.info). Algunas de estas palabras clave son Given, And, When y Then (Dado, Y, Cuando y Luego) y puede usarlas para crear un escenario. Por ejemplo, este es un escenario que está encapsulado en una función; Agregar un nuevo cliente:

Given a user has entered information about a customer
When she completes entering more information
Then that customer should be stored in the system

Puede ser un poco más específico, por ejemplo:

Given a user has entered information about a customer
And she has provided a first name and a last name as required
When she completes entering more information
Then that customer should be stored in the system

En esta última instrucción realizaré parte de la persistencia de datos. SpecFlow no se preocupa por la forma en que todo esto sucede. La meta consiste en escribir escenarios para demostrar si se alcanza el resultado y si este se mantiene. El escenario impulsará el conjunto de pruebas y las pruebas le ayudarán a configurar la lógica del dominio:

Given that you have used the proper keywords
When you trigger SpecFlow
Then a set of steps will be generated for you to populate with code
And a class file will be generated that will automate the execution of these steps on your behalf

Veamos cómo funciona.

Haga clic con el botón secundario en la carpeta Add para agregar un nuevo elemento. Si instaló SpecFlow, puede encontrar tres elementos relacionados con SpecFlow al buscar en specflow. Seleccione el archivo Feature de SpecFlow y asígnele un nombre. Yo le asigné el nombre AddCustomer.feature.

Un archivo de función comienza con un ejemplo, la función de matemática ubicua. Observe que la función se describe en la parte superior y en la parte inferior se describe un escenario (que representa un ejemplo clave de la función) mediante el uso de Given, And, When y Then. El complemento SpecFlow asegura que el texto tenga un código por color, para que pueda discernir fácilmente entre los términos del paso y sus propias instrucciones.

Voy a reemplazar la función y los pasos grabados con los míos:

Feature: Add Customer
Allow users to create and store new customers
As long as the new customers have a first and last name

Scenario: HappyPath
Given a user has entered information about a customer
And she has provided a first name and a last name as required
When she completes entering more information
Then that customer should be stored in the system

(¡Gracias a David Starr por el nombre del escenario! Lo robé de su vídeo Pluralsight).

¿Qué pasa si no se cuenta con los datos? Crearé otro escenario en esta función para administrar esa posibilidad:

Scenario: Missing Required Data
Given a user has entered information about a customer
And she has not provided the first name and last name
When she completes entering more information
Then that user will be notified about the missing data
And the customer will not be stored into the system

Eso será suficiente por ahora.

Del caso de usuario a algo de código

Hasta ahora hemos visto el elemento Feature y cierto código de color que SpecFlow proporciona. Observe que existe un archivo de código subyacente adjunto al archivo de la función, el cual posee algunas pruebas vacías que se crearon a partir de estas funciones. Cada una de estas pruebas ejecutará los pasos en su escenario, pero primero debe crear estos pasos. Existen varias maneras de hacerlo. Puede ejecutar estas pruebas y SpecFlow devolverá un listado de códigos para la clase Steps en el resultado de la prueba, para que pueda copiarlo y pegarlo. Alternativamente, puede usar una herramienta en el menú contextual del archivo de la función. Ahora describiré el segundo enfoque:

  1. Haga clic con el botón secundario en la ventana del proyecto de prueba del archivo de la función. En el menú contextual, verá una sección dedicada a las tareas de SpecFlow.
  2. Haga clic en Generar definiciones del paso. Aparecerá una ventana que comprueba los pasos a crear.
  3. Haga clic en el botón Copiar los métodos en el portapapeles y use los parámetros predeterminados.
  4. En la carpeta AddCustomer de su proyecto, cree un nuevo archivo de clase llamado Steps.cs.
  5. Abra el archivo y dentro de la definición de la clase pegue los contenidos del portapapeles.
  6. Agregue una referencia de espacio de nombres en la parte superior del archivo, mediante el uso de TechTalk.SpecFlow.
  7. Agregue una anotación de enlace a la clase.

La nueva clase se muestra en la Figura 2.

Figura 2. El archivo Steps.cs

[Binding]
public class Steps
{
  [Given(@"a user has entered information about a customer")]
  public void GivenAUserHasEnteredInformationAboutACustomer()
  {
    ScenarioContext.Current.Pending();
  }
  [Given(@"she has provided a first name and a last name as required")]
  public void GivenSheHasProvidedAFirstNameAndALastNameAsRequired
 ()
  {
    ScenarioContext.Current.Pending();
  }
    [When(@"she completes entering more information")]
  public void WhenSheCompletesEnteringMoreInformation()
  {
    ScenarioContext.Current.Pending();
  }
  [Then(@"that customer should be stored in the system")]
  public void ThenThatCustomerShouldBeStoredInTheSystem()
  {
    ScenarioContext.Current.Pending();
  }
  [Given(@"she has not provided both the firstname and lastname")]
  public void GivenSheHasNotProvidedBothTheFirstnameAndLastname()
  {
    ScenarioContext.Current.Pending();
  }
  [Then(@"that user will get a message")]
  public void ThenThatUserWillGetAMessage()
  {
    ScenarioContext.Current.Pending();
  }
  [Then(@"the customer will not be stored into the system")]
  public void ThenTheCustomerWillNotBeStoredIntoTheSystem()
  {
    ScenarioContext.Current.Pending();
  }
}

Si observa los dos escenarios que he creado, notará que si bien existe cierta superposición en lo que se define (como "un usuario ingresó información sobre un cliente"), los métodos generados no crean pasos duplicados. También puede observar que SpecFlow aprovechará las constantes en los atributos del método. Los verdaderos nombres del método no son importantes.

En este punto, puede dejar que SpecFlow ejecute las pruebas que solicitarán estos métodos. Mientras que SpecFlow admite un número de marcos de pruebas unitarias, yo estoy usando MSTest, por lo que si está viendo esta solución en Visual Studio, notará que el archivo del código subyacente de la función define un TestMethod para cada escenario. Cada TestMethod ejecuta la combinación correcta de métodos de paso con un TestMethod que se ejecuta para el escenario HappyPath.

Si ejecutara esto ahora, al hacer clic con el botón secundario en el archivo Feature y al elegir "Ejecutar escenarios de SpecFlow", la prueba no sería concluyente y se vería el siguiente mensaje: "Una o más definiciones de paso aún no se implementan". Esto es porque cada uno de los métodos en el archivo Steps todavía solicita Scenario.Current.Pending.

Así, es hora de estructurar los métodos. Mis escenarios me dicen que necesitaré un tipo de cliente con algunos datos obligatorios. Gracias a otra documentación, sé que se requiere el nombre y el apellido, por lo que necesitaré esas dos propiedades en el tipo de cliente. También necesito un mecanismo para almacenar el cliente, al igual que un lugar para guardarlo. Mis pruebas no se interesan en cómo o dónde se almacenan, siempre y cuando se guarden, por lo que usaré un repositorio que será responsable de recuperar y almacenar los datos.

Comenzaré por agregar las variables _customer y _repository a mi clase Steps:

private Customer _customer;
private Repository _repository;

Luego, reemplazo una clase Customer:

public class Customer
{
  public int Id { get; set; }
  public string FirstName { get; set; }
  public string LastName { get; set; }
}

Eso es suficiente para permitirme agregar código a los métodos de paso. La Figura 3 muestra la lógica agregada a los pasos relacionados con HappyPath. Creo un nuevo cliente en uno y luego incluyo el nombre y apellido obligatorios en el siguiente. Realmente no hay nada que tenga que hacer en el paso WhenSheCompletesEnteringMoreInformation.

Figura 3. Algunos de los métodos de paso de SpecFlow

[Given(@"a user has entered information about a customer")]
public void GivenAUserHasEnteredInformationAboutACustomer()
{
  _newCustomer = new Customer();
}
[Given(@"she has provided a first name and a last name as required")]
public void GivenSheHasProvidedTheRequiredData()
{
  _newCustomer.FirstName = "Julie";
  _newCustomer.LastName = "Lerman";
}
[When(@"she completes entering more information")]
public void WhenSheCompletesEnteringMoreInformation()
{
}

El último paso es el más interesante. Aquí es donde no solo almaceno el cliente, sino que demuestro que realmente se guardó. Necesitaré un método Add en mi repositorio para almacenar el cliente, un Save para enviarlo a la base de datos y luego alguna forma para ver si finalmente el repositorio puede encontrar ese cliente. Por lo tanto, voy a agregar un método Add, otro Save y un método FindById a mi repositorio, de esta forma:

public class CustomerRepository
{
  public void Add(Customer customer)
    { throw new NotImplementedException();  }
  public int Save()
    { throw new NotImplementedException();  }
  public Customer FindById(int id)
    { throw new NotImplementedException();  }
}

Ahora puedo agregar una lógica al paso final que mi escenario HappyPath pueda solicitar. Voy a agregar el cliente al repositorio y comprobar si este lo puede encontrar. Aquí es donde finalmente uso una aserción para determinar si mi escenario es correcto. Si se encuentra un cliente (es decir, IsNotNull), se pasa la prueba. Este es un patrón muy común para probar que los datos se hayan almacenado. Sin embargo, según mi experiencia con Entity Framework, existe un problema que no se verá reflejado en la prueba. Comenzaré con el siguiente código para que pueda ver el problema de manera tal que sea más fácil de recordar que si simplemente mostrara la forma correcta desde el principio (me refiero a la forma):

[Then(@"that customer should be stored in the system")]
public void ThenThatCustomerShouldBeStoredInTheSystem()
{
  _repository = new CustomerRepository();
  _repository.Add(_newCustomer);
  _repository.Save();
  Assert.IsNotNull(_repository.FindById(_newCustomer.Id));
}

Cuando vuelvo a ejecutar la prueba de HappyPath, no se completa correctamente. Puede ver en la Figura 4 que el resultado de la prueba muestra cómo funciona mi escenario SpecFlow hasta ahora. Pero preste atención al motivo por el que no se pasó la prueba: no es porque FindById no encontró al cliente, sino porque los métodos de mi repositorio todavía no están implementados.

Figura 4. Resultado de la prueba fallida que muestra el estado de cada paso

Test Name:  HappyPath
Test Outcome:               Failed
Result Message:             
Test method UnitTestProject1.UserStories.Add.AddCustomerFeature.HappyPath threw exception:
System.NotImplementedException: The method or operation is not implemented.
Result StandardOutput:     
Given a user has entered information about a customer
-> done: Steps.GivenAUserHasEnteredInformationAboutACustomer() (0.0s)
And she has provided a first name and a last name as required
-> done: Steps. GivenSheHasProvidedAFirstNameAndALastNameAsRequired() (0.0s)
When she completes entering more information
-> done: Steps.WhenSheCompletesEnteringMoreInformation() (0.0s)
Then that customer should be stored in the system
-> error: The method or operation is not implemented.

Así, mi siguiente paso es proporcionarle una lógica al repositorio. Finalmente usaré este repositorio para interactuar con la base de datos y, porque soy una fanática de Entity Framework, usaré un Entity Framework DbContext en mi repositorio. Comenzaré por crear una clase DbContext que expone un Customers DbSet:

public class CustomerContext:DbContext
{
  public DbSet<Customer> Customers { get; set; }
}

Luego puedo refactorizar mi CustomerRepository para usar el CustomerContext para la persistencia. Para esta demostración, trabajaré directamente con el contexto, en vez de preocuparme por las abstracciones. Este es el CustomerRepository actualizado:

public  class CustomerRepository
{
  private CustomerContext _context = new CustomerContext();
  public void Add(Customer customer
  {    _context.Customers.Add(customer);  }
  public int Save()
  {    return _context.SaveChanges();  }
  public Customer FindById(int id)
  {    return _context.Customers.Find(id);  }
}

Ahora cuando vuelvo a ejecutar la prueba HappyPath, se pasa correctamente y todos mis pasos se marcan como listos. Pero aún no estoy satisfecha.

Asegurarse de que las pruebas de integración comprendan el comportamiento EF

¿Por qué todavía no estoy satisfecha cuando todas mis pruebas se pasan correctamente y veo aquel bello círculo verde? Porque sé que la prueba no demuestra realmente que se almacenó el cliente.

En el método ThenThatCustomerShouldBeStoredInTheSystem, comente para excluir la solicitud para Save y vuelva a ejecutar la prueba. Todavía se pasa. ¡Y ni siquiera guardé el cliente en la base de datos! ¿Ahora entiende a lo que me refería? Es lo que se conoce como un "falso positivo".

El problema es que el método DbSet Find que uso en mi repositorio es un método especial en Entity Framework que primero comprueba los objetos en memoria, de los cuales el contexto lleva registro, antes de ir a la base de datos. Cuando solicité Add, hice que CustomerContext reconociera la instancia del cliente. La solicitud de Customers.Find descubrió esa instancia y se saltó un viaje en vano a la base de datos. De hecho, el id. del cliente sigue siendo 0 porque aún no se almacena.

De esta forma y porque estoy usando Entity Framework (y debería considerar el comportamiento de cualquier marco de asignación de objetos relacionales [ORM] que esté usando), tengo una forma más sencilla de comprobar si el cliente realmente llegó a la base de datos. Cuando la instrucción EF SaveChanges inserta el cliente en la base de datos, recogerá el nuevo id. del cliente generado por la base de datos y la aplicará a la instancia que insertó. Por lo tanto, si el nuevo id. del cliente ya no es 0, puedo estar segura de que el cliente realmente llegó a la base de datos. No tengo que volver a realizar una solicitud a la base de datos.

Por consiguiente, revisaré Assert para ese método. A continuación está el método que sé que realizará una prueba apropiada:

[Then(@"that customer should be stored in the system")]
  public void ThenThatCustomerShouldBeStoredInTheSystem()
  {
    _repository = new CustomerRepository();
    _repository.Add(_newCustomer);
    _repository.Save();
    Assert.IsNotNull(_newCustomer.Id>0);
  }

Pasa y estoy segura de que lo hace por los motivos correctos. No es inusual definir una prueba fallida, por ejemplo, mediante el uso de Assert.IsNull(FindById(customer.Id) para asegurarse de que no se está aprobando por los motivos incorrectos. Pero en este caso, el problema tampoco hubiera aparecido sin que hubiera eliminado la solicitud para Save. Si no está seguro sobre el funcionamiento de EF, puede ser una buena idea crear además algunas pruebas de integración específicas, que no se relacionen con los casos de usuario, con el fin de garantizar que sus repositorios se comporten de la manera esperada.

¿Prueba de comportamiento o prueba de integración?

A medida que avancé a lo largo de la curva de aprendizaje de este primer escenario SpecFlow, me encontré con un camino algo incierto. Mi escenario declara que el cliente se debería almacenar en "el sistema".

El problema es que no estaba segura de la definición del sistema. Mi experiencia me dice que la base de datos o, por lo menos, algún tipo de mecanismo de persistencia es una parte muy importante del sistema.

El usuario no está interesado en repositorios ni bases de datos, solo su aplicación. Pero no estará demasiado feliz si vuelve a iniciar sesión en su aplicación y no puede encontrar al cliente nuevamente porque nunca se almacenó en la base de datos (porque no pensé que _repository.Save fuera necesario para cumplir con su escenario).

Le pedí ayuda a otro Dennis, Dennis Doomen, el autor de Fluent Assertions y un practicante acérrimo de BDD, TDD y más en grandes sistemas empresariales. Él me confirmó que, como desarrollador, yo debería aplicar mi conocimiento a los pasos y las pruebas, incluso si esto significaba que tuviera que ir más allá de la intención del usuario que definió el escenario original. Los usuarios proporcionan su conocimiento y yo agrego el mío, de tal manera que no les imponga mi perspectiva técnica. Sigo hablando su idioma y soy capaz de comunicarme con ella.

Más detalles sobre BDD y SpecFlow

Estoy bastante segura de que si no hubiera sido por todas las herramientas que se han desarrollado para respaldar BDD, mi camino no hubiera sido tan sencillo. Si bien soy una fanática de los datos, también me interesa mucho trabajar con mis clientes, comprender sus negocios y garantizar que tengan una experiencia agradable cuando usen el software que ayudo a construir para ellos. Esta es la razón de por qué el Diseño controlado por dominio y el Diseño controlado por comportamiento me resultan tan atrayentes. Creo que muchos desarrolladores piensan igual que yo (incluso si es de forma mucho más interna) y es posible que también se inspiren gracias a estas técnicas.

Además de los amigos que me ayudaron a llegar hasta acá, a continuación comparto algunos de los recursos que me resultaron útiles. El artículo de MSDN Magazine "Desarrollo controlado por comportamiento (Behavior-Driven Development, BDD) con SpecFlow y WatiN", el que se puede encontrar en msdn.microsoft.com/magazine/gg490346, resultó de mucha ayuda. También vi un excelente módulo en el curso Test First Development de David Starr en Pluralsight.com. (De hecho, vi ese módulo varias veces). Creo que el artículo de Wikipedia sobre BDD (bit.ly/LCgkxf) (en inglés) es bastante interesante, ya que presenta el panorama más amplio de la historia de BDD y dónde se encaja con relación a otras prácticas. Estoy esperando con ansias la aparición del libro “BDD and Cucumber”, cuyo coautor, Paul Rayner, también me aconsejó para este artículo.

Julie Lerman ha recibido el premio al Profesional más valioso (MVP) de Microsoft, es consultora y mentor de .NET, y vive en las colinas de Vermont. Realiza presentaciones sobre acceso a datos y otros temas de Microsoft .NET en grupos de usuarios y congresos en todo el mundo. Mantiene un blog en thedatafarm.com/blog y es la autora de “Programming Entity Framework” (2010), además de una edición para Code First (2011) y una para DbContext (2012), todos de O’Reilly Media. Puede seguirla a través de Twitter en twitter.com/julielerman.

Gracias a los siguientes expertos técnicos por su ayuda en la revisión de este artículo: Dennis Doomen (Aviva Solutions) y Paul Rayner (Virtual Genius)
Dennis Doomen es consultor principal en Aviva Solutions (Países Bajos), orador ocasional, ágil tutor, autor de las Pautas de códigos para C# 3.0, 4.0 y 5.0, el marco de Fluent Assertions y la Guía de compatibilidad de Silverlight. Actualmente desarrolla soluciones de clase empresarial basadas en .NET, Event Sourcing y CQRS. Es un fanático del desarrollo ágil, la arquitectura, la programación extrema y el diseño controlado por dominio. Puede ponerse en contacto con él por Twitter en @ddoomen.