Saltar a contenido

Lab guiado: Tests del repositorio SQLite y documentación de la versión final

Cómo trabajar en parejas

Este lab se realiza en parejas conductor/navegante. Leed las instrucciones antes de empezar.

Roles

Rol Qué hace
Conductor Escribe el código/configuración y ejecuta los pasos. Solo el conductor toca el teclado.
Navegante Lee el enunciado en voz alta, propone soluciones, detecta errores y formula las preguntas.

Rotación

  • Cambiad de rol al final de cada paso.
  • Al rotar, el navegante ocupa el teclado y el conductor se convierte en navegante.
  • Ambos debéis poder explicar cualquier parte del trabajo al terminar el lab.

Entrega

Carpeta comprimida en formato zip que contenga todos los ficheros modificados o creados durante el lab: tests/test_repositorio_sqlite.py y los cuatro ficheros de documentación actualizados.


Antes de empezar

Este lab parte del proyecto con el Lab A2 completamente terminado: RepositorioProductosSQLite, errores.py y la adaptación de maquina.py (Paso 8 del Lab A2, que añade actualizar() y corrige comprar, reponer y agregar_item). Todos los comandos se ejecutan desde la carpeta padre de expendedora/ — la misma desde la que arrancas la aplicación con python -m expendedora.presentation.menu.

Verifica que los tests existentes siguen pasando:

python -m unittest

Si alguno falla, resuélvelo antes de continuar.


Paso 1 — Estructura del fichero de test tests/test_repositorio_sqlite.py

Conceptos

Los tests de repositorio leen y escriben en un fichero .db real. Para que no se contaminen entre sí, cada test trabaja sobre una BD nueva y vacía — distinta de la de producción. BD_TEST es un atributo de clase: todas las instancias de la clase de test comparten la misma ruta.

BD_TEST = Path("test_expendedora.db")   # nunca "expendedora.db"

Objetivo

Crear el esqueleto del fichero de test con los imports necesarios y el atributo BD_TEST.

Tarea

Crea el fichero tests/test_repositorio_sqlite.py:

import sqlite3
import unittest
from pathlib import Path

from expendedora.domain.item import Item, ItemConDescuento
from expendedora.infrastructure.repositorio_sqlite import RepositorioProductosSQLite
from expendedora.infrastructure.errores import (
    ProductoYaExisteError,
    ProductoNoEncontradoError,
)


class TestRepositorioSQLite(unittest.TestCase):

    BD_TEST = Path("test_expendedora.db")


if __name__ == "__main__":
    unittest.main()

Verifica que el fichero se puede importar sin errores:

python -m unittest expendedora.tests.test_repositorio_sqlite

Pregunta:

¿Por qué usamos un nombre diferente al de la BD de producción?

Respuesta

Para que los tests nunca toquen expendedora.db. Si un test guarda datos de prueba en la BD de producción, contamina los datos reales de la aplicación. Con un nombre distinto, setUp y tearDown pueden crear y borrar libremente el fichero de test sin riesgo.

Comprobación

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK

Sin tests aún, pero tampoco errores de importación. Si ves ModuleNotFoundError, comprueba que repositorio_sqlite.py y errores.py existen en infrastructure/.


Paso 2 — setUp y tearDown: aislar la BD de cada test tests/test_repositorio_sqlite.py

Conceptos

setUp se ejecuta automáticamente antes de cada test: crea el esquema y el repositorio. tearDown se ejecuta después de cada test, tanto si pasa como si falla: elimina el fichero. Forman un ciclo simétrico — si setUp crea, tearDown destruye. El unlink() inicial en setUp garantiza un estado limpio aunque el proceso anterior se hubiera interrumpido.

Objetivo

Implementar el ciclo setUp/tearDown para que cada test parta de una BD limpia.

Tarea

Añade los dos métodos a la clase:

def setUp(self):
    if self.BD_TEST.exists():
        self.BD_TEST.unlink()      # estado limpio aunque tearDown fallara antes

    conn = sqlite3.connect(self.BD_TEST)
    cursor = conn.cursor()
    cursor.execute("""
        CREATE TABLE productos (
            codigo TEXT PRIMARY KEY, nombre TEXT NOT NULL,
            precio REAL NOT NULL, cantidad INTEGER NOT NULL
        )
    """)
    cursor.execute("""
        CREATE TABLE descuentos (
            codigo TEXT PRIMARY KEY,
            porcentaje REAL NOT NULL,
            FOREIGN KEY (codigo) REFERENCES productos(codigo)
        )
    """)
    conn.commit()
    conn.close()
    self.repo = RepositorioProductosSQLite(self.BD_TEST)

