Módulo 2: Unit Tests con Claude Code

Validar Tests Generados por AI

Validar Tests Generados por AI

Descripción de la cápsula

Claude Code puede generar 50 tests en 30 segundos. Eso suena impresionante. Pero si no sabes evaluar si esos tests son útiles o triviales, tienes una falsa sensación de seguridad. 40 de esos 50 tests podrían verificar lo obvio y pasar con una implementación equivocada. Los 10 restantes podrían no existir — y el bug crítico en producción permanece escondido.

Esta cápsula es el skill más crítico del módulo. No basta con saber generar tests con prompts específicos (cápsula 02) ni dominar los patrones de pytest (cápsulas 03-04). La pregunta que debes responder en 30 segundos es: ¿Estos tests que Claude Code generó realmente protegen contra bugs, o solo dan checkmarks verdes? Aprenderás el checklist de 5 puntos para evaluar tests generados por AI, las red flags que indican tests triviales, y el mental model de mutation testing que distingue coverage de línea de coverage de comportamiento.

Al final, podrás mirar una suite generada por Claude Code y distinguir en segundos qué tests aportan valor y cuáles son decorativos. Y tendrás prompts concretos para mejorar tests débiles hasta convertirlos en una suite profesional.


El Problema: La Falsa Confianza

"50 tests pasan" ≠ "Tu código es correcto"

Cuando ejecutas pytest y ves 50 passed in 0.3s, es tentador asumir que tu código está bien. Pero esa conclusión solo es válida si los 50 tests verifican comportamiento relevante. Si 45 de ellos hacen assert result is not None o testean el mismo happy path con variaciones cosméticas, tienes 45 tests que pasan incluso cuando tu lógica tiene bugs graves.

El objetivo de los tests es detectar regresiones. Si modificas la implementación y introduces un bug, un test bien escrito falla. Un test trivial no falla — porque no verifica la lógica que rompiste. El resultado: despliegues con confianza falsa.

Coverage de líneas ≠ Coverage de comportamiento

Las herramientas de coverage (que verás en el Módulo 5) reportan qué porcentaje de líneas de código fue ejecutado. Si tu suite ejecuta el 100% de las líneas, el coverage dice "100%." Pero eso no significa que cada línea está verificada.

Imagina esta función:

def calculate_tax(price: float, rate: float) -> float:
    if price < 0:
        raise ValueError("Price cannot be negative")
    return price * rate

Un test que llama calculate_tax(100, 0.1) y verifica result == 10.0 ejecuta el 100% de las líneas. Pero ¿qué pasa si alguien cambia la fórmula a return 0? El test falla. Bien. ¿Y si cambian la fórmula a return price * rate * 0.5 (un bug)? El test también falla. Eso está bien.

Ahora imagina estos tests:

def test_calculate_tax_returns_number():
    result = calculate_tax(100, 0.1)
    assert isinstance(result, float)

def test_calculate_tax_not_none():
    result = calculate_tax(100, 0.1)
    assert result is not None

def test_calculate_tax_positive_input():
    result = calculate_tax(100, 0.1)
    assert result > 0

Los tres pasan. Coverage: 100%. Pero si la implementación hace return 0.001 (cualquier valor positivo pequeño), los tres tests siguen pasando. Coverage de línea dice 100%. Coverage de comportamiento dice: la fórmula nunca fue verificada.

Tests triviales: checkmarks verdes sin protección real

Un test trivial da un resultado verde pero no detectaría un bug si lo introduces. Características típicas:

  • Verifica que la función retorna "algo" en vez de verificar el valor exacto.
  • Usa siempre inputs "amigables" (2, 3, "hello", True).
  • No cubre edge cases (vacío, None, cero, negativos, límites).
  • No verifica los paths de error (excepciones).
  • Podría pasar si la función retornara un valor hardcodeado.

Ejemplo completo: 10 tests que pasan y un bug crítico escondido

La función a probar:

# discounts.py
def apply_discount(price: float, discount_percent: float) -> float:
    """
    Apply discount and return final price.
    Raises ValueError for invalid inputs.
    """
    if price < 0:
        raise ValueError("Price cannot be negative")
    if not 0 <= discount_percent <= 100:
        raise ValueError("Discount must be between 0 and 100")
    return round(price * (1 - discount_percent / 100), 2)

