19. Probar clases, métodos y cambios de estado

19.1 Objetivo del tema

Hasta ahora probamos muchas funciones que devuelven valores. Las clases agregan un aspecto importante: los objetos tienen estado interno. Un método puede modificar atributos y ese cambio debe verificarse.

En este tema probaremos una clase con saldo, movimientos y validaciones. Veremos cómo comprobar estado inicial, cambios después de llamar métodos y errores esperados.

Idea clave: al probar objetos no solo importa qué devuelve un método, también importa cómo queda el estado del objeto después de ejecutarlo.

19.2 Crear una carpeta de práctica

Crea un proyecto nuevo:

mkdir pytest-clases-demo
cd pytest-clases-demo

Si pytest no está instalado en el entorno activo:

python -m pip install pytest

19.3 Crear la clase a probar

Crea un archivo llamado cuenta.py:

class CuentaBancaria:
    def __init__(self, titular, saldo_inicial=0):
        if saldo_inicial < 0:
            raise ValueError("El saldo inicial no puede ser negativo")

        self.titular = titular
        self.saldo = saldo_inicial
        self.movimientos = []

    def depositar(self, monto):
        if monto <= 0:
            raise ValueError("El depósito debe ser mayor que cero")

        self.saldo += monto
        self.movimientos.append(("deposito", monto))

    def extraer(self, monto):
        if monto <= 0:
            raise ValueError("La extracción debe ser mayor que cero")
        if monto > self.saldo:
            raise ValueError("Saldo insuficiente")

        self.saldo -= monto
        self.movimientos.append(("extraccion", monto))

    def esta_activa(self):
        return self.saldo > 0

    def cantidad_movimientos(self):
        return len(self.movimientos)

La clase modifica saldo y movimientos cuando se ejecutan sus métodos.

19.4 Crear el archivo de pruebas

Crea test_cuenta.py:

import pytest

from cuenta import CuentaBancaria


def test_crear_cuenta_con_saldo_inicial():
    cuenta = CuentaBancaria("Ana", 1000)

    assert cuenta.titular == "Ana"
    assert cuenta.saldo == 1000
    assert cuenta.movimientos == []

Esta prueba verifica el estado inicial del objeto.

19.5 Probar valores por defecto

También conviene probar qué ocurre cuando usamos argumentos por defecto:

def test_crear_cuenta_sin_saldo_inicial_empieza_en_cero():
    cuenta = CuentaBancaria("Luis")

    assert cuenta.saldo == 0
    assert cuenta.esta_activa() is False

La cuenta existe, pero no está activa porque su saldo es cero.

19.6 Probar un método que cambia estado

El método depositar modifica el saldo y agrega un movimiento:

def test_depositar_incrementa_saldo():
    cuenta = CuentaBancaria("Ana", 1000)

    cuenta.depositar(500)

    assert cuenta.saldo == 1500
    assert cuenta.movimientos == [("deposito", 500)]

Después de llamar al método, verificamos cómo quedó el objeto.

19.7 Probar otro cambio de estado

El método extraer reduce el saldo y registra la extracción:

def test_extraer_reduce_saldo():
    cuenta = CuentaBancaria("Ana", 1000)

    cuenta.extraer(300)

    assert cuenta.saldo == 700
    assert cuenta.movimientos == [("extraccion", 300)]

19.8 Usar fixture para evitar repetición

Como varias pruebas necesitan una cuenta con saldo, podemos crear una fixture:

@pytest.fixture
def cuenta():
    return CuentaBancaria("Ana", 1000)

Ahora las pruebas pueden pedir cuenta como argumento.

19.9 Reescribir pruebas con fixture

La prueba de depósito queda más breve:

def test_depositar_incrementa_saldo(cuenta):
    cuenta.depositar(500)

    assert cuenta.saldo == 1500
    assert cuenta.movimientos == [("deposito", 500)]

Cada prueba recibe una cuenta nueva porque el alcance por defecto de la fixture es function.

19.10 Probar varios métodos sobre el mismo objeto

Podemos ejecutar una secuencia de operaciones y verificar el estado final:

def test_deposito_y_extraccion_actualizan_saldo_y_movimientos(cuenta):
    cuenta.depositar(500)
    cuenta.extraer(200)

    assert cuenta.saldo == 1300
    assert cuenta.movimientos == [
        ("deposito", 500),
        ("extraccion", 200),
    ]

Esta prueba verifica una interacción más completa entre métodos.

19.11 Probar métodos de consulta

Los métodos que no modifican estado también deben probarse:

def test_cuenta_con_saldo_esta_activa(cuenta):
    assert cuenta.esta_activa() is True


def test_cantidad_movimientos(cuenta):
    cuenta.depositar(100)
    cuenta.extraer(50)

    assert cuenta.cantidad_movimientos() == 2

19.12 Probar errores del constructor

El constructor también puede validar datos:

def test_saldo_inicial_negativo_lanza_error():
    with pytest.raises(ValueError):
        CuentaBancaria("Ana", -100)

19.13 Probar errores de métodos

Los métodos con validaciones deben probar sus errores esperados:

