TDD en práctica
TDD es un gran avance en la ingeniería de software. Es una técnica que nos proporciona un feedback claro sobre si nuestro código hace lo que esperamos y, aún más importante, si nuestro diseño es suficientemente bueno.
Aunque no es fácil y puede parecer antinatural para los que son nuevos, la clave es la práctica. No se trata de ser bueno en testing sino en volverse bueno en el diseño.
Creo que el concepto más importante a mencionar es que TDD no es lo mismo que unit testing, en absoluto. TDD trata sobre conducir el diseño de nuestro código mediante tests. En lugar de escribir nuestra funcionalidad y luego escribir un test para la misma, comenzamos por el test, imaginando cuál será la interfaz pública de nuestro componente y qué casos deben cumplirse, y en base a dicho test trabajaremos en diseñar una solución que lo satisfaga.
¿Cuándo es conveniente usar TDD?
- En código nuevo: siempre comienza un nuevo proyecto aplicando TDD. Es el momento ideal para hacerlo y es cuando dará más frutos en cuanto a la detección temprana de problemas de diseño.
- En proyectos preexistentes: puedes aplicarlo en funcionalidades nuevas que debas agregar o en algunas existentes que debas modificar, escribiendo tests específicos para las mismas. En este sentido te permitirá aplicar nuevos enfoques a la arquitectura actual, poniendo en práctica tus habilidades de refactoring.
Errores de principiante
Algunos errores comunes que solemos cometer al iniciarnos en esta práctica son:
- Perseguir una alta cobertura
- Escribir tests para que pasen en lugar de escribirlos para que fallen
- Escribir tests grandes y complejos
- Falsa confianza: tests que pasan cuando deben fallar. Esto suele pasar cuando se esperan excepciones en lugar de realizar aserciones
- Tratar de escribir un único test que cubra todo
- Intentar aplicar TDD en código legacy
- Escribir tests que prueban la implementación en lugar de los resultados
No he detallado cada error de forma puntual ya que considero que en la mayoría de los casos todos ellos pueden justificarse por la misma causa: escribir tests sólo para darnos la falsa sensación de seguridad de que estamos haciendo las cosas bien. No quiero decir con esto que unit testing no agregue valor, pero detengámonos un instante a recordar cuántas veces el hecho de contar con tests, en lugar de simplificar nuestro trabajo, lo han terminado duplicando. Esto es una consecuencia de escribir los tests ex post, lo cual suele resultar en que, por mucho que luchemos por no hacerlo, terminamos probando nuestra solución específica en lugar de describir claramente los criterios de aceptación del problema, y valernos de ello para modelar nuestra implementación. El objeto de escribir un test es el de definir qué esperamos que haga nuestro sistema, no cómo esperamos que lo haga.
Evalúa tu diseño con TDD
El enfoque utilizado para aplicar TDD es llamado red-green-refactor. Éste permite a los programadores compartimentar su trabajo en tres etapas:
🔴 Red: piensa en lo que quieres desarrollar
Te enfocarás en diseñar la vista externa, la interfaz pública de tus componentes para que éstas sean limpias, claras y simples mientras escribes tu test. Si resulta difícil escribir tu test esto es una señal de que tu código no es lo suficientemente bueno y se puede mejorar. Piensa en ejemplos simples que te ayuden a diseñar tu solución en pequeños pasos.
Podemos ver a este test fallido como una forma de comunicar nuestra intención de implementar una nueva funcionalidad, que será satisfecha una vez que dicho test pase.
🟢 Green: piensa en cómo hacer que tu test pase
La idea principal es encontrar una solución que satisfaga a tu test, sin centrarnos mucho en cuestiones de optimización. Desde luego existen tantas posibles soluciones como programadores en el mundo, por lo que un buen punto de partida es enfocarse en que esta primera solución sea lo más simple posible.
🧩 Refactor: piensa en cómo mejorar tu implementación actual
Habiendo encontrado una solución al test, ahora puedes enfocarte en optimizar la misma, para lograr un código más descriptivo o más rápido, o ambos. Estando ya en la zona verde, podemos trabajar seguros de que si incorporamos algún cambio que dañe el resultado de nuestra solución, esto nos será informado por el test. En ocasiones este paso puede no ser necesario.
La noción de código suficientemente bueno
Cuando hablamos de código lo suficientemente bueno nos referimos a código altamente cohesivo, que cumpla con cierto grado de ortogonalidad. Esto es, como hemos desarrollado en otras publicaciones, aplicar conceptos como composición sobre herencia, bajo acoplamiento y alta intercambiabilidad, mantener las interdependencias lo más bajas posibles, limitar la responsabilidad de nuestros componentes, …. suena como SOLID ¿verdad? Bueno, ese es un excelente punto de partida.
Lograr un buen diseño
Como he mencionado antes, el mayor potencial de TDD no es lograr cobertura de código, sino un buen diseño. ¿Cómo aplica esto? Si pensamos detenidamente, un test no es más que un “cliente” diferente al que usará habitualmente nuestra aplicación. Por cliente no nos referimos estrictamente a una interfaz de usuario, sino más bien a un objeto o función que consume otro objeto o función.
En cualquier lenguaje moderno cada módulo expone su funcionalidad a través de una interfaz pública, y puede a su vez consumir a otros módulos a través de sus interfaces públicas. Tomemos el ejemplo de un endpoint que liste artículos: para completar su tarea, recibe parámetros de búsqueda (filtros) a través de su interfaz, hace una llamada a un cliente de base de datos o servicio externo para obtener dichos artículos indicando estos filtros, y luego compone el resultado y lo retorna.
class GetPosts extends Controller
{
function __invoke(string $name = '', int $page = 1)
{
$postsQuery = $this->container->get('db')
->find('posts')
->select(['id', 'title', 'pub_date', 'author'])
->where('name like %?%', $name)
->limit(10)
->offset($page);
posts = $postsQuery->execute();
$pages = $this->container->get('paginator')
->getPages($postsQuery, 10);
return $this->response(
compact($posts, $pages, $page)
)->json();
}
}
Simple. Ahora intentemos poner a prueba nuestra solución. Y es aquí donde comienzan a surgir preguntas existenciales y dolores de cabeza:
- ¿Cómo escribo el test dado que la funcionalidad depende de la base de datos? ¿Uso una base de datos de pruebas o uso mocks?
- ¡Momento! no sólo dependo de la base de datos sino de todo el contenedor de la aplicación ¿Cómo puedo inyectarlo?
- Pensándolo bien, mi funcionalidad sólo debe cumplir la condición de que, dada una entrada, retorne una salida en un formato específico: Tiene sentido realmente probar también la base de datos o el contenedor de aplicación? ¿Es necesario que inicialice toda la infraestructura de software sólo para correr un test?
Y es ahí cuando comenzamos a comprender todo. Nuestro diseño, si bien satisface el requisito, no es del todo bueno:
- ¿Está apropiadamente aislado de las capas subyacentes?
- ¿Sería fácil modificar las interfaces internas y externas de la aplicación manteniendo mi lógica de negocio sin cambios?
- ¿Existe alguna forma de probar mi código de forma rápida y económica?
La primera clave aquí es: separa el problema y comienza por el corazón del mismo. Analicemos entonces el problema, leyendo la especificación:
Como usuario del sistema, quiero listar los artículos registrados,
cumpliendo los siguientes criterios de aceptación:
1. Deberá listarse por cada artículo el nombre,
la fecha de publicación y el autor;
2. El usuario deberá poder buscar entre los artículos por su título,
escrito total o parcialmente;
3. Deberán mostrarse hasta 10 resultados por página y el usuario
deberá poder navegar entre las páginas.
¿Cómo sería el proceso de TDD para este requerimiento?
0. Separar el problema
EL cero es intencional. Definimos que los pasos para este caso de uso sencillo son los siguientes:
- Obtener los filtros indicados por el usuario
- Obtener los artículos que cumplan con esos filtros
- Componer la respuesta
¿Cuál sería la funcionalidad principal? La descrita en el segundo punto, ya que la primera y la tercera están asociadas a interfaces de entrada y salida.
1. Escribir un test unitario que describa nuestro problema (RED)
Es aquí cuando comenzamos a pensar de sobre cómo podría estructurarse nuestro código, sabiendo qué cosas debemos incluir y cuáles dejar afuera. Nos centraremos sin embargo en las interfaces de nuestra funcionalidad, no en el detalle de la misma. Luego pensaremos en unos pocos casos que representen a la mayoría de posibles resultados (casos de prueba).
Para continuar, olvidémonos de la implementación anterior y asumamos que estamos comenzando desde cero.
final class GetPostsTest extends TestCase
{
private $action;
public function setUp()
{
$this->action = new GetPosts();
}
/**
* @dataProvider scenariosProvider
*/
public function testScenarios(string $title, int $page, int $pages, array $posts): void
{
$expected = compact($posts, $pages, $page);
$result = $this->action($title, $page);
$this->assertSame($expected, $result);
}
public function scenariosProvider(): array
{
return [
'Return no results when none match' => ['non-existent title', 1, 0, []],
'Return no results when page is out of bounds' => ['', 100, 0, []],
'Return right posts' => ['bill', 1, 1, [
['id' => 1, 'title' => 'How to make Bill millionaire', 'pub_date' => '2022-12-07 15:45', 'author' => 'Timothy Robbins']
['id' => 5, 'title' => 'Interview to Bill Murray', 'pub_date' => '2022-10-05 11:10', 'author' => 'John K. Goodman']
]],
];
}
}
El test resultó bastante descriptivo. Sin embargo nos falta algo: sabemos que nuestro caso de uso dependerá funcionalmente de un origen de datos. Observemos en el método setUp
la línea $this->action = new GetPosts();
. Sabemos que durante el ciclo de ejecución de nuestra aplicación podríamos valernos de un motor de inyección de dependencias para resolver éstas al momento de instanciar un objeto.
También sabemos que sería saludable para nuestra base de código abstraer dichas dependencias. Podríamos entonces asumir como una posible solución que dentro de nuestros casos de prueba podríamos pasar explícitamente esta dependencia, la cual a su vez debería satisfacer un contrato que también sea satisfecho por el servicio que usaremos en el flujo de ejecución real. Modificamos entonces este método:
final class GetPostsTest extends TestCase
{
private $action;
public function setUp()
{
$this->action = new GetPosts(new FakePostsRepository());
}
///... resto del código
}
Luego definimos el mismo y el respectivo contrato:
interface PostsRepositoryInterface
{
function find(string $title, int $page): array;
}
final class FakePostsRepository implements PostsRepositoryInterface
{
public function find(string $title, int $page): array
{
//alguna implementación con datos en memoria o en un archivo de texto
//que sepa filtrar los mismos por título
}
}
Y con eso habremos finalizado la fase RED de nuestro proceso de TDD. ejecutaremos dicho test y desde luego que fallará. Tengamos presente que por simplicidad no hemos separado los espacios de nombres de nuestros componentes, cosa que sí deberíamos llevar a cabo en una implementación real.
2. Escribir una implementación lo más simple posible para satisfacer el test (GREEN)
En este paso comenzaremos a ver las implicaciones de haber escrito el test en primer lugar. Y observaremos que tendrá un gran impacto en nuestro diseño, por citar algunas consecuencias:
- La solución ya no devuelve un objeto JSON, sino un vector, por lo que esta lógica no estará en el núcleo de nuestra solución.
- Debemos abstraer el origen de datos de manera que satisfaga el contrato.
- Ya no podremos apoyarnos en el objeto Query para realizar el cálculo de paginado.
Suena como mucho trabajo extra. ¿Vale la pena? Ya lo veremos.
final class GetPostsAction extends Controller
{
public function __invoke(GetPosts $handler, string $name = '', int $page = 1)
{
$postsWithMetadata = $handler($name, $page);
return this->response($postsAndMetadata)->json();
}
}
final class GetPosts
{
public function __construct(private PostsRepositoryInterface $postsRepository) { }
public function __invoke(string $name = '', int $page = 1): array {
return $this->postsRepository->find($name, $page);
}
}
final class MySQLPostsRepository implements PostsRepositoryInterface
{
function __construct(private DatabaseClient $db, private QueryPaginator $paginator) { }
public function find(string $title, int $page): array
{
$postsQuery = $this->db
->find('posts')
->select(['id', 'title', 'pub_date', 'author'])
->where('name like %?%', $name)
->limit(10)
->offset($page);
posts = $postsQuery->execute();
$pages = $this->paginator->getPages($postsQuery, 10);
return compact($posts, $pages, $page);
}
}
En la configuración de nuestro motor de inyección de dependencias indicaremos que cuando se solicite una PostsRepositoryInterface
en un constructor, inyecte una instancia de MySQLPostsRepository
. En el caso del objeto hipotético DatabaseClient
, dicha inyección debería resolverse por sí sola.
A esta altura, nuestro test debería ejecutarse correctamente. Continuemos entonces con el siguiente paso.
3. Mejorar nuestro código (REFACTOR)
Habiendo satisfecho nuestros casos de prueba, intentaremos ahora mejorar nuestra implementación. Si bien este caso de uso es trivial y lo que suele ocurrir con las operaciones de lectura es que los casos de uso resultan en un simple “pasamanos” de datos con poca o nula lógica de negocio, aún así podemos aplicar ciertas mejoras. Entendiendo que el caso de uso GetPosts
pertenece de hecho a una capa muy importante de nuestra aplicación, la capa de dominio o negocio, sería valioso, al menos, componer un objeto de salida que represente de una forma un poco más clara cuál es la salida de dicho caso de uso.
final class GetPostsAction extends Controller
{
public function __invoke(GetPosts $handler, string $name = '', int $page = 1)
{
...
return this->response($postsAndMetadata->toArray())->json();
}
}
final class GetPosts
{
public function __construct(private PostsRepositoryInterface $postsRepository) { }
public function __invoke(string $name = '', int $page = 1): GetPostsResponse
{
$postsWithMetadata = $this->postsRepository->find($name, $page);
return new GetPostsResponse(...$postsWithMetadata);
}
}
final class GetPostsResponse
{
public function __construct(private array $posts, private int $pages, private int $page) { }
public function toArray()
{
return [
'posts' => $this->posts,
'pages' => $this->pages,
'page' => $this->page
];
}
}
Notemos que, por haber modificado la salida del componente, debemos también actualizar nuestros casos de prueba para convertir la salida a un valor comparable con los datos esperados. Este es un punto a tener en cuenta, y es que las interfaces de nuestros componentes deben tratarse como lugares especiales en cuanto a cambios:
final class GetPostsTest extends TestCase
{
...
public function testScenarios(string $title, int $page, int $pages, array $posts): void
{
...
$this->assertInstanceOf(GetPostsResponse::class, $result); //por qué no agregar una aserción más :)
$this->assertSame($expected, $result->toArray());
}
...
}
También podríamos haber aplicado una solución similar en la entrada de dicho caso de uso, a fin de reducir el número de parámetros y de proporcionar validaciones adicionales.
Conclusión
Todo lo anterior surgió de un simple ejercicio: aplicar TDD. Esto nos permitió darnos cuenta de que si bien hay infinitas implementaciones posibles de una solución, no todas son claras, extensibles y mantenibles a lo largo del tiempo. Sin un diseño lo suficientemente bueno:
- ¿Qué ocurriría cuando en un futuro debamos cambiar nuestro framework? Quizá dirás que no está en tus planes cambiarlo; y puedo asegurarte que si tu intención es construir una aplicación que dure al menos 5 años, durante ese período de tiempo deberás al menos mantener actualizadas las versiones de dicho framework, y es sabido que entre major versions se introducen breaking changes y que lo que hoy se hace de una forma, en versiones futuras probablemente se hará de otra (deprecations).
- ¿Cómo podríamos lidiar con el acoplamiento que generan las dependencias entre objetos? Nuestro sistema se degradaría con mucha mayor velocidad que uno correctamente diseñado desde su origen. Por ejemplo: ¿qué haríamos si la organización decide cambiar la tecnología de base de datos?
- ¿Cómo podríamos lograr una buena performance y economía al momento de ejecutar los tests en un pipeline de integración continua? No es en absoluto igual correr tests unitarios con datos en memoria o en un archivo, que requerir una instancia de base de datos para lograr un resultado análogo, así como vernos arrastrados hacia tests de integración que pueden evitarse.
Si llegaste hasta aquí, gracias por tu tiempo y espero que hayas disfrutado este artículo. Happy coding :).