Tests generados por AI (versión débil):

# test_discounts_weak.py
import pytest
from discounts import apply_discount


def test_apply_discount_returns_float():
    result = apply_discount(100, 10)
    assert isinstance(result, float)


def test_apply_discount_not_none():
    result = apply_discount(100, 10)
    assert result is not None


def test_apply_discount_positive():
    result = apply_discount(100, 10)
    assert result > 0


def test_apply_discount_less_than_price():
    result = apply_discount(100, 10)
    assert result < 100


def test_apply_discount_with_zero_discount():
    result = apply_discount(50, 0)
    assert result == 50  # Este es el único assert fuerte


def test_apply_discount_with_hundred_percent():
    result = apply_discount(100, 100)
    assert result >= 0


def test_apply_discount_different_inputs():
    result = apply_discount(200, 25)
    assert isinstance(result, float)


def test_apply_discount_small_values():
    result = apply_discount(1, 1)
    assert result >= 0


def test_apply_discount_negative_price_raises():
    with pytest.raises(ValueError):
        apply_discount(-10, 5)


def test_apply_discount_invalid_discount_raises():
    with pytest.raises(ValueError):
        apply_discount(100, 150)

Los 10 tests pasan. Pero hay un bug crítico: si la implementación tiene un error de redondeo o usa la fórmula equivocada, la mayoría de los tests no lo detectan.

Implementación con bug sutil que los tests débiles NO detectan:

# discounts_buggy.py — los 10 tests pasan con esta implementación incorrecta
def apply_discount(price: float, discount_percent: float) -> float:
    if price < 0:
        raise ValueError("Price cannot be negative")
    if not 0 <= discount_percent <= 100:
        raise ValueError("Discount must be between 0 and 100")
    # BUG: divide por 100 dos veces (typo)
    return round(price * (1 - discount_percent / 100 / 100), 2)

Con apply_discount(100, 10):

  • Correcto: 100 * 0.9 = 90.0
  • Con bug: 100 * (1 - 0.001) = 99.9

Solo test_apply_discount_with_zero_discount verifica un valor exacto. Los otros 9 pasan. 9 de 10 tests son decorativos.

El test que sí detectaría el bug:

def test_apply_discount_ten_percent_returns_ninety():
    result = apply_discount(100, 10)
    assert result == 90.0  # Assert explícito del valor esperado

El Checklist de 5 Puntos para Evaluar Tests Generados por AI

Usa este checklist cada vez que Claude Code genere una suite. Si un test falla en más de un punto, refuércenlo o reemplácelo.

1. ¿Testea COMPORTAMIENTO o IMPLEMENTACIÓN?

Comportamiento: Qué hace el sistema desde el punto de vista del consumidor. Dado input X, debe retornar Y. No importa cómo lo hace internamente.

Implementación: Cómo lo hace el sistema. Qué funciones internas usa, qué estructuras de datos, qué orden de operaciones.

TipoEjemploEvaluación
Comportamientoassert sort([3,1,2]) == [1,2,3]✅ Verifica el contrato: dado input, output correcto
Implementaciónassert "sorted" in inspect.getsource(sort)❌ Acopla el test a detalles internos
Comportamientoassert validate_email("a@b.co") == True✅ Verifica contrato
Implementaciónassert "split" in inspect.getsource(validate_email)❌ Si cambias a regex, el test rompe sin razón

Regla práctica: Si refactorizas la implementación sin cambiar el comportamiento, los tests no deberían cambiar. Si cambian, probablemente testean implementación.

2. ¿Cubre EDGE CASES?

Los edge cases son los inputs donde el comportamiento es menos obvio o donde los bugs suelen esconderse:

  • Vacío: "", [], {}, None
  • Cero: 0 (para números)
  • Negativos: -1, -100
  • Valores límite: primer elemento, último elemento, exactamente en el borde
  • Tipos incorrectos: None donde se espera string, int donde se espera float
  • Casos extremos del dominio: máximo valor representable, strings muy largos

