BDD (Behavior-Driven Development) en Python

Estamos acostumbrados a diseñar nuestros tests desde un punto de vista puramente técnico, pero esta no es la única manera posible. Utilizando un lenguaje más natural y usando un punto de vista más de negocio nos permitirá involucrar a más gente en el desarrollo de los mismos (project managers, support, etc.).

Hoy quiero mostraros como hacer BDD en Python y ver que ventajas nos aporta.

¿Qué es BDD?

BDD, cuyas siglas significan Behavior-Driven Development, es una técnica de diseño y desarrollo derivada de TDD (Test-Driven Development) lo que tiene ciertas implicaciones como:

  • Los tests se realizaran antes de empezar con el código.
  • Haremos iteraciones cortas dentro de un ciclo Red - Green - Refactor.

Centrándonos en BDD sus principales diferencias y ventajas frente a otros tipos de testing son:

  • Los tests se escriben como casos de uso y como tal sirven como documentación.
  • Los casos de uso nos hablarán del comportamiento no de la implementación.
  • Estos casos de uso se escribirán usando un lenguaje natural y común a toda la organización (Soporte, Project Managers, Stakeholders, etc.).
  • Los casos de uso se crean en ficheros con extensión .feature.
  • Existen palabras clave como Feature, Scenario, Given, When o Then.
  • Los pasos de los casos de uso se conocen como steps y en ellos introduciremos la lógica de nuestros tests.

Conceptos generales de BDD (Given, When & Then)

Para definir los casos de uso en BDD se debe usa el patrón Given-When-Then, que se define de la siguiente forma:

  • Given: En este paso definiremos el estado inicial de nuestro caso de uso.
  • When: En este paso realizaremos las acciones o eventos (se recomienda tener un solo When por escenario).
  • Then: En este paso describiremos el estado final esperado de nuestro caso de uso.

Así mismo haremos uso de términos como:

  • Feature: Para agrupar nuestros casos de uso.
  • Scenario: Para crear un caso de uso (por cada escenario haremos uso de Given-When-Then).
  • Scenario Outline: Para ejecutar un mismo caso de uso con diferentes valores de entrada.
    • Examples: Definición de variables de entrada a los tests y sus valores.

Un ejemplo práctico de caso de uso sería:

Feature: Usuarios
  Scenario: Listado de usuarios activos
    Given: Cuando consultamos la lista de usuarios activos
    When: Cuando se carga la lista.
    Then: Debemos obtener una lista de usuarios

BDD en Python

BDD está disponible en prácticamente todos los lenguajes de programación y Python no iba a ser menos.

En este post voy a usar una librería llamada Behave, ya que tiene una muy buena comunidad, es muy sencilla de usar y está bastante actualizada.

A raíz de publicar el post me comentaron que Behave no está tan actualizada como yo creía y que a día de hoy es más recomendable usar el plugin BDD de pytest, os dejo aquí el enlace por si lo queréis mirar.

En su propia documentación nos detallan su configuración mínima para que funcione:

features/MY_FEATURE.feature
features/steps/MY_STEPS.py

Es decir necesitamos al menos:

  • Una carpeta features.
    • Esta carpeta debe contener:
      • Un fichero con extension .feature para nuestros casos de uso.
      • Una carpeta steps.
        • Esta carpeta debe contener:
          • Un fichero Python que contendrá nuestros steps.

Resolvamos la kata FizzBuzz usando BDD

Una vez hablado de que es BDD y como usarlo en Python vamos a resolver una kata muy sencilla llamada FizzBuzz y que nos va a permitir centrar nuestras energías en los tests con BDD en lugar de en su lógica.

Creamos un proyecto nuevo con Poetry

Creamos una carpeta llamada fizzbuzz y dentro de ella ejecutamos poetry init -n (si no tienes instalado Poetry lo puedes instalar con pip install poetry).

A continuación añadimos los paquetes behave y expects a nuestro proyecto ejecutando poetry add behave expects.

En este punto ya podríamos ejecutar casos de uso con el comando poetry run behave.

Creamos nuestro primer caso de uso

Creamos un fichero .feature dentro de una carpeta features con el siguiente contenido:

Feature: FizzBuzz

  Scenario: FizzBuzz for number 3
     Given the number 3
     When calculates the result
     Then the result should be Fizz

Si volvemos a ejecutar el comando poetry run behave veremos que nuestro caso de uso ya está disponible.

Behave tiene una cosa superinteresante y es que si detecta que faltan los steps del caso de uso nos los sugiere cuando lanzamos el caso de uso:

You can implement step definitions for undefined steps with these snippets:

@given('the number 3')
def step_impl(context):
    raise NotImplementedError('STEP: Given the number 3')


@when('calculates the result')
def step_impl(context):
    raise NotImplementedError('STEP: When calculates the result')


@then('the result should be Fizz')
def step_impl(context):
    raise NotImplementedError('STEP: Then the result should be Fizz')

Aprovechemos estos snippets para completar nuestros pasos en el siguiente apartado.

Creamos los steps necesarios para nuestro caso de uso

Como hablábamos al principio del post para hacer el mapeo entre los keywords Given-When-Then y nuestro steps debemos hacer uso de los decoradores de behave con el mismo nombre (@given, @when, @then) en nuestros métodos .

Estos métodos reciben como parámetro un objeto context que se puede ver como una variable global que se va pasando entre steps y donde podremos almacenar valores para usar en steps posteriores.

Dicho lo cual creamos un fichero steps.py dentro de una carpeta features/steps con el siguiente contenido.

from behave import given, when, then
from expects import equal, expect

from fizzbuzz.main import FizzBuzz


