34. Ejercicio integrador: gestor de tareas desarrollado con TDD

34.1 Objetivo del tema

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.

34.2 Requisito general

Vamos a desarrollar esta funcionalidad:

Como usuario, quiero administrar mis tareas para saber qué tengo pendiente y qué ya completé.

Convertiremos esta idea en pruebas ejecutables, empezando por el comportamiento más simple.

34.3 Estructura sugerida

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.

34.4 Primera prueba: gestor nuevo sin tareas

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.

34.5 Implementación mínima

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ó.

34.6 Segunda prueba: agregar una tarea

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.

34.7 Código mínimo para agregar

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.

34.8 Tercera prueba: identificadores incrementales

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},
    ]

34.9 Implementar identificadores incrementales

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.

34.10 Cuarta prueba: completar una tarea

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.

34.11 Implementar completar

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.

34.12 Quinta prueba: tarea inexistente

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.

34.13 Implementar error de tarea inexistente

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.

34.14 Sexta prueba: filtrar pendientes

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}
    ]

34.15 Implementar pendientes

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.

34.16 Séptima prueba: filtrar completadas

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}
    ]

34.17 Implementar completadas

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.

34.18 Octava prueba: título obligatorio

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("")

34.19 Implementar validación de título

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.

34.20 Novena prueba: resumen

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,
    }

34.21 Implementar resumen

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.

34.22 Refactor: crear una clase Tarea

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.

34.23 Código refactorizado

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.

34.24 Suite como documentación

Al finalizar, las pruebas documentan qué sabe hacer el gestor:

  • Empieza sin tareas.
  • Agrega tareas con identificadores incrementales.
  • Marca tareas como completadas.
  • Rechaza identificadores inexistentes.
  • Filtra pendientes y completadas.
  • Valida título obligatorio.
  • Calcula resumen de estado.

34.25 Extensión del ejercicio

Agregá nuevas reglas aplicando TDD.

  1. Permitir editar el título de una tarea.
  2. Impedir editar una tarea inexistente.
  3. Permitir eliminar una tarea.
  4. Filtrar tareas por texto dentro del título.
  5. Agregar una prioridad: baja, media o alta.

34.26 Checklist del tema

  • El gestor se construyó con pasos pequeños.
  • Cada regla nueva apareció primero como prueba.
  • Las validaciones se agregaron cuando hubo un requisito.
  • El refactor a Tarea se hizo con la suite en verde.
  • La interfaz pública del gestor quedó protegida por pruebas.

34.27 Conclusión

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.