Si todos los tests usan inputs "normales" (2, 3, "hello", [1, 2, 3]), la suite es débil. Al menos un 20-30% de los tests deberían cubrir edge cases.

Ejemplo para clamp(value, min_val, max_val):

CategoríaEdge cases a cubrir
Normalvalue dentro del rango
Límitevalue == min_val, value == max_val
Fueravalue < min_val, value > max_val
Especialmin_val == max_val, rango negativo (-10 a -1)
Errormin_val > max_val (debe lanzar)

3. ¿El ASSERT es significativo?

La fuerza del assert determina cuánto "espacio" tiene una implementación incorrecta para pasar.

Tipo de assertFuerzaEjemplo
DébilCasi cualquier cosa pasaassert result is not None
DébilCasi cualquier cosa pasaassert isinstance(result, (int, float))
MedioAlgo de restricciónassert len(result) > 0
MedioAlgo de restricciónassert result >= 0
FuerteValida el valor exactoassert result == 90.0
FuerteValida la estructuraassert result == {"key": "value"}

Regla: Preferir asserts que verifiquen el valor esperado exacto. Si solo verificas "es un número" o "no es None", una implementación rota puede pasar.

4. ¿Testea los ERROR PATHS?

No solo el happy path. ¿Qué pasa con inputs inválidos? ¿La función lanza la excepción correcta con el mensaje correcto?

# ❌ No verifica error path
def test_validate_email():
    assert validate_email("user@example.com") == True


# ✅ Verifica error path
def test_validate_email_none_raises_type_error():
    with pytest.raises(TypeError, match="must be a string"):
        validate_email(None)

Para funciones que validan inputs, al menos 1-2 tests por tipo de error (tipo incorrecto, valor fuera de rango, formato inválido).

5. ¿Una implementación INCORRECTA pasaría estos tests?

El test definitivo: Imagina que la función retorna un valor hardcodeado.

def apply_discount(price: float, discount_percent: float) -> float:
    return 90.0  # Hardcodeado

Ejecuta la suite. Si todos los tests pasan, los tests son triviales. Un test útil fallaría porque apply_discount(50, 0) debería dar 50, no 90.

Variante: Comenta una línea crítica de la implementación. ¿Algún test falla? Si no, esa línea no tiene coverage de comportamiento — aunque pytest-cov diga que la línea fue ejecutada.


Red Flags en Tests Generados por AI

Reconoce estos patrones y actúa (refuerza o elimina el test).

Red flag 1: Todos los tests usan inputs "amigables"

  • Siempre 2, 3, "hello", True, [1, 2, 3]
  • Nunca 0, -1, "", None, [], float('nan')

Acción: Pide a Claude Code: "Añade tests para edge cases: input vacío, None, cero, negativos."

Red flag 2: Asserts débiles

  • assert result is not None
  • assert isinstance(result, SomeType)
  • assert len(result) > 0 cuando el valor exacto importa

Acción: Sustituye por assert que verifique el valor esperado. Si no conoces el valor esperado, calcúlalo independientemente (no usando la misma función).

Red flag 3: Llaman a la función pero no afirman el resultado

def test_process_data():
    result = process_data([1, 2, 3])
    # No hay assert — el test pasa si no hay excepción

Acción: Añade un assert que verifique el resultado. Si la función retorna algo, verifica ese algo.

Red flag 4: El valor esperado parece calculado por la misma lógica

A veces Claude Code hace:

def test_calculate():
    result = calculate(100, 10)
    expected = 100 * (1 - 10/100)  # Repite la lógica
    assert result == expected

Si la implementación tiene un bug en esa misma fórmula, el test replica el bug y pasa. Calcula el valor esperado a mano o con una fuente independiente.

Red flag 5: Testean built-ins de Python en vez de tu lógica

def test_sort_returns_list():
    result = my_sort([3, 1, 2])
    assert isinstance(result, list)  # Verifica que es lista

Eso verifica que Python retorna listas. No verifica que tu función ordene correctamente.

Acción: El assert debe verificar el orden: assert result == [1, 2, 3].