def tearDown(self):
    if self.BD_TEST.exists():
        self.BD_TEST.unlink()

Pregunta:

¿Qué pasaría si solo tuviéramos setUp sin tearDown?

Respuesta

Los ficheros test_expendedora.db se acumularían entre ejecuciones. Peor aún: al ejecutar por segunda vez, setUp intentaría CREATE TABLE productos sobre una BD que ya tiene esa tabla y lanzaría OperationalError: table already exists. Los tests dejarían de funcionar de forma impredecible.

Comprobación

Ejecuta los tests y verifica que el fichero test_expendedora.db no existe al terminar:

python -m unittest expendedora.tests.test_repositorio_sqlite
ls *.db

Solo debe aparecer expendedora.db.


Paso 3 — Verificar que guardar() persiste los datos tests/test_repositorio_sqlite.py

Conceptos

El test comprueba el resultado observable: tras guardar(), los datos se recuperan con obtener(). No comparamos objetos enteros porque Item no tiene __eq__ definido — comparar item1 == item2 sería siempre False. En su lugar, verificamos cada atributo por separado. Para float usamos assertAlmostEqual para evitar problemas de redondeo.

Objetivo

Escribir los tests que verifican que guardar() persiste tanto un Item como un ItemConDescuento.

Tarea

Añade los dos tests a la clase:

def test_guardar_persiste_el_producto(self):
    self.repo.guardar(Item("A1", "Agua", 1.00, 10))
    item = self.repo.obtener("A1")
    self.assertEqual(item.nombre, "Agua")
    self.assertEqual(item.precio, 1.00)
    self.assertEqual(item.cantidad, 10)

def test_guardar_persiste_item_con_descuento(self):
    self.repo.guardar(ItemConDescuento("D1", "Refresco", 2.50, 5, 20))
    item = self.repo.obtener("D1")
    self.assertEqual(item.nombre, "Refresco")
    self.assertAlmostEqual(item.porcentaje_descuento, 20.0)

Comprobación

python -m unittest expendedora.tests.test_repositorio_sqlite
..
----------------------------------------------------------------------
Ran 2 tests in 0.Xs

OK

Paso 4 — Verificar el tipo devuelto por obtener() tests/test_repositorio_sqlite.py

Conceptos

El repositorio debe reconstruir el tipo correcto: ItemConDescuento si el producto tiene entrada en descuentos, Item base si no la tiene. assertIsInstance(obj, Clase) verifica el tipo real del objeto — es el assert adecuado cuando la lógica depende de la clase concreta, no solo de los atributos.

Objetivo

Escribir los tests que verifican que obtener() devuelve el tipo exacto correcto.

Tarea

Añade los dos tests:

def test_obtener_devuelve_item_con_descuento(self):
    self.repo.guardar(ItemConDescuento("D1", "Refresco", 2.50, 5, 20))
    item = self.repo.obtener("D1")
    self.assertIsInstance(item, ItemConDescuento)
    self.assertAlmostEqual(item.porcentaje_descuento, 20.0)

def test_obtener_devuelve_item_normal(self):
    self.repo.guardar(Item("A1", "Agua", 1.00, 10))
    item = self.repo.obtener("A1")
    self.assertIsInstance(item, Item)
    self.assertNotIsInstance(item, ItemConDescuento)

Pregunta:

¿Por qué no basta con verificar item.nombre == "Agua" para saber si obtener() devuelve el tipo correcto?

Respuesta

Porque nombre es un atributo de Item, presente en ambas clases. Un repositorio que siempre devolviera Item pasaría ese test aunque fuera incorrecto: no estaría diferenciando entre productos con y sin descuento. assertIsInstance detecta exactamente ese fallo.

Comprobación

python -m unittest expendedora.tests.test_repositorio_sqlite

Los 4 tests escritos hasta ahora deben pasar.


Paso 5 — Testar las excepciones de dominio tests/test_repositorio_sqlite.py

Conceptos

El repositorio traduce sqlite3.IntegrityError a ProductoYaExisteError y devuelve ProductoNoEncontradoError cuando no existe el código. Los tests verifican excepciones de dominio, no excepciones de SQLite: si cambiamos el motor de BD, los tests siguen siendo válidos.

