En este tema construiremos un gestor de tareas aplicando TDD de manera integrada. El objetivo es practicar el ciclo completo: elegir la siguiente prueba, escribir el mínimo código para pasarla, refactorizar y mantener una suite clara.
El gestor permitirá crear tareas, marcarlas como completadas, filtrarlas, validar datos y calcular un pequeño resumen.
Vamos a desarrollar esta funcionalidad:
Convertiremos esta idea en pruebas ejecutables, empezando por el comportamiento más simple.
Usaremos una estructura mínima de proyecto.
src/
tareas.py
tests/
test_tareas.py
A partir de este tema ya damos por conocidos los pasos para crear entorno virtual, instalar
pytest y ejecutar la suite.
La primera regla define el estado inicial.
Archivo a crear: tests/test_tareas.py
from tareas import GestorTareas
def test_gestor_nuevo_no_tiene_tareas():
gestor = GestorTareas()
assert gestor.listar() == []
Ejecutamos python -m pytest. La prueba debe fallar porque aún no existe la clase.
Creamos lo necesario para pasar.
Archivo a crear: src/tareas.py
class GestorTareas:
def listar(self):
return []
Es una implementación mínima. Todavía no guarda tareas porque ninguna prueba lo pidió.
Ahora agregamos el primer comportamiento útil.
Archivo a modificar: tests/test_tareas.py
def test_agregar_tarea_la_incluye_en_el_listado():
gestor = GestorTareas()
gestor.agregar("Estudiar TDD")
assert gestor.listar() == [
{"id": 1, "titulo": "Estudiar TDD", "completada": False}
]
Esta prueba define tres decisiones: la tarea tiene identificador, título y estado inicial pendiente.
Agregamos almacenamiento interno y el método agregar.
Archivo a modificar: src/tareas.py
class GestorTareas:
def __init__(self):
self._tareas = []
def agregar(self, titulo):
self._tareas.append({
"id": 1,
"titulo": titulo,
"completada": False,
})
def listar(self):
return self._tareas
Ejecutamos la suite. La implementación es simple y todavía solo soporta bien una tarea.
Agregamos un ejemplo que obliga a generalizar el identificador.
Archivo a modificar: tests/test_tareas.py
def test_cada_tarea_tiene_id_incremental():
gestor = GestorTareas()
gestor.agregar("Estudiar TDD")
gestor.agregar("Practicar pytest")
assert gestor.listar() == [
{"id": 1, "titulo": "Estudiar TDD", "completada": False},
{"id": 2, "titulo": "Practicar pytest", "completada": False},
]
Calculamos el siguiente identificador a partir de la cantidad de tareas existentes.
Archivo a modificar: src/tareas.py
def agregar(self, titulo):
siguiente_id = len(self._tareas) + 1
self._tareas.append({
"id": siguiente_id,
"titulo": titulo,
"completada": False,
})
Ejecutamos python -m pytest. Las pruebas anteriores deben seguir pasando.
Ahora agregamos una acción sobre una tarea existente.
Archivo a modificar: tests/test_tareas.py
def test_marcar_tarea_como_completada():
gestor = GestorTareas()
gestor.agregar("Estudiar TDD")
gestor.completar(1)
assert gestor.listar() == [
{"id": 1, "titulo": "Estudiar TDD", "completada": True}
]
La prueba expresa el comportamiento observable: el estado de la tarea cambia.
Buscamos la tarea por identificador y modificamos su estado.
Archivo a modificar: src/tareas.py
def completar(self, tarea_id):
for tarea in self._tareas:
if tarea["id"] == tarea_id:
tarea["completada"] = True
return
Todavía no definimos qué pasa si el identificador no existe. Eso será otra prueba.
Agregamos una regla de error.
Archivo a modificar: tests/test_tareas.py
import pytest
def test_no_permite_completar_tarea_inexistente():
gestor = GestorTareas()
with pytest.raises(ValueError, match="Tarea inexistente"):
gestor.completar(99)
Esta prueba evita que un error pase silenciosamente.
Si no encontramos la tarea, lanzamos una excepción.
Archivo a modificar: src/tareas.py
def completar(self, tarea_id):
for tarea in self._tareas:
if tarea["id"] == tarea_id:
tarea["completada"] = True
return
raise ValueError("Tarea inexistente")
Ejecutamos toda la suite para confirmar que el cambio no rompió el caso válido.
Agregamos una consulta útil del dominio.
Archivo a modificar: tests/test_tareas.py
def test_lista_solo_tareas_pendientes():
gestor = GestorTareas()
gestor.agregar("Estudiar TDD")
gestor.agregar("Practicar pytest")
gestor.completar(1)
assert gestor.pendientes() == [
{"id": 2, "titulo": "Practicar pytest", "completada": False}
]
Filtramos tareas no completadas.
Archivo a modificar: src/tareas.py
def pendientes(self):
return [
tarea for tarea in self._tareas
if not tarea["completada"]
]
Esta consulta no modifica el estado del gestor.
Agregamos la consulta complementaria.
Archivo a modificar: tests/test_tareas.py
def test_lista_solo_tareas_completadas():
gestor = GestorTareas()
gestor.agregar("Estudiar TDD")
gestor.agregar("Practicar pytest")
gestor.completar(1)
assert gestor.completadas() == [
{"id": 1, "titulo": "Estudiar TDD", "completada": True}
]
La implementación es similar a pendientes.
Archivo a modificar: src/tareas.py
def completadas(self):
return [
tarea for tarea in self._tareas
if tarea["completada"]
]
La duplicación entre filtros es una señal para refactorizar luego de llegar a verde.
Agregamos una validación de entrada.
Archivo a modificar: tests/test_tareas.py
def test_no_permite_agregar_tarea_sin_titulo():
gestor = GestorTareas()
with pytest.raises(ValueError, match="El título es obligatorio"):
gestor.agregar("")
Validamos antes de crear la tarea.
Archivo a modificar: src/tareas.py
def agregar(self, titulo):
if titulo == "":
raise ValueError("El título es obligatorio")
siguiente_id = len(self._tareas) + 1
self._tareas.append({
"id": siguiente_id,
"titulo": titulo,
"completada": False,
})
Ejecutamos toda la suite. La validación no debe afectar los títulos válidos.
Agregamos una función de resumen que combine varias consultas.
Archivo a modificar: tests/test_tareas.py
def test_resumen_indica_totales_de_tareas():
gestor = GestorTareas()
gestor.agregar("Estudiar TDD")
gestor.agregar("Practicar pytest")
gestor.completar(1)
assert gestor.resumen() == {
"total": 2,
"pendientes": 1,
"completadas": 1,
}
Calculamos el resumen usando los métodos existentes.
Archivo a modificar: src/tareas.py
def resumen(self):
return {
"total": len(self._tareas),
"pendientes": len(self.pendientes()),
"completadas": len(self.completadas()),
}
Este método reutiliza comportamiento público del gestor.
El uso de diccionarios funcionó para avanzar, pero ya tenemos un concepto claro: tarea.
Con la suite en verde, podemos refactorizar a una dataclass.
Archivo a modificar: src/tareas.py
from dataclasses import dataclass
@dataclass
class Tarea:
id: int
titulo: str
completada: bool = False
def como_dict(self):
return {
"id": self.id,
"titulo": self.titulo,
"completada": self.completada,
}
Para no cambiar el contrato de las pruebas, listar seguirá devolviendo
diccionarios.
El gestor puede guardar objetos Tarea internamente y exponer diccionarios hacia
afuera.
Archivo a modificar: src/tareas.py
class GestorTareas:
def __init__(self):
self._tareas = []
def agregar(self, titulo):
if titulo == "":
raise ValueError("El título es obligatorio")
siguiente_id = len(self._tareas) + 1
self._tareas.append(Tarea(siguiente_id, titulo))
def listar(self):
return [tarea.como_dict() for tarea in self._tareas]
def completar(self, tarea_id):
tarea = self._buscar(tarea_id)
tarea.completada = True
def pendientes(self):
return self._filtrar(completada=False)
def completadas(self):
return self._filtrar(completada=True)
def resumen(self):
return {
"total": len(self._tareas),
"pendientes": len(self.pendientes()),
"completadas": len(self.completadas()),
}
def _buscar(self, tarea_id):
for tarea in self._tareas:
if tarea.id == tarea_id:
return tarea
raise ValueError("Tarea inexistente")
def _filtrar(self, completada):
return [
tarea.como_dict()
for tarea in self._tareas
if tarea.completada == completada
]
Ejecutamos python -m pytest. Si todo sigue en verde, el refactor preservó el
comportamiento.
Al finalizar, las pruebas documentan qué sabe hacer el gestor:
Agregá nuevas reglas aplicando TDD.
Tarea se hizo con la suite en verde.Este ejercicio integrador muestra cómo TDD permite construir una funcionalidad completa sin perder control. El diseño del gestor no apareció de una vez: surgió de pruebas concretas, refactors seguros y decisiones tomadas en pasos pequeños.
En el próximo tema realizaremos el proyecto final: una pequeña biblioteca Python creada con el ciclo rojo, verde y refactor.