Cómo Mejorar Tests Generados por AI

Prompt de expansión: "¿Qué edge cases NO están cubiertos?"

Cuando tengas una suite que pasa, pregúntale a Claude Code:

Aquí están los tests existentes para [función]:
[pega los tests]

Y la implementación:
[pega la función]

¿Qué edge cases, boundary conditions o error paths NO están cubiertos
por estos tests? Genera únicamente los tests que faltan.

Claude Code suele identificar gaps (None, vacío, cero, límites) y generar tests adicionales dirigidos.

Prompt adversarial: "Intenta romper esta función"

Aquí está la implementación de [función]:
[pega la función]

Actúa como tester adversarial. Tu objetivo es encontrar inputs que:
1. Causen excepciones no manejadas
2. Produzcan resultados incorrectos silenciosamente
3. Exploten edge cases que la implementación podría manejar mal

Genera tests que intenten romper la función.

Este prompt produce tests que buscan bugs activamente.

Concepto de mutation testing: "Si cambio la línea X, ¿falla algún test?"

Para cada línea significativa de tu implementación, pregúntate: Si borro o modifico esta línea, ¿algún test falla?

Si la respuesta es no, esa línea no tiene coverage de comportamiento. Pytest-cov puede decir 100% de líneas ejecutadas, pero si ninguna aserción depende de esa línea, un bug allí pasaría desapercibido.

Ejemplo:

def apply_discount(price: float, discount_percent: float) -> float:
    if price < 0:
        raise ValueError("Price cannot be negative")
    if not 0 <= discount_percent <= 100:
        raise ValueError("Discount must be between 0 and 100")
    return round(price * (1 - discount_percent / 100), 2)  # Línea crítica

Si comentas la línea del return y pones return 0, ¿fallan los tests? Si tus tests solo verifican result >= 0 o isinstance(result, float), no fallarían. Necesitas al menos un test con assert result == 90.0 (o valor exacto) para que esa línea tenga coverage real.

Prompt dirigido: "Añade un test que falle si [X] se elimina"

La función apply_discount tiene esta línea que aplica el descuento:
    return round(price * (1 - discount_percent / 100), 2)

Añade un test que FALLE si alguien cambia o elimina esa fórmula.
El test debe verificar el valor exacto del resultado para al menos
dos combinaciones de price y discount_percent.

Este prompt fuerza tests con asserts fuertes sobre la lógica crítica.


El Mental Model: Mutation Testing

La pregunta clave por cada línea

Para cada línea de tu implementación:

Si elimino esta línea (o la cambio por algo incorrecto), ¿algún test falla?

  • Sí → Esa línea tiene coverage de comportamiento. Los tests la protegen.
  • No → Esa línea está ejecutada pero no verificada. Un bug ahí no sería detectado.

Coverage de línea vs coverage de comportamiento

MétricaQué mideLimitación
Line coverageLíneas ejecutadasNo dice si el resultado fue verificado
Behavior coverageLíneas que, si cambian, hacen fallar un testRequiere análisis manual o herramientas de mutation

Objetivo: Maximizar behavior coverage. Los tests deben ser tales que cualquier cambio que introduzca un bug cause al menos un fallo.

Ejercicio mental rápido

Toma una función de 10 líneas. Recorre cada línea:

  1. Línea de validación (if x < 0: raise): ¿Hay test con input inválido que use pytest.raises?
  2. Línea de cálculo: ¿Hay test que verifique el resultado numérico exacto?
  3. Línea de return: ¿Hay test que assert el valor retornado?

Si para alguna línea la respuesta es "no," tienes un gap. Añade el test que cubra esa línea.


Práctica: Evaluar y Mejorar una Suite Completa

Suite generada (versión inicial)

# text_utils.py
def truncate(text: str, max_length: int) -> str:
    """Truncate text to max_length, appending '...' if truncated."""
    if not isinstance(text, str):
        raise TypeError("text must be a string")
    if not isinstance(max_length, int):
        raise TypeError("max_length must be an integer")
    if max_length < 0:
        raise ValueError("max_length must be non-negative")
    if len(text) <= max_length:
        return text
    return text[:max_length - 3] + "..."
