BDD + TDD para descubrir el diseño de tu código

Publicado por Eduardo Riol el

QABDDTDD

Hace algunas semanas estuve en la CAS 2017 hablando de BDD (Behaviour Driven Development) y una de las cosas que más llamó la atención a la gente fue la posibilidad de hacer BDD no solamente implementando las especificaciones como tests contra la interfaz de usuario (vía Selenium o cualquier otra herramienta), como tradicionalmente se ha (mal)entendido BDD, sino poder implementar las especificaciones de nuestros usuarios también a nivel unitario.

La realidad es que no sólo es posible implementar las especificaciones ejecutables a bajo nivel, con las entidades y lógica de negocio de nuestra aplicación en lugar de a través de la UI, sino que la mayoría de las veces es lo más deseable.

Casi todos nosotros siempre hemos asociado "tests de aceptación" con "tests funcionales de usuario", y esto nos lleva a pensar en BDD como una forma fácilmente comprensible por personas no técnicas, de entender las pruebas que estamos automatizando con Selenium. Pero la realidad es muy distinta. Y mucho más potente. Lo cierto es que implementando BDD a bajo nivel y combinándolo con TDD, es como realmente aprovechamos toda la potencia de este modelo de colaboración, porque es la forma en la que realmente la definición de los comportamientos dirige el desarrollo (recordemos BDD = Behaviour Driven Development), ayudándonos a diseñar el código que realmente queremos y descubrir las clases y métodos que precisamos para satisfacer las necesidades de nuestro negocio. En este artículo explico cómo.

Empezamos. Todo el código que vamos a ver lo puedes encontrar en mi GitHub: https://github.com/eduriol/bdd-unit-example

Identificando nuestro Negocio

Para el caso que nos ocupa vamos a imaginar que nuestro Negocio es una tienda de barrio que desea implementar un sistema de gestión de sus clientes, para poder registrar a los mismos y obtener diversos beneficios. No es mi intención en este post explicar BDD en profundidad, pero como seguramente sepáis este modelo de colaboración se basa en especificar el software que necesitan nuestros usuarios con ejemplos concretos usando un lenguaje sencillo y tabulado.

Tras una primera reunión, lo primero que nos indican nuestros usuarios es que quieren que el personal de caja de la tienda registre nuevos clientes en el sistema, y que al hacerlo el nuevo cliente debe empezar con 100 puntos del programa de fidelización, además de recibir una notificación de bienvenida. Una descripción de estas necesidades, descrita como una feature de Gherkin, puede ser la siguiente:

Feature: Sign up new customer into the platform
 In order to fidelize customers
 As a cashier
 I want to sign up new customers
Scenario: new customers should start with 100 points
     Given Marty McFly wants to become a new customer
     When I signup him into the platform
     Then he starts with 100 points
Scenario: new customers should receive a welcome notification
     Given Jennifer Parker wants to become a new customer
     When I signup her into the platform
     Then she receives a welcome notification

Como vemos el objetivo de Negocio de nuestra feature es "fidelizar clientes". Es muy importante al hacer BDD tener siempre en mente el objetivo con el que implementamos el código, y por eso me gusta cambiar el orden tradicional de As a / I want to / In order to, poniendo por delante el In order to. Conjuntamente con la feature, definimos dos escenarios con clientes concretos, con nombres y apellidos, con la popular estructura Given / When / Then.

Implementando los escenarios

Como estamos trabajando haciendo BDD de verdad, es decir definiendo las features y escenarios previamente a la existencia del código, tenemos libertad para definir las clases y métodos que realmente necesitamos, diseñando el código que nos gustaría tener sin caer en desperdicios (entendidos desde el punto de vista de Lean). Podemos implementar los escenarios definidos con el siguiente código:

public class Steps {

    String name;
    String surname;
    Customer customer;

    @Given("^(.+) (.+) wants to become a new customer$")
    public void wants_to_become_a_new_customer(String name, String surname) {
        this.name = name;
        this.surname = surname;
    }

    @When("^I signup (?:him|her) into the platform$")
    public void signup_somebody_into_the_platform() {
        this.customer = new Customer(name, surname);
    }

    @Then("^(?:he|she) starts with (\\d+) points$")
    public void starts_with_points(int expectedPoints) {
        assertEquals(expectedPoints, this.customer.getPoints());
    }

    @Then("^(?:he|she) receives a welcome notification$")
    public void she_receives_a_welcome_notification() {
        assertEquals("Welcome to our platform!", this.customer.getLastNotificationReceived().getTitle());
    }

}