Con el gestor de contexto se puede inspeccionar el mensaje:

with self.assertRaises(ProductoNoEncontradoError) as ctx:
    self.repo.obtener("X9")
self.assertIn("X9", str(ctx.exception))

Objetivo

Verificar que el repositorio lanza las excepciones de dominio correctas y que el mensaje incluye el código problemático.

Tarea

Los dos primeros tests están completos. Completa el tercero con una sola línea de assertIn:

def test_obtener_codigo_inexistente_lanza_excepcion(self):
    with self.assertRaises(ProductoNoEncontradoError):
        self.repo.obtener("X9")

def test_guardar_duplicado_lanza_excepcion(self):
    self.repo.guardar(Item("A1", "Agua", 1.00, 10))
    with self.assertRaises(ProductoYaExisteError):
        self.repo.guardar(Item("A1", "Duplicado", 0.50, 3))

def test_excepcion_incluye_el_codigo(self):
    with self.assertRaises(ProductoNoEncontradoError) as ctx:
        self.repo.obtener("X9")
    # tu código aquí: verifica que "X9" aparece en str(ctx.exception)
Pista

ctx.exception es el objeto excepción. str() lo convierte en texto. assertIn("X9", ...) comprueba que el código aparece en ese texto.

Mala práctica

# Incorrecto: el test se acopla a la infraestructura
with self.assertRaises(sqlite3.IntegrityError):
    self.repo.guardar(Item("A1", "Duplicado", 0.50, 3))
Si cambias de motor de BD, este test se rompe aunque el repositorio funcione bien. Testea siempre excepciones de dominio.

Comprobación

Ejecuta la suite completa:

python -m unittest

Todos los tests de test_item.py, test_maquina.py y test_repositorio_sqlite.py deben estar en verde.


Tests completados

La suite de tests del repositorio SQLite está lista. Lo siguiente es documentar todos los cambios introducidos desde la versión 0.3.0.


Paso 6 — Registrar los cambios en el CHANGELOG CHANGELOG.md

Conceptos

El CHANGELOG.md registra qué cambió en cada versión (formato Keep a Changelog). La versión más reciente va al inicio del fichero. Las secciones Added y Changed describen qué se añadió y qué se modificó respecto a la versión anterior.

Objetivo

Añadir la entrada [0.4.0] al inicio de CHANGELOG.md.

Tarea

Añade el siguiente bloque antes de la entrada [0.3.0]. Sustituye XX por el día de hoy:

## [0.4.0] - 2026-03-XX (Fase 04: SQLite + tests de repositorio)

### Added
- `infrastructure/repositorio_sqlite.py`: `RepositorioProductosSQLite` con `guardar`, `obtener`, `actualizar`, `listar` y `eliminar`.
- `infrastructure/errores.py`: `ErrorRepositorio`, `ProductoYaExisteError`, `ProductoNoEncontradoError` y `ErrorPersistencia`.
- `crear_bd.py`: script para crear el esquema e insertar datos iniciales.
- `expendedora.db`: base de datos SQLite con los datos iniciales.
- `tests/test_repositorio_sqlite.py`: tests del repositorio con BD aislada.

### Changed
- `domain/repositorio_productos.py`: añadido `actualizar(item)` al contrato.
- `domain/maquina.py`: `comprar()` y `reponer()` persisten el stock con `actualizar()`; `agregar_item()` delega la validación de duplicado en `guardar()`.
- `infrastructure/repositorio_memoria.py`: excepciones estandarizadas (`ProductoYaExisteError`, `ProductoNoEncontradoError`); añadido `actualizar()`.
- `infrastructure/datos_iniciales.py`: usa `RepositorioProductosSQLite`.
- `docs/CONTRATO_REPOSITORIO.md`: contrato actualizado con excepciones de dominio y método `actualizar`.
- `docs/ARQUITECTURA_POR_CAPAS.md` y `docs/TESTS_Y_PASOS.md`: mapa de ficheros actualizado.
- `README.md`: estructura del proyecto actualizada.

Comprobación

El fichero debe empezar con ## [0.4.0] seguido de ## [0.3.0].


Paso 7 — Actualizar el contrato del repositorio docs/CONTRATO_REPOSITORIO.md

Conceptos