# test_text_utils_initial.py
import pytest
from text_utils import truncate


def test_truncate_returns_string():
    result = truncate("hello world", 5)
    assert isinstance(result, str)


def test_truncate_short_text():
    result = truncate("hi", 10)
    assert result is not None


def test_truncate_long_text():
    result = truncate("hello world", 5)
    assert len(result) <= 8  # 5 + 3 for "..."


def test_truncate_empty_string():
    result = truncate("", 5)
    assert result == ""


def test_truncate_exact_length():
    result = truncate("hello", 5)
    assert result == "hello"


def test_truncate_invalid_text_raises():
    with pytest.raises(TypeError):
        truncate(123, 5)

Evaluación con el checklist

TestComportamiento?Edge cases?Assert fuerte?Error path?¿Bug pasaría?
test_truncate_returns_stringParcialNo❌ DébilNo✅ Pasaría con "x"
test_truncate_short_textNoNo❌ DébilNo✅ Pasaría con "wrong"
test_truncate_long_textParcialNo❌ MedioNo✅ Pasaría con "he..." incorrecto
test_truncate_empty_stringSí✅ Vacío✅ FuerteNo❌
test_truncate_exact_lengthSí✅ Límite✅ FuerteNo❌
test_truncate_invalid_text_raisesSíNo✅✅❌

Problemas detectados:

  1. test_truncate_long_text no verifica el valor exacto. Implementación incorrecta return "wrong" pasaría si len("wrong") <= 8.
  2. No hay test que verifique que el truncado produce "he..." para "hello world" con max_length=5.
  3. No hay test para max_length=0, max_length negativo (error path), ni max_length no entero.

Suite mejorada

# test_text_utils_improved.py
import pytest
from text_utils import truncate


class TestTruncateHappyPath:
    def test_short_text_returned_unchanged(self):
        result = truncate("hi", 10)
        assert result == "hi"

    def test_exact_length_returned_unchanged(self):
        result = truncate("hello", 5)
        assert result == "hello"

    def test_long_text_truncated_with_ellipsis(self):
        result = truncate("hello world", 5)
        assert result == "he..."

    def test_truncation_boundary(self):
        result = truncate("abcdefgh", 5)
        assert result == "ab..."


class TestTruncateEdgeCases:
    def test_empty_string_returns_empty(self):
        result = truncate("", 5)
        assert result == ""

    def test_max_length_zero_returns_ellipsis_only(self):
        # "a" truncado a 0 sería "..."
        result = truncate("a", 3)
        assert result == "..."

    def test_unicode_text_truncates_correctly(self):
        result = truncate("café", 3)
        assert result == "caf..."


class TestTruncateErrorPaths:
    def test_none_text_raises_type_error(self):
        with pytest.raises(TypeError, match="text must be a string"):
            truncate(None, 5)

    def test_int_text_raises_type_error(self):
        with pytest.raises(TypeError, match="text must be a string"):
            truncate(123, 5)

    def test_negative_max_length_raises_value_error(self):
        with pytest.raises(ValueError, match="non-negative"):
            truncate("hello", -1)

    def test_float_max_length_raises_type_error(self):
        with pytest.raises(TypeError, match="integer"):
            truncate("hello", 5.0)

Mutación mental: Si cambias return text[:max_length - 3] + "..." por return text[:max_length], el test test_long_text_truncated_with_ellipsis falla. Esa línea tiene coverage de comportamiento.


Conexión con Proyecto

En el proyecto Unit test suite generada (cápsula 06), recibirás un módulo data_utils.py y usarás Claude Code para generar tests. La calificación no se basa en la cantidad de tests sino en la calidad.

Criterios de evaluación que aplicarás con este checklist:

  • ¿Los tests verifican comportamiento (valor exacto) o solo tipo/formato?
  • ¿Hay cobertura de edge cases (vacío, None, cero, límites)?
  • ¿Los asserts son fuertes (valor esperado) o débiles (is not None)?
  • ¿Se testean los error paths con pytest.raises?
  • ¿Una implementación con bug pasaría los tests? (prueba mental de mutación)

