Mejora tus tests usando Builders

¿Alguna vez has tenido que cambiar multitud de tests después de añadir una propiedad en una clase? Si es así tengo malas noticias para ti, posiblemente tus tests no están bien hechos. ¡Por suerte hoy vamos a ver como solucionarlo!

Smell en los tests

Por simplicidad vamos a asumir que estamos trabajando en una arquitectura en capas con el patrón CQRS, usando implementaciones en lugar de interfaces. Si quieres pararte aquí y echarle un ojo a todos estos conceptos te dejo un repo de ejemplo.

Imaginemos por un momento que tenemos en nuestro dominio el concepto Student representado por la siguiente clase:

from dataclasses import dataclass

@dataclass
class Student:
	name: str
	last_name: str
	age: int
	email: str
	phone: str
	address: str
	zip_code: int
	country: str

Imaginemos también que nos asignan una nueva tarea que consiste en implementar la creación de Students en nuestra app.

Para ello vamos a crear un CreateStudentCommandHandler como caso de uso, este caso de uso tendrá un colaborador de tipo StudentRepository que será el encargado de hacer la creación de los estudiantes.

Como no puede ser de otra manera vamos a trabajar con TDD por lo que antes de nada vamos a crear un test que nos permita replicar cómo sería la creación de un Student en nuestro sistema:

from doublex import Mimic, Spy
from expects import have_been_called_with
...

class TestCreateCommandHandler:
	def test_creates_a_student(self) -> None:
		student = Student(
			name = "any-name"
			last_name = "any-last-name"
			age = 1
			email = "any-email"
			phone = "+34123456789"
			address = "any-address"
			zip_code = 12345
			country = "any-contry")
		create_student_command = CreateStudentCommand(student)
		student_repository = Mimic(Spy, StudentRepository)
		create_student_command_handler = CreateStudentCommandHandler(student_repository)

		create_student_command.process(create_student_command)
	
		expect(student_repository.save).to(have_been_called_with(student))

¿Os llama la atención algo en este test? Para mí gusto tiene varios smells:

  • Es muy grande, un test de más de 5-6 líneas no es buena señal.
    • La parte del arrange es gigante (12 líneas del total de 14 del test).
  • Hay muchos valores que no son relevantes (toda la parte de creación del Student) para el test generando una carga cognitiva mayor.
  • Es difícil saber a simple vista qué hace este test.

Ahora imaginemos que como este ejemplo tenemos decenas de tests con una estructura similar, es decir creando una instancia de la clase Student.

¿Qué ocurriría si por necesidades del dominio tenemos que añadir una propiedad a nuestra clase Student?

Pues imagino que ya me veréis venir, tendremos que actualizar todos los tests que creen un objeto Student, lo que para mí es un smell importante.

¡Así que vamos a solucionarlo!

Builder Pattern al rescate

Por suerte este smell tiene fácil solución usando el patrón Builder el cual se caracteriza principalmente por facilitar enormemente la creación de objetos.

Además vamos a lograr una mejora extra en nuestros tests ya que se van a simplificar muchísimo como veremos más adelante.

class StudentBuilder:
	def __init__(self) -> None:
		self._name = "any-name"
		self._last_name = "any-last-name"
		self._age = 1
		self._email = "any-email"
		self._phone = "+34123456789"
		self._address = "any-address"
		self._zip_code = 12345
		self._country = "any-country"

	def build(self) -> Student:
        	return Student(
        		name = self._name
			last_name = self._last_name
			age = self._age
			email = self._email
			phone = self._phone
			address = self._address
			zip_code = self._zip_code
			country = self._country)

Más allá de las peculiaridades que puede tener crear un Builder en Python el patrón no tiene mucha historia. En su constructor asignamos unos valores por defecto y en el método build creamos el objeto Student.

Una vez disponemos de nuestro Builder vamos a actualizar nuestro test haciendo uso de él.

from doublex import Mimic, Spy
from expects import have_been_called_with
...

class TestCreateCommandHandler:
	def test_creates_a_student(self) -> None:
		student = StudentBuilder().build()
	 	create_student_command = CreateStudentCommand(student)
	 	student_repository = Mimic(Spy, StudentRepository)
	 	create_student_command_handler = CreateStudentCommandHandler(student_repository)

	 	create_student_command.process(create_student_command)

	 	expect(student_repository.save).to(have_been_called_with(student))

Aquí destacaría dos cosas:

  • El test se ha reducido de 14 líneas a 6 (casi un 60%!) por el simple hecho de usar un StudentBuilder.
  • Ahora es muchísimo más fácil entender que hace el test a primera vista.
  • Si en el futuro tenemos que añadir o quitar propiedades en la clase Student solo habrá que hacerlo en el StudentBuilder y no de forma individual en cada test.

Nuestro StudentBuilder se nos queda pequeño

¿Pero qué ocurre si las propiedades del objeto Student son relevantes y necesitamos especificarlas en el test?

Bastará con actualizar nuestro StudentBuilder para añadir métodos que nos permitan especificar qué campos por defecto queremos customizar:

class StudentBuilder:
	def __init__(self) -> None:
    		...

    	def with_country(self, country: str) -> "StudentBuilder":
		self._country = country
		return self

    	def build(self) -> Student:
    		...

Y actualizar nuestro test será tan sencillo como llamar al metodo .with_country una vez creado nuestro builder:

from doublex import Mimic, Spy
from expects import have_been_called_with
...

class TestCreateCommandHandler:
	def test_creates_a_student(self) -> None:
		student = StudentBuilder().with_country("Spain").build()
		create_student_command = CreateStudentCommand(student)
		student_repository = Mimic(Spy, StudentRepository)
		create_student_command_handler = CreateStudentCommandHandler(student_repository)

		create_student_command.process(create_student_command)

		expect(student_repository.save).to(have_been_called_with(student))

Conclusiones

Como hemos podido ver en este sencillo ejemplo, hacer uso de Builders en nuestros tests nos va a permitir reducir significativamente el tamaño de los mismos permitiéndonos centrarnos en lo realmente importante.

Para mí sin duda es una práctica muy sencilla de implementar y que aportar un valor inmediato tanto a la calidad como en la legibilidad de nuestro código.

!Espero que os haya gustado, un saludo!