@given('the number 3')
def number(context):
    context.number = 3


@when("calculates the result")
def calculates(context):
    context.result = FizzBuzz.calculates(num=context.number)


@then('the result should be Fizz')
def validates(context, result):
    expect(context.result).to(equal("Fizz"))

Primera implementación de FizzzBuzz

Una vez creados los test y ver que estamos en la fase en Red del ciclo BDD, procedemos a crear un fichero main.py donde ira el algoritmo para resolver la kata.

Como primer babe step vamos a hacer mínimo código necesario para pasar nuestro test.

class FizzBuzz:
    @staticmethod
    def calculates(num):
	return "Fizz"

Siguiente iteración FizzBuzz

Ahora mismo nuestra lógica funciona perfectamente pero sabemos que esta lejos de estar completa, necesitamos añadir nuevos tests que nos obliguen a modificar el código de producción.

Para ello vamos a editar tanto el .feature como el steps.py para añadir un nuevo caso de uso:

Feature: FizzBuzz

  Scenario: FizzBuzz for number 3
     Given the number 3
     When calculates the result
     Then the result should be Fizz

  Scenario: FizzBuzz for number 5
     Given the number 5
     When calculates the result
     Then the result should be Buzz
from behave import given, when, then
from expects import equal, expect

from fizzbuzz.main import FizzBuzz


@given('the number 3')
def number_3(context):
    context.number = 3


@given('the number 5')
def number_5(context):
    context.number = 5


@when("calculates the result")
def calculates(context):
    context.result = FizzBuzz.calculates(num=context.number)


@then('the result should be Fizz')
def validates_3(context):
    expect(context.result).to(equal("Buzz"))


@then('the result should be Buzz')
def validates_5(context):
    expect(context.result).to(equal("Buzz"))

Si ejecutamos poetry run behave veremos uno de los casos de uso en rojo. Actualicemos nuestro algoritmo para volver a la fase Green:

class FizzBuzz:
    @staticmethod
    def calculates(num):
	return "Fizz" if num % 3 == 0 else "Buzz"

Si ejecutamos nuevamente poetry run behave veremos los dos casos de uso en verde.

Fase Refactor

Dentro del ciclo BDD la fase de refactor es muy importante y debemos dedicarle tiempo. En nuestro caso ya podemos ver que hay ciertas duplicaciones que se pueden mejorar, asi que editemos tanto el .feature y el steps.py:

Feature: FizzBuzz

  Scenario: FizzBuzz for number 3
     Given the number "3"
     When calculates the result
     Then the result should be "Fizz"

  Scenario: FizzBuzz for number 5
     Given the number "5"
     When calculates the result
     Then the result should be "Buzz"
from behave import given, when, then
from expects import equal, expect
from fizzbuzzd.main import FizzBuzz


@given('the number "{number}"')
def number(context, number):
    context.number = number


@when("calculates the result")
def calculates(context):
    context.result = FizzBuzz.calculates(num=context.number)


@then('the result should be "{result}"')
def validates(context, result):
    expect(context.result).to(equal(result))

Con este cambio estamos haciendo uso de que en behave se conoce como step parameters, que no es ni mas ni menos que la posibilidad de reutilizar steps pasandole diferentes valores de entrada al mismo.

Si ejecutamos los tests nuevamente todo debería seguir en verde.

Introducimos ejemplos

Como primera aproximación no está mal, pero podemos ir más allá aun incluyendo el concepto de Examples en nuestros casos de uso, editemos el .feature:

Feature: FizzBuzz

  Scenario Outline: FizzBuzz calculations
     Given the number "<number>"
     When calculates the result
     Then the result should be "<result>"
     
     Examples: FizzBuzzs
     | number         | result        |
     | 3              | Fizz          |
     | 5              | Buzz          |
     | 15             | FizzBuzz      |

Hemos añadido un nuevo ejemplo lo que nos obliga a cambiar nuestro código de produccion, nuevamente con el mínimo código posible para que pasan los tests:

class FizzBuzz:
    @staticmethod
    def calculates(num):
	if num % 15 == 0:
		return "FizzBuzz"
	return "Fizz" if num % 3 == 0 else "Buzz"

Si lanzamos nuevamente poetry run behave deberíamos volver a tener todos lo tests en verde.

Solución final

Para acabar vamos a incluir los últimos tests que nos permitan cubrir todos los posibles escenarios de nuestro problema:

Feature: FizzBuzz

  Scenario Outline: FizzBuzz calculations
     Given the number "<number>"
     When calculates the result
     Then the result should be "<result>"
     
     Examples: FizzBuzzs
     | number         | result        |
     | 2              | 2             |
     | 4              | 4             |
     | 8              | 8             |
     | 3              | Fizz          |
     | 6              | Fizz          |
     | 9              | Fizz          |
     | 5              | Buzz          |
     | 10             | Buzz          |
     | 20             | Buzz          |
     | 15             | FizzBuzz      |
     | 30             | FizzBuzz      |
     | 45             | FizzBuzz      |

Lo que nos fuerza a actualizar nuestro algoritmo para estos nuevos ejemplos:

class FizzBuzz:
    @staticmethod
    def calculate(num):
        result = ""
        if num % 3 == 0:
            result += "Fizz"
        if num % 5 == 0:
            result += "Buzz"
        return result if result else f"{num}"

¡Y deberíamos tener de nuevo los tests en verde!

solved

Conclusiones

Y hasta aquí está breve introducción a BDD, espero que os haya gustado y que os pueda servir de cara a introducirla en vuestro flujo de trabajo si creéis que os puede aportar valor. Os dejo por aquí el código en un repositorio por si queréis echarle un ojo.

!Un saludo!