Antes de entregar, recorre el checklist para cada test. Refuerza los que fallen. El proyecto exige aplicar este skill de validación de forma explícita.


Troubleshooting

Problema 1: Todos los tests pasan al primer intento y no sé si son buenos

Causa: Puede ser código simple o tests triviales.

Solución: Usa el test de mutación manual. Modifica una línea crítica de la implementación (ej. cambia un * por +). Si todos los tests siguen pasando, son triviales. Añade tests con asserts fuertes sobre valores exactos para esa lógica.

Problema 2: Claude Code genera muchos tests con assert result is not None

Causa: Prompt genérico o la AI prioriza "que no falle" sobre "verificar valor exacto."

Solución: En el prompt, pide explícitamente: "Cada assert debe verificar el valor esperado exacto. No uses assert result is not None ni assert isinstance. Verifica el resultado con assert result == expected_value."

Problema 3: No sé qué edge cases pedir para mi dominio

Causa: Falta de experiencia en el dominio.

Solución: Usa el prompt de expansión: "¿Qué edge cases NO están cubiertos?" Claude Code suele proponer: vacío, None, cero, negativos, tipos incorrectos, valores muy grandes. Para dominios específicos, describe las reglas de negocio y pide: "Dados estos contratos, ¿qué inputs podrían violarlos o estar en el límite?"

Problema 4: Los tests verifican implementación y se rompen al refactorizar

Causa: Los tests acoplan a detalles internos (nombres de funciones, orden de llamadas).

Solución: Refactoriza los tests para verificar solo inputs y outputs. Si el test usa inspect.getsource o mockea funciones internas, replántalo como test de caja negra: dados estos inputs, el output debe ser este. El comportamiento es el contrato; la implementación puede cambiar.

Problema 5: Tengo 100% de coverage pero sé que hay código no verificado

Causa: Coverage de línea ≠ coverage de comportamiento. Las líneas se ejecutan pero ningún assert depende de su resultado.

Solución: Para las líneas dudosas, aplica la mutación mental: cambia la línea. Si ningún test falla, añade un test cuyo assert dependa directamente de esa línea. Alternativamente, usa herramientas de mutation testing (mutmut, cosmic-ray) que automatizan este proceso — se cubren en el Módulo 5.


Ejercicios

Ejercicio 1: Identificar asserts débiles (Fácil)

Estos tests tienen asserts débiles. Reescríbelos con asserts fuertes. La función es def count_words(text: str) -> int.

def test_count_words_returns_int():
    result = count_words("hello world")
    assert isinstance(result, int)

def test_count_words_not_empty():
    result = count_words("hello world")
    assert result > 0
Ver solución
def test_count_words_two_words_returns_two():
    result = count_words("hello world")
    assert result == 2

def test_count_words_single_word_returns_one():
    result = count_words("hello")
    assert result == 1

def test_count_words_empty_returns_zero():
    result = count_words("")
    assert result == 0

Explicación: Los asserts fuertes verifican el valor exacto. Si count_words retornara siempre 1 o 42, los tests nuevos fallarían. Los originales pasarían.

Ejercicio 2: El test de la función hardcodeada (Fácil)

Imagina que safe_divide(a: int, b: int) -> float retorna a / b o lanza si b == 0. Claude Code generó estos tests:

def test_safe_divide_returns_float():
    assert isinstance(safe_divide(10, 2), float)

def test_safe_divide_not_none():
    assert safe_divide(10, 2) is not None

def test_safe_divide_by_zero_raises():
    with pytest.raises(ZeroDivisionError):
        safe_divide(10, 0)

Implementa una versión incorrecta de safe_divide que retorne un valor hardcodeado (ej. siempre 5.0) y ejecuta los tests. ¿Cuántos pasan? Añade el test que fallaría.

Ver solución
# Implementación incorrecta
def safe_divide(a: int, b: int) -> float:
    if b == 0:
        raise ZeroDivisionError("division by zero")
    return 5.0  # Hardcodeado — incorrecto

Los tres tests pasan: isinstance(5.0, float) ✓, 5.0 is not None ✓, y el de zero sigue levantando.

