TDD - Desarrollo Guiado por Pruebas
TDD, o Desarrollo Guiado por Pruebas (Test-Driven Development), es una práctica de desarrollo de software que enfatiza la creación de pruebas automatizadas antes de la implementación del código funcional. El TDD se basa en la idea de que las pruebas deben ser una parte fundamental del proceso de desarrollo, y no solo una verificación final.
Principios del TDD
-
Escriba una Prueba: Antes de escribir cualquier código, debe escribir una prueba que defina una función o mejora deseada. Esta prueba debe fallar inicialmente, ya que la funcionalidad aún no ha sido implementada.
-
Implemente la Funcionalidad: Después de escribir la prueba, debe implementar la mínima cantidad de código necesaria para que pase.
-
Refactorice: Una vez que la prueba pasa, puede refactorizar el código para mejorar su calidad, manteniendo las pruebas en verde (pasando). La refactorización es una parte crítica del TDD, ya que permite mejorar el diseño del código sin alterar su comportamiento.
Beneficios del TDD
- Feedback Rápido: Permite detectar fallas rápidamente, ya que las pruebas se ejecutan con frecuencia.
- Código Más Limpio y Mantenible: Como el código se refactoriza con frecuencia, tiende a ser más limpio y más fácil de mantener.
- Documentación Viva: Las pruebas sirven como una forma de documentación del comportamiento del sistema.
Ciclo Rojo-Verde-Azul
El ciclo Rojo-Verde-Azul es un enfoque visual y práctico para implementar el TDD. Cada color representa una fase en el proceso de desarrollo:
-
Rojo (Red):
- Escriba una prueba que valide una nueva funcionalidad. Al principio, la prueba debe fallar, indicando que la funcionalidad aún no ha sido implementada. Esta fase es importante porque confirma que la prueba está, de hecho, verificando algo significativo.
-
Verde (Green):
- Implemente la funcionalidad mínima necesaria para que la prueba pase. El objetivo aquí es garantizar que la implementación cumpla con la prueba. Tan pronto como la prueba pasa, se puede decir que la funcionalidad está en "verde".
-
Azul (Blue):
- Refactorice el código. El objetivo es mejorar la estructura y la legibilidad del código, garantizando que todas las pruebas sigan pasando. El término "azul" puede no ser común; a menudo, el paso de refactorización se llama simplemente "refactorización", pero la idea es que ahora está en un estado "estable" y "limpio".
El TDD y el ciclo Rojo-Verde-Azul son prácticas poderosas que pueden mejorar significativamente la calidad del código y la eficiencia del desarrollo. Al escribir pruebas antes de implementar funcionalidades, los desarrolladores pueden garantizar que el código cumpla con los requisitos desde el principio y que permanezca robusto y flexible a medida que evoluciona. Este enfoque no solo mejora la calidad del software, sino que también ayuda a construir una mentalidad de calidad y responsabilidad entre los desarrolladores.
Ejemplo de uso del TDD
Abordaremos la implementación de un sistema de gestión de personajes en un juego, donde los personajes pueden tener atributos como fuerza de ataque y defensa, además de equipar armaduras, armas y cascos. El sistema se desarrollará inicialmente de forma funcional y, luego, se refactorizará a un enfoque orientado a objetos (OOP).
1. Creando la Prueba
Usaremos el framework pytest para crear pruebas unitarias que verificarán si el sistema de personajes funciona como se espera. Las pruebas incluirán:
- Verificación de la creación de un personaje.
- Adición de armaduras, armas y cascos.
- Cálculo correcto de la fuerza de ataque y defensa.
tests.py
import pytest
from character import *
def test_character_creation():
create_character(1, 'Guerreiro Valente')
stats = get_stats(1)
assert stats['nome'] == 'Guerreiro Valente'
assert stats['ataque'] == 10
assert stats['defesa'] == 5
def test_add_armor():
create_character(2, 'Cavaleiro Forte')
add_armor(2, 1) # Añade Cota de Malla
stats = get_stats(2)
assert stats['defesa'] == 8 # 5 base + 3 de la armadura
def test_add_weapon():
create_character(3, 'Lutador Rápido')
add_weapon(3, 2) # Añade Hacha
stats = get_stats(3)
assert stats['ataque'] == 15 # 10 base + 5 del arma
def test_add_helmet():
create_character(4, 'Guerreiro Blindado')
add_helmet(4, 2) # Añade Casco de Acero
stats = get_stats(4)
assert stats['defesa'] == 8 # 5 base + 3 del casco
Ejecutar para Fallar (ROJO)
Es una buena práctica ejecutar las pruebas antes de implementar cualquier funcionalidad. Esto garantiza que, si hay alguna falla en las pruebas, sabremos que necesita ser corregida. Por lo tanto, al ejecutar el comando pytest, debemos asegurarnos de que todas las pruebas fallen inicialmente, indicando que la implementación no está completa.
2. Implementación Funcional
A continuación se presenta la implementación funcional del sistema de gestión de personajes:
character.py
# Implementación Funcional
personagens = {}
def create_character(id_personagem, nome):
"""Crea un nuevo personaje con atributos iniciales."""
personagens[id_personagem] = {
'nome': nome,
'ataque': 10, # Fuerza de ataque base
'defesa': 5, # Fuerza de defensa base
'armaduras': [],
'armas': [],
'capacetes': []
}
def add_armor(id_personagem, id_armor):
armor_stats = {
1: {'nome': 'Cota de Malla', 'bonus_defesa': 3},
2: {'nome': 'Armadura de Cuero', 'bonus_defesa': 2},
3: {'nome': 'Armadura Pesada', 'bonus_defesa': 5}
}
if id_personagem in personagens and id_armor in armor_stats:
armor = armor_stats[id_armor]
personagens[id_personagem]['defesa'] += armor['bonus_defesa']
personagens[id_personagem]['armaduras'].append(armor['nome'])
def add_weapon(id_personagem, id_weapon):
weapon_stats = {
1: {'nome': 'Espada Corta', 'bonus_ataque': 4},
2: {'nome': 'Hacha', 'bonus_ataque': 5},
3: {'nome': 'Lanza', 'bonus_ataque': 3}
}
if id_personagem in personagens and id_weapon in weapon_stats:
weapon = weapon_stats[id_weapon]
personagens[id_personagem]['ataque'] += weapon['bonus_ataque']
personagens[id_personagem]['armas'].append(weapon['nome'])
def get_stats(id_personagem):
"""Retorna la fuerza de ataque y defensa del personaje."""
if id_personagem in personagens:
return personagens[id_personagem]
return None
Ejecutar la prueba para que pase (VERDE)
Ejecutar la prueba a esta altura ocasionará que la misma pase con éxito, estamos en la fase en que ya tenemos una versión funcional del código.
3. Refactorización a Objetos
Después de la implementación funcional, el siguiente paso es refactorizar el código a un enfoque orientado a objetos. La refactorización implicará crear una clase Personagem que encapsule los atributos y métodos necesarios para manipular los personajes.
Implementación Orientada a Objetos
from dataclasses import dataclass
@dataclass
class Armadura:
nome: str
bonus_defesa: str
@dataclass
class Arma:
nome: str
bonus_ataque: str
@dataclass
class Personagem:
id_personagem: int
nome: str
forca_ataque: int = 10
forca_defesa: int = 5
armaduras:list[Armadura] = []
armas:list[Arma] = []
def add_armadura(self, armadura: Armadura):
self.armaduras.append(armadura)
def add_arma(self, arma: Arma):
self.armas.append(arma)
@property
def ataque(self):
return sum([self.forca_ataque, *[a.bonus_ataque for a in self.armas]])
@property
def defesa(self):
return sum([self.forca_ataque, *[a.bonus_defesa for a in self.armaduras]])
def get_stats(self):
return {
'nome': self.nome,
'ataque': self.ataque,
'defesa': self.defesa
}
personagens = {}
aramaduras = {
1: Armadura('Cota de Malla', 3),
2: Armadura('Armadura de Cuero', 2),
3: Armadura('Armadura Pesada', 5),
}
armas = {
1: Arma('Espada Corta', 4),
2: Arma('Hacha', 5),
3: Arma('Lanza', 3),
}
def create_character(id_personagem, nome):
personagems[id_personagem] = Personagem(1, nome)
def add_armor(id_personagem, id_armor):
if id_personagem in personagens and id_armor in armor_stats:
armor = armor_stats[id_armor]
personagems[id_personagem].add_armor(armor)
def add_weapon(id_personagem, id_weapon):
if id_personagem in personagens and id_weapon in weapon_stats:
weapon = weapon_stats[id_weapon]
personagens[id_personagem].add_weapon(weapon)
def get_stats(id_personagem):
"""Retorna la fuerza de ataque y defensa del personaje."""
if id_personagem in personagens:
return personagens[id_personagem].get_stats()
return None
Refactorizando sin cambiar las pruebas (AZUL)
La refactorización a OOP no requiere cambios en las pruebas, ya que la firma de los métodos y la lógica de manipulación de personajes permanecen las mismas. La estructura de las pruebas ya creada para la implementación funcional funcionará con la nueva implementación orientada a objetos, garantizando que el comportamiento del sistema permanezca consistente.
Revisión de las Pruebas Unitarias
Análisis del Código Backend
El siguiente análisis se concentra en pruebas del backend, con énfasis en problemas comunes que pueden encontrarse.
Problemas Comunes:
- Enfoque en Unidades Aisladas: Pruebas que se concentran en métodos específicos de clases, en lugar de verificar flujos de comportamiento completos.
- Uso Excesivo de Mocks: Utilización de mocks para dependencias internas que podrían ser instanciadas directamente.
- Acoplamiento a la Implementación: Pruebas que dependen de detalles de implementación, como el orden de llamadas de métodos internos.
Ejemplo Problemático (Hipotético):
// Código Problemático
@Test
void testAutenticarUsuario() {
UsuarioRepository usuarioRepository = mock(UsuarioRepository.class);
AutenticacaoService autenticacaoService = new AutenticacaoService(usuarioRepository);
when(usuarioRepository.findByEmail(anyString())).thenReturn(Optional.of(new Usuario()));
autenticacaoService.autenticarUsuario("email@example.com", "senha");
verify(usuarioRepository).findByEmail("email@example.com");
}
Problemas Identificados:
- Uso Excesivo de Mocks: El repositorio podría ser reemplazado por una base de datos en memoria, proporcionando una prueba más cercana a la realidad.
- Enfoque en el Método: La prueba debería centrarse en el resultado de la autenticación, en lugar de solo verificar si un método fue llamado.
Código Corregido (Ejemplo):
// Código Corregido (Ejemplo)
@Test
void testAutenticarUsuarioComSucesso() {
// Configuración: Crear un usuario en la base de datos en memoria
Usuario usuario = new Usuario();
usuario.setEmail("email@example.com");
usuario.setSenha(PasswordEncryption.encrypt("senha"));
usuarioRepository.save(usuario);
// Acción: Intentar autenticar el usuario
UsuarioResponseDTO resultado = autenticacaoService.autenticarUsuario("email@example.com", "senha");
// Verificaciones: Confirmar el resultado de la autenticación
assertNotNull(resultado);
assertEquals("email@example.com", resultado.getEmail());
}
Mejoras Propuestas:
- Uso de una base de datos en memoria para simular la interacción real con el repositorio.
- Enfoque en el resultado de la autenticación, que es el comportamiento deseado.
Análisis del Código Frontend
El siguiente análisis se concentra en pruebas del frontend, destacando problemas comunes.
Problemas Comunes:
- Exceso de Mocks: Mocks de componentes, hooks y funciones que podrían ser probados directamente.
- Pruebas Superficiales: Verificación solo de la renderización de componentes, sin considerar interacciones y resultados.
- Acoplamiento a Detalles de Implementación: Pruebas que dependen de la estructura interna de los componentes.
Ejemplo Problemático:
// Código Problemático
vi.mock('next/navigation', () => ({
useRouter: vi.fn(),
}));
describe('RootLayout', () => {
beforeEach(() => {
const push = vi.fn();
(useRouter as jest.Mock).mockReturnValue({ push });
});
it('renders the header and child components', () => {
const { getByText } = render(<PrivateLayout>Child Component</PrivateLayout>);
expect(getByText(/Journey/i)).to.exist;
expect(getByText(/Child Component/i)).to.exist;
});
});
Problemas Identificados:
- Uso Innecesario de Mocks: El mock de
useRouteres innecesario a menos que la prueba dependa de rutas específicas. - Enfoque en la Renderización: La prueba se limita a verificar la presencia de texto, sin evaluar si el componente funciona correctamente.
Código Corregido (Ejemplo):
// Código Corregido (Ejemplo)
describe('RootLayout', () => {
it('renders the header and child components', () => {
const { getByText } = render(<PrivateLayout>Child Component</PrivateLayout>);
expect(getByText(/Journey/i)).to.exist;
expect(getByText(/Child Component/i)).to.exist;
// Aquí puedes añadir interacciones y verificaciones adicionales
});
});
Mejoras Propuestas:
- Eliminar el mock de
useRouter, a menos que sea estrictamente necesario. - Enfocarse en probar el comportamiento del componente, como interacciones y visualización de datos.
Otras Sugerencias
-
Utilizar Bibliotecas de Pruebas Más Expresivas: La biblioteca
react-testing-libraryfomenta pruebas que se centran en el comportamiento del usuario, en lugar de detalles de implementación. -
Crear Escenarios de Prueba Abrangentes: Probar diferentes estados e interacciones del componente, garantizando que el sistema se comporte adecuadamente en varias situaciones.
-
Evitar Aserciones Excesivas: Cada prueba debe tener un objetivo claro y verificar solo el resultado esperado, evitando la sobrecarga de verificaciones innecesarias.
Trabajando con la IA y pruebas TDD
La IA no aplica los principios del TDD de inmediato. Es necesario describir exactamente lo que el TDD predica antes de pedirle que genere una prueba. A continuación, un prompt efectivo para que genere estas pruebas o mejore las pruebas existentes en su aplicación.
El TDD predica que las pruebas deben ser de integración y basadas en comportamientos, desafiando el método tradicional de pruebas unitarias. Esto requiere que no se usen mocks para todo, solo para conexiones externas que no se puedan usar directamente. El TDD también predica que las pruebas deben saber lo menos posible sobre la implementación, como máximo deben conocer la interfaz pública para generar desacoplamiento entre la prueba y la implementación.
Teniendo esto en cuenta, mejora las pruebas existentes en mi aplicación.