def test_deposito_cero_lanza_error(cuenta):
    with pytest.raises(ValueError):
        cuenta.depositar(0)


def test_extraccion_mayor_al_saldo_lanza_error(cuenta):
    with pytest.raises(ValueError):
        cuenta.extraer(2000)

19.14 Verificar que un error no cambie el estado

Cuando un método falla, muchas veces esperamos que el objeto quede igual:

def test_extraccion_invalida_no_modifica_saldo(cuenta):
    with pytest.raises(ValueError):
        cuenta.extraer(2000)

    assert cuenta.saldo == 1000
    assert cuenta.movimientos == []

Esta prueba protege una regla importante: una operación rechazada no debe alterar la cuenta.

19.15 Archivo completo de pruebas

El archivo test_cuenta.py puede quedar así:

import pytest

from cuenta import CuentaBancaria


@pytest.fixture
def cuenta():
    return CuentaBancaria("Ana", 1000)


def test_crear_cuenta_con_saldo_inicial():
    cuenta = CuentaBancaria("Ana", 1000)

    assert cuenta.titular == "Ana"
    assert cuenta.saldo == 1000
    assert cuenta.movimientos == []


def test_crear_cuenta_sin_saldo_inicial_empieza_en_cero():
    cuenta = CuentaBancaria("Luis")

    assert cuenta.saldo == 0
    assert cuenta.esta_activa() is False


def test_depositar_incrementa_saldo(cuenta):
    cuenta.depositar(500)

    assert cuenta.saldo == 1500
    assert cuenta.movimientos == [("deposito", 500)]


def test_extraer_reduce_saldo(cuenta):
    cuenta.extraer(300)

    assert cuenta.saldo == 700
    assert cuenta.movimientos == [("extraccion", 300)]


def test_deposito_y_extraccion_actualizan_saldo_y_movimientos(cuenta):
    cuenta.depositar(500)
    cuenta.extraer(200)

    assert cuenta.saldo == 1300
    assert cuenta.movimientos == [
        ("deposito", 500),
        ("extraccion", 200),
    ]


def test_cuenta_con_saldo_esta_activa(cuenta):
    assert cuenta.esta_activa() is True


def test_cantidad_movimientos(cuenta):
    cuenta.depositar(100)
    cuenta.extraer(50)

    assert cuenta.cantidad_movimientos() == 2


def test_saldo_inicial_negativo_lanza_error():
    with pytest.raises(ValueError):
        CuentaBancaria("Ana", -100)


def test_deposito_cero_lanza_error(cuenta):
    with pytest.raises(ValueError):
        cuenta.depositar(0)


def test_extraccion_mayor_al_saldo_lanza_error(cuenta):
    with pytest.raises(ValueError):
        cuenta.extraer(2000)


def test_extraccion_invalida_no_modifica_saldo(cuenta):
    with pytest.raises(ValueError):
        cuenta.extraer(2000)

    assert cuenta.saldo == 1000
    assert cuenta.movimientos == []

19.16 Ejecutar las pruebas

Ejecuta:

python -m pytest

La salida esperada será similar a:

collected 11 items

test_cuenta.py ...........                                       [100%]

11 passed in 0.03s

19.17 Ejecutar con salida detallada

Para ver cada prueba:

python -m pytest -v

La salida detallada ayuda a revisar qué comportamiento cubre cada caso.

19.18 Qué observar al probar estado

Aspecto Ejemplo
Estado inicial saldo == 1000, movimientos == []
Cambio después de un método depositar incrementa el saldo.
Registro de operaciones movimientos guarda depósitos y extracciones.
Errores sin efectos secundarios Una extracción inválida no cambia el saldo.

19.19 Errores frecuentes

  • Reutilizar el mismo objeto entre pruebas: puede provocar dependencia entre pruebas.
  • Probar solo el valor devuelto: en métodos con estado también hay que verificar atributos modificados.
  • No comprobar errores sin efectos secundarios: una excepción puede dejar el objeto en un estado incorrecto.
  • Crear pruebas demasiado largas: si una prueba verifica muchas reglas, cuesta entender la falla.
  • Ocultar demasiado en fixtures: la preparación debe ayudar, no volver la prueba misteriosa.

19.20 Comandos usados en este tema

mkdir pytest-clases-demo
cd pytest-clases-demo
python -m pip install pytest
python -m pytest
python -m pytest -v
python -m pytest test_cuenta.py::test_extraccion_invalida_no_modifica_saldo -v

19.21 Qué debes recordar de este tema

  • Las clases tienen estado interno que debe verificarse.
  • Conviene probar estado inicial, cambios y errores esperados.
  • Los métodos que modifican atributos deben probar el estado final.
  • Una operación inválida no debería dejar cambios parciales.
  • Las fixtures ayudan a crear objetos nuevos para cada prueba.
  • Las pruebas deben ser independientes entre sí.

19.22 Conclusión

En este tema probamos una clase con estado interno. Verificamos construcción, métodos que modifican atributos, métodos de consulta y errores esperados.

En el próximo tema trabajaremos con validaciones, casos borde y datos inválidos, reforzando cómo elegir entradas importantes para una suite de pruebas.