Test que fallaría:

def test_safe_divide_returns_correct_quotient():
    result = safe_divide(10, 2)
    assert result == 5.0  # 10/2 = 5.0

def test_safe_divide_returns_correct_quotient_non_integer():
    result = safe_divide(7, 2)
    assert result == 3.5

Con la implementación hardcodeada, el segundo fallaría (3.5 != 5.0). El primero pasaría por casualidad (10/2 = 5.0).

Ejercicio 3: Aplicar el checklist (Medio)

Evalúa estos tests para def is_palindrome(s: str) -> bool usando el checklist de 5 puntos. Indica qué falta y escribe los tests adicionales necesarios.

def test_is_palindrome_returns_bool():
    result = is_palindrome("racecar")
    assert isinstance(result, bool)

def test_is_palindrome_known_true():
    assert is_palindrome("aba") == True

def test_is_palindrome_known_false():
    assert is_palindrome("abc") == False
Ver solución

Evaluación:

PuntoEvaluación
1. ComportamientoEl primero testea tipo; los otros dos sí testean comportamiento
2. Edge casesFaltan: string vacío, un solo carácter, espacios, mayúsculas/minúsculas
3. AssertPrimero débil; los otros fuertes
4. Error pathsFalta: None, int (si la función debe validar)
5. Implementación incorrectaEl primer test pasaría con return True siempre

Tests adicionales:

def test_empty_string_is_palindrome():
    assert is_palindrome("") == True  # Convención común

def test_single_char_is_palindrome():
    assert is_palindrome("a") == True

def test_palindrome_with_spaces_depends_on_spec():
    # Depende: ¿"a ba" es palíndromo? Normalmente se ignora espacios
    assert is_palindrome("race car") == True  # Si la spec dice que se ignoran espacios

def test_none_input_raises_or_returns_false():
    with pytest.raises(TypeError):
        is_palindrome(None)

Ejercicio 4: Mutación mental (Medio)

Para esta función, indica para cada línea si tiene coverage de comportamiento con los tests dados. Si no, escribe el test que la cubriría.

def min_of_three(a: int, b: int, c: int) -> int:
    if a < b:
        smallest = a
    else:
        smallest = b
    if c < smallest:
        smallest = c
    return smallest

Tests actuales:

def test_min_first():
    assert min_of_three(1, 2, 3) == 1

def test_min_second():
    assert min_of_three(2, 1, 3) == 1

def test_min_third():
    assert min_of_three(3, 2, 1) == 1
Ver solución

Análisis por rama:

  • Rama a < b (smallest = a): cubierta por test_min_first.
  • Rama else (smallest = b): cubierta por test_min_second.
  • Línea if c < smallest: en test_min_third, c=1 es el mínimo, así que sí se ejecuta y se verifica.
  • ¿Qué pasa si los tres son iguales? min_of_three(5, 5, 5) → no hay test. La rama c < smallest sería False (5 < 5 es False), así que smallest no se actualiza. Eso está cubierto implícitamente.

Gap potencial: min_of_three(2, 2, 1): a < b es False, smallest = 2, luego c < smallest (1 < 2) True, smallest = 1. Ese path sí está cubierto por test_min_third (3, 2, 1) que ejemplifica c como mínimo.

Test adicional útil (todos iguales):

def test_all_equal_returns_that_value():
    assert min_of_three(7, 7, 7) == 7

Este asegura que cuando ningún valor es menor, el resultado sigue siendo correcto. Los tres tests existentes cubren bien las ramas; este cubre el caso límite de igualdad.

Ejercicio 5: Prompt de expansión (Medio)

Tienes estos tests para def parse_age(age_str: str) -> int y sospechas que faltan edge cases. Escribe el prompt que darías a Claude Code para que genere los tests faltantes.

def test_parse_age_valid():
    assert parse_age("25") == 25

def test_parse_age_zero():
    assert parse_age("0") == 0
Ver solución
Aquí están los tests existentes para parse_age:

def test_parse_age_valid():
    assert parse_age("25") == 25

def test_parse_age_zero():
    assert parse_age("0") == 0