El contrato ha cambiado: guardar() ya no lanza ValueError genérico sino ProductoYaExisteError, y obtener() ya no devuelve None sino que lanza ProductoNoEncontradoError. Cualquiera que implemente un repositorio alternativo debe conocer este contrato actualizado.

Objetivo

Reemplazar el contenido de docs/CONTRATO_REPOSITORIO.md para reflejar el nuevo comportamiento.

Tarea

Reemplaza el contenido completo del fichero:

# Contrato de repositorio

## Contrato RepositorioProductos (domain)

- `guardar(item)`: almacena un item. Lanza `ProductoYaExisteError` si el código ya existe.
- `obtener(codigo)`: devuelve el item con ese código. Lanza `ProductoNoEncontradoError` si no existe.
- `actualizar(item)`: persiste los cambios de estado de un item existente (por ejemplo, la cantidad tras una compra o reposición).
- `listar()`: devuelve lista de todos los items almacenados.
- `eliminar(codigo)`: elimina un item por código; no falla si no existe.

## Implementaciones disponibles

- `RepositorioProductosMemoria` (`infrastructure/repositorio_memoria.py`):
  almacena en un diccionario en memoria. Solo para tests rápidos y desarrollo.
- `RepositorioProductosSQLite` (`infrastructure/repositorio_sqlite.py`):
  persiste en `expendedora.db`. Implementación de producción.

## Excepciones de dominio (`infrastructure/errores.py`)

- `ErrorRepositorio`: base de todas las excepciones del repositorio.
- `ProductoYaExisteError`: código duplicado al guardar.
- `ProductoNoEncontradoError`: código no encontrado al obtener.
- `ErrorPersistencia`: error inesperado del motor de base de datos.

## Tests

- `tests/test_repositorio_sqlite.py`: cubre `guardar`, `obtener` y excepciones del repositorio SQLite.

```bash
python -m unittest expendedora.tests.test_repositorio_sqlite
```

Comprobación

El fichero tiene 5 secciones: contrato, implementaciones, excepciones, tests. Sin referencias a None ni ValueError.


Paso 8 — Actualizar la arquitectura y los pasos de test docs/ARQUITECTURA_POR_CAPAS.md + docs/TESTS_Y_PASOS.md

Objetivo

Añadir los nuevos ficheros al mapa de la arquitectura y al documento de ejecución de tests.

Tarea

A) docs/ARQUITECTURA_POR_CAPAS.md — Reemplaza la sección Mapa de archivos:

## Mapa de archivos

- `presentation/menu.py`: UI de consola.
- `application/servicios.py`: casos de uso.
- `domain/item.py`, `domain/maquina.py`, `domain/repositorio_productos.py`: nucleo.
- `infrastructure/repositorio_memoria.py`, `infrastructure/datos_iniciales.py`: adaptadores.
- `infrastructure/repositorio_sqlite.py`: repositorio SQLite de producción.
- `infrastructure/errores.py`: excepciones de dominio del repositorio.
- `tests/test_item.py`, `tests/test_maquina.py`: pruebas del dominio.
- `tests/test_repositorio_sqlite.py`: pruebas del repositorio SQLite.
- `crear_bd.py`: script para crear el esquema e insertar datos iniciales.

B) docs/TESTS_Y_PASOS.md — Añade al final:

## Tests del repositorio SQLite

`tests/test_repositorio_sqlite.py` cubre las operaciones del repositorio sobre la base
de datos real. Usa una BD de test aislada (`test_expendedora.db`) que se crea antes de
cada test y se elimina al terminar.

Ejecutar solo los tests del repositorio:

```bash
python -m unittest expendedora.tests.test_repositorio_sqlite
```

Comprobación

  • ARQUITECTURA_POR_CAPAS.md lista repositorio_sqlite.py, errores.py, test_repositorio_sqlite.py y crear_bd.py.
  • TESTS_Y_PASOS.md tiene la nueva sección al final con el comando de ejecución.

Paso 9 — Actualizar el README README.md

Objetivo

Actualizar la estructura del proyecto en README.md para incluir los nuevos ficheros.

Tarea

Reemplaza el bloque text de la sección ## Estructura del proyecto:

## Estructura del proyecto