Lo más significativo de lo que hemos hecho ha sido descubrir que necesitamos una clase Customer para nuestro cliente que se instancie con su nombre y apellido, así como los métodos que recuperan los puntos del cliente y la última notificación que ha recibido. Pero insistimos, este código aún no existe, sólo su especificación. Y aquí es donde entra TDD.

Realizando TDD a nivel unitario

Previamente a crear las clases que necesitamos para satisfacer las especificaciones de negocio debemos crear los tests unitarios que definan el comportamiento de los métodos que hemos descubierto gracias a BDD:

public class WhenSigningUpANewCustomerTest {

    @Test
    public void the_customer_should_be_correctly_set_up() {
        Customer customer = new Customer("Marty", "McFly");
        assertEquals("Marty", customer.getName());
        assertEquals("McFly", customer.getSurname());
    }

    @Test
    public void the_customer_should_receive_100_points_when_set_up() {
        Customer customer = new Customer("Marty", "McFly");
        assertEquals(100, customer.getPoints());
    }

    @Test
    public void the_customer_should_receive_a_welcome_message() {
        Customer customer = new Customer("Marty", "McFly");
        assertEquals("Welcome to our platform!", customer.getLastNotificationReceived().getTitle());
    }

}

Tras definir los tests unitarios, ya podemos implementar como un POJO de toda la vida la clase Customer que necesitamos para satisfacer los tests:

public class Customer {

    private String name;
    private String surname;
    private int numberOfPoints;
    private List<CustomerNotification> notificationsReceived;
    private static final int INITIAL_NUMBER_OF_POINTS = 100;
    private static final String WELCOME_MESSAGE = "Welcome to our platform!";

    public Customer(String name, String surname) {
        this.name = name;
        this.surname = surname;
        this.numberOfPoints = Customer.INITIAL_NUMBER_OF_POINTS;
        this.notificationsReceived = new ArrayList<>();
        this.notificationsReceived.add(new CustomerNotification(Customer.WELCOME_MESSAGE, this));
    }

    public String getName() {
        return name;
    }

    public String getSurname() {
        return surname;
    }

    public int getPoints() {
        return numberOfPoints;
    }

    public CustomerNotification getLastNotificationReceived() {
        return this.notificationsReceived.get(this.notificationsReceived.size() - 1);
    }

}

Al definir la clase Customer nos damos damos cuenta de que para satisfacer las necesidades de nuestros usuarios, también necesitamos una clase CustomerNotification que aún no existe. Lo primero como siempre es hacer TDD para especificar el código que necesitamos para definir esta clase:

public class WhenCreatingANewNotificationTest {

    @Test
    public void the_notification_should_be_correctly_created() {
        Customer customer = new Customer("Marty", "McFly");
        CustomerNotification notification = new CustomerNotification("Welcome to our platform!", customer);
        assertEquals("Welcome to our platform!", notification.getTitle());
        assertEquals("Marty McFly", notification.getReceiverName());
    }

}

Una vez definido mediante los tests el comportamiento que necesitamos de la clase CustomerNotification, podemos implementarla como otro POJO corriente y moliente:

public class CustomerNotification {

    private String title;
    private Customer receiver;

    public CustomerNotification(String title, Customer receiver) {
        this.title = title;
        this.receiver = receiver;
    }

    public String getTitle() {
        return this.title;
    }

    public String getReceiverName() {
        return this.receiver.getName() + " " + this.receiver.getSurname();
    }

}

Si ahora ejecutamos nuestros tests y nuestra feature, veremos que por fin pasan correctamente, lo que significa que ha sido correctamente implementada. Por supuesto tendremos pendiente la creación del front de nuestra aplicación, que seguramente merezca algún test de regresión con Selenium o similares, pero ya tendremos un juego de tests de aceptación que hacen que el número de tests contra la UI a mantener sea muy limitado.

Concluyendo

Hemos visto cómo realizar un ciclo completo de BDD + TDD para implementar de una manera limpia y mantenible una funcionalidad simple que necesitan nuestros usuarios. En el repositorio de código que comentaba al principio del artículo se incluye la definición e implementación de otra feature de negocio relativa a la obtención de listas de clientes registrados en mi tienda en una fecha determinada, que nos llevará a la necesidad de modificar las clases que habíamos implementado así como a extender el código con nuevas entidades.

Si te ha gustado, síguenos en Twitter, y si vives en Madrid, ven a conocernos en nuestro grupo de Meetup.

Autor

Eduardo Riol

Software Engineer in knowmad mood 💻 | Data-intensive distributed systems | Rich experience in high performance engineering teams