Y la implementación (o especificación): parse_age recibe un string que
representa una edad y retorna un int. Debe lanzar ValueError para
strings inválidos.

¿Qué edge cases, boundary conditions y error paths NO están cubiertos?
Genera tests adicionales para:
- String vacío
- String con espacios
- String no numérico ("abc", "25 years")
- Número negativo
- Número con decimales ("25.5")
- None (si aplica)
- Número muy grande
- Formato "  25  " con espacios alrededor

Solo genera los tests que faltan. Usa pytest.raises cuando corresponda.

Claude Code debería proponer tests para la mayoría de estos casos.

Ejercicio 6: Suite completa con checklist (Difícil)

Dado este módulo, genera una suite de tests con Claude Code. Luego evalúa cada test con el checklist de 5 puntos. Identifica los que fallen y mejóralos. Finalmente, aplica el test de mutación: cambia una línea crítica. ¿Algún test falla?

# validators.py
def validate_age(age: int) -> bool:
    """Return True if age is between 0 and 150 inclusive."""
    if not isinstance(age, int):
        raise TypeError("age must be an integer")
    if age < 0:
        return False
    if age > 150:
        return False
    return True
Ver solución

Suite generada (ejemplo):

import pytest
from validators import validate_age


def test_valid_age_returns_true():
    assert validate_age(25) == True

def test_zero_age_valid():
    assert validate_age(0) == True

def test_max_age_150_valid():
    assert validate_age(150) == True

def test_negative_age_invalid():
    assert validate_age(-1) == False

def test_over_150_invalid():
    assert validate_age(151) == False

def test_float_raises_type_error():
    with pytest.raises(TypeError, match="integer"):
        validate_age(25.5)

def test_none_raises_type_error():
    with pytest.raises(TypeError, match="integer"):
        validate_age(None)

Checklist:

  • Comportamiento: Sí, verifican True/False
  • Edge cases: 0, 150, -1, 151 cubiertos
  • Asserts fuertes: Sí, valores exactos
  • Error paths: TypeError cubierto
  • Mutación: Si cambias age > 150 por age >= 150, test_max_age_150_valid fallaría (150 pasaría a ser False). Si cambias return True por return False, la mayoría de los tests fallan. Cobertura de comportamiento adecuada.

Test adicional opcional (límite):

def test_boundary_149_valid():
    assert validate_age(149) == True

Mutación: Cambia if age > 150 por if age > 149. Entonces validate_age(150) retornaría False. El test test_max_age_150_valid falla. La suite detecta el bug.


Resumen

  • ✅ "50 tests pasan" no implica código correcto; importa qué comportamientos verifican
  • ✅ Coverage de líneas ejecutadas ≠ coverage de comportamiento verificado
  • ✅ Checklist de 5 puntos: comportamiento vs implementación, edge cases, assert fuerte, error paths, test de mutación (¿implementación incorrecta pasaría?)
  • ✅ Red flags: inputs siempre "amigables", asserts débiles (is not None, isinstance), llamadas sin assert, valor esperado calcado de la lógica, tests de built-ins
  • ✅ Prompts para mejorar: expansión (edge cases faltantes), adversarial (romper la función), dirigido (test que falle si se elimina X)
  • ✅ Mental model de mutation: si al cambiar/eliminar una línea ningún test falla, esa línea no tiene coverage de comportamiento
  • ✅ El proyecto de la cápsula 06 se evalúa por calidad de tests, no por cantidad; usa este checklist

Próxima cápsula: Proyecto — Unit test suite generada para un módulo de utilidades.


Recursos Adicionales

  1. Mutation Testing (Wikipedia) - Concepto y fundamentos
  2. mutmut: Python mutation testing - Herramienta para mutation testing en Python
  3. Test Quality - Martin Fowler - Pirámide de tests y calidad
  4. pytest: Good practices - Organización y buenas prácticas
  5. Google: Testing on the Toilet - Artículos breves sobre calidad de tests
  6. Hypothesis: Property-based testing - Generación automática de edge cases (Módulo 5)

Módulo 2, Cápsula 05 — Testing with Claude Code Guide Cantidad ≠ calidad: valida antes de confiar