```text
expendedora/
  presentation/
    menu.py
  application/
    servicios.py
  domain/
    item.py
    maquina.py
    repositorio_productos.py
  infrastructure/
    datos_iniciales.py
    errores.py
    repositorio_memoria.py
    repositorio_sqlite.py
  tests/
    test_item.py
    test_maquina.py
    test_repositorio_sqlite.py
  docs/
crear_bd.py
expendedora.db
requirements.txt
```

Actualiza también la sección ## Tests para incluir el nuevo fichero:

## Tests

Ejecutar toda la suite:

```bash
python -m unittest
```

Cobertura:

```bash
coverage run -m unittest
coverage report
coverage html
```

El reporte HTML queda en `htmlcov/index.html`.

Comprobación

El README muestra repositorio_sqlite.py, errores.py, test_repositorio_sqlite.py y crear_bd.py en la estructura, y el comando python -m unittest en la sección de tests.


Katas de refactor

Kata 1 — unlink(missing_ok=True)

En setUp y tearDown hay una comprobación if self.BD_TEST.exists() antes de unlink(). En Python 3.8+ se puede eliminar usando unlink(missing_ok=True). Refactoriza ambos métodos para usar esta forma más concisa.

Kata 2 — Test de listar()

El repositorio tiene un método listar(). Escribe un test que guarde tres productos (dos Item y un ItemConDescuento), llame a listar() y verifique que devuelve exactamente tres elementos. ¿Qué devuelve listar() cuando el repositorio está vacío? Escribe también ese test.

Kata 3 — setUpClass (avanzado)

setUp crea el esquema antes de cada test aunque el esquema sea siempre el mismo. Investiga setUpClass y tearDownClass (se ejecutan una sola vez para toda la clase) y rediseña la suite para que el esquema se cree una vez y cada test solo inserte y limpie datos. ¿Qué ventajas e inconvenientes tiene frente al diseño original?


Checklist de entrega

  • He creado tests/test_repositorio_sqlite.py con BD_TEST como atributo de clase.
  • He implementado setUp que crea el esquema completo antes de cada test.
  • He implementado tearDown que elimina el fichero de BD al terminar.
  • He escrito tests para guardar() verificando atributos individuales.
  • He escrito tests de tipo con assertIsInstance y assertNotIsInstance.
  • He escrito tests de excepciones con assertRaises usando excepciones de dominio.
  • He verificado el mensaje de la excepción con ctx.exception.
  • He añadido la entrada [0.4.0] al CHANGELOG.md.
  • He actualizado docs/CONTRATO_REPOSITORIO.md con el nuevo contrato.
  • He actualizado docs/ARQUITECTURA_POR_CAPAS.md y docs/TESTS_Y_PASOS.md.
  • He actualizado README.md con la estructura actual del proyecto.

Actividades de ampliación

A — Test de listar() vacío (Paso 5) tests/test_repositorio_sqlite.py

¿Qué devuelve listar() cuando el repositorio está vacío? Escribe el test que lo comprueba.

B — Test de stock a cero (Paso 3) tests/test_repositorio_sqlite.py

Guarda un Item con cantidad=0 y verifica que se puede guardar y recuperar correctamente. Esto garantiza que el repositorio no confunde "stock vacío" con "producto inexistente".

1
2
3
def test_guardar_producto_sin_stock(self):
    self.repo.guardar(Item("A1", "Agua", 1.00, 0))
    # tu código aquí

C — Cobertura del repositorio SQLite (Paso 5)

Genera el reporte de cobertura HTML y analiza infrastructure/repositorio_sqlite.py. ¿Qué líneas no están cubiertas? Escribe un test adicional para cubrir al menos una:

coverage run -m unittest
coverage html
# abre htmlcov/index.html → navega a repositorio_sqlite.py

D — docs/DATOS_INICIALES.md (Paso 6)

La versión 0.4.0 cambia cómo se cargan los datos iniciales: ahora es crear_bd.py quien los inserta. Actualiza docs/DATOS_INICIALES.md para reflejar que los datos se precargan vía ese script y describe qué productos contiene la BD inicial.

E — Reto final: test de integración

Sin plantilla ni pistas. Diseña y escribe un test de integración que: 1. Cree una BD de test con el esquema completo. 2. Use RepositorioProductosSQLite como repositorio. 3. Instancie MaquinaExpendedora con ese repositorio. 4. Simule un flujo completo: seleccionar producto, insertar dinero, comprar y verificar que el stock disminuye en la BD.

Define tú mismo qué assertions son necesarios para que el test sea significativo.