LABS · JULIÁN LÓPEZ JIMÉNEZ · RECURSO GRATUITO

Tus tests pasan, pero tu programa falla

Tres errores que sobreviven a un test débil: frontera, mutación y estado compartido. Escribe pruebas con pytest real, gratis y sin cuenta.

Qué necesitas: Funciones, listas, excepciones y assert básicos. Navegador con JavaScript y WebAssembly. Ordenador con teclado recomendado; carga inicial de Python y pytest desde Labs. Para repetir en local: Python3.12+ y pytest9.0.2; crea un entorno virtual y ejecuta python -m pip install pytest==9.0.2. El ZIP gratuito incluye los apoyos separados por reto. Tiempo editorial orientativo, pendiente de validar con alumnos.

Tu resultado: Una suite que distingue las versiones correctas de los fallos conocidos de cada contrato, también orden y repeticiones en la limpieza de listas.

Reto 1 · El descuento que escapa al test

assert cobrar(3000)==2800 pasa aunque el programa devuelva siempre 2800. El recurso comienza mostrando esa falsa seguridad; amplía los casos antes de corregir. Descuenta 200 céntimos desde 2000 inclusive; debajo conserva el importe. Una prueba debe distinguir la constante y el umbral exclusivo.

from objetivo import cobrar

def test_ejemplo():
    assert cobrar(3000) == 2800

if __name__ == '__main__':
    from laboratorio import evaluar, mostrar
    resultado = evaluar(__file__)
    mostrar(resultado)
Necesito una pista

Prueba debajo del umbral. → Incluye exactamente 2000. → 1999→1999 y 2000→1800.

Ver solución de este reto
from objetivo import cobrar
def test_cobro():
    assert cobrar(1999)==1999
    assert cobrar(2000)==1800
    assert cobrar(3000)==2800

if __name__ == '__main__':
    from laboratorio import evaluar, mostrar
    resultado = evaluar(__file__)
    mostrar(resultado)

Los tres esperados independientes distinguen la regla de un resultado memorizado.

Archivo de apoyo: objetivo.py

Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.

def cobrar(n):
    return n-200 if n>=2000 else n

Archivo de apoyo: laboratorio.py

Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.

"""Runner docente de suites y mutantes. No es una evaluación inviolable."""
from pathlib import Path
import contextlib,io,json,os,sys,tempfile,types,uuid

def evaluar(archivo):
    import pytest
    scenario=json.loads(Path('escenario.json').read_text(encoding='utf8'))
    source=Path(archivo).read_text(encoding='utf8')
    results=[]
    previous=sys.modules.get('objetivo')
    old_env=os.environ.get('PYTEST_DISABLE_PLUGIN_AUTOLOAD')
    os.environ['PYTEST_DISABLE_PLUGIN_AUTOLOAD']='1'
    class Reports:
        def __init__(self):self.passed=0;self.failed=0;self.skipped=0;self.errors=0
        def pytest_runtest_logreport(self,report):
            if report.skipped:self.skipped+=1
            if report.when=='call':
                self.passed+=int(report.passed);self.failed+=int(report.failed)
            elif report.failed:self.errors+=1
        def pytest_collectreport(self,report):self.errors+=int(report.failed)
    try:
        for label,implementation in [('correcta',scenario['reference'])]+list(scenario['mutants'].items()):
            if label!='correcta' and not results[0]['ok']:
                results.append(dict(version=label,ok=False,passed=0,failed=0,skipped=0,errors=0,exit_code=None,not_run=True,detail='Primero consigue que tu suite pase la versión correcta, sin errores ni saltos.'))
                continue
            module=types.ModuleType('objetivo');exec(implementation,module.__dict__);sys.modules['objetivo']=module
            reports=Reports();capture=io.StringIO()
            with tempfile.TemporaryDirectory(prefix='labs-suite-') as folder:
                root=Path(folder);test=root/('test_'+uuid.uuid4().hex+'.py');test.write_text(source,encoding='utf8')
                config=root/'pytest.ini';config.write_text('[pytest]\n',encoding='utf8')
                try:
                    with contextlib.redirect_stdout(capture),contextlib.redirect_stderr(capture):
                        status=int(pytest.main([str(test),'-q','--color=no','--tb=short','--capture=sys','--import-mode=importlib','-p','no:cacheprovider','-c',str(config)],plugins=[reports]))
                except BaseException as error:
                    status=3;capture.write(type(error).__name__+': '+str(error))
            valid=reports.errors==0 and reports.skipped==0
            ok=(valid and status==0 and reports.passed>0) if label=='correcta' else (valid and status==1 and reports.failed>0)
            results.append(dict(version=label,ok=ok,passed=reports.passed,failed=reports.failed,skipped=reports.skipped,errors=reports.errors,exit_code=status,not_run=False,detail=capture.getvalue()[-5000:]))
    finally:
        if previous is None:sys.modules.pop('objetivo',None)
        else:sys.modules['objetivo']=previous
        if old_env is None:os.environ.pop('PYTEST_DISABLE_PLUGIN_AUTOLOAD',None)
        else:os.environ['PYTEST_DISABLE_PLUGIN_AUTOLOAD']=old_env
    return results

def mostrar(results):
    for result in results:
        if result.get('not_run'):
            print('SIN EVALUAR · '+result['version']+' · corrige primero la versión correcta')
            continue
        if result['version']=='correcta':label='PASA' if result['ok'] else 'CORRIGE TU SUITE'
        else:label='DETECTADO' if result['ok'] else ('ESCAPA' if result['exit_code']==0 else 'REVISA EL DIAGNÓSTICO')
        print(label+' · '+result['version'])
        if not result['ok']:print(result['detail'][-1800:])
    print('No es una certificación: explica por qué cada caso distingue el contrato.')

Archivo de apoyo: escenario.json

Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.

{"reference": "def cobrar(n):\n    return n-200 if n>=2000 else n\n", "mutants": {"Frontera incorrecta": "def cobrar(n):\n    return n-200 if n>2000 else n\n", "Constante para el ejemplo": "def cobrar(n):\n    return 2800\n"}}

Comprueba: Usa un umbral diferente en tu copia local.

Reto 2 · La lista vuelve cambiada

Una salida correcta no garantiza que los datos del llamante sigan intactos. Observa también la entrada después de ejecutar. Quita espacios exteriores de todas las cadenas y conserva la lista original, el orden, las repeticiones y el número de filas.

import pytest
from objetivo import limpias

def test_contrato():
    # Escribe un caso con entrada y resultado esperado independientes.
    assert False, "Sustituye esta prueba pendiente"

if __name__ == '__main__':
    from laboratorio import evaluar, mostrar
    resultado = evaluar(__file__)
    mostrar(resultado)
Necesito una pista

Necesitas varias filas desordenadas y alguna repetida. → La entrada debe conservar sus espacios. → B,A,A debe seguir en ese orden y con las dos A; observa también la lista original.

Ver solución de este reto
from objetivo import limpias
def test_limpieza():
    xs=[" B "," A "," A "]
    assert limpias(xs)==["B","A","A"]
    assert xs==[" B "," A "," A "]
    assert limpias([])==[]

if __name__ == '__main__':
    from laboratorio import evaluar, mostrar
    resultado = evaluar(__file__)
    mostrar(resultado)

El esperado desordenado y repetido descubre ordenaciones y deduplicaciones no autorizadas, además de pérdida de filas y mutación.

Archivo de apoyo: objetivo.py

Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.

def limpias(xs):
    return [x.strip() for x in xs]

Archivo de apoyo: laboratorio.py

Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.

"""Runner docente de suites y mutantes. No es una evaluación inviolable."""
from pathlib import Path
import contextlib,io,json,os,sys,tempfile,types,uuid

def evaluar(archivo):
    import pytest
    scenario=json.loads(Path('escenario.json').read_text(encoding='utf8'))
    source=Path(archivo).read_text(encoding='utf8')
    results=[]
    previous=sys.modules.get('objetivo')
    old_env=os.environ.get('PYTEST_DISABLE_PLUGIN_AUTOLOAD')
    os.environ['PYTEST_DISABLE_PLUGIN_AUTOLOAD']='1'
    class Reports:
        def __init__(self):self.passed=0;self.failed=0;self.skipped=0;self.errors=0
        def pytest_runtest_logreport(self,report):
            if report.skipped:self.skipped+=1
            if report.when=='call':
                self.passed+=int(report.passed);self.failed+=int(report.failed)
            elif report.failed:self.errors+=1
        def pytest_collectreport(self,report):self.errors+=int(report.failed)
    try:
        for label,implementation in [('correcta',scenario['reference'])]+list(scenario['mutants'].items()):
            if label!='correcta' and not results[0]['ok']:
                results.append(dict(version=label,ok=False,passed=0,failed=0,skipped=0,errors=0,exit_code=None,not_run=True,detail='Primero consigue que tu suite pase la versión correcta, sin errores ni saltos.'))
                continue
            module=types.ModuleType('objetivo');exec(implementation,module.__dict__);sys.modules['objetivo']=module
            reports=Reports();capture=io.StringIO()
            with tempfile.TemporaryDirectory(prefix='labs-suite-') as folder:
                root=Path(folder);test=root/('test_'+uuid.uuid4().hex+'.py');test.write_text(source,encoding='utf8')
                config=root/'pytest.ini';config.write_text('[pytest]\n',encoding='utf8')
                try:
                    with contextlib.redirect_stdout(capture),contextlib.redirect_stderr(capture):
                        status=int(pytest.main([str(test),'-q','--color=no','--tb=short','--capture=sys','--import-mode=importlib','-p','no:cacheprovider','-c',str(config)],plugins=[reports]))
                except BaseException as error:
                    status=3;capture.write(type(error).__name__+': '+str(error))
            valid=reports.errors==0 and reports.skipped==0
            ok=(valid and status==0 and reports.passed>0) if label=='correcta' else (valid and status==1 and reports.failed>0)
            results.append(dict(version=label,ok=ok,passed=reports.passed,failed=reports.failed,skipped=reports.skipped,errors=reports.errors,exit_code=status,not_run=False,detail=capture.getvalue()[-5000:]))
    finally:
        if previous is None:sys.modules.pop('objetivo',None)
        else:sys.modules['objetivo']=previous
        if old_env is None:os.environ.pop('PYTEST_DISABLE_PLUGIN_AUTOLOAD',None)
        else:os.environ['PYTEST_DISABLE_PLUGIN_AUTOLOAD']=old_env
    return results

def mostrar(results):
    for result in results:
        if result.get('not_run'):
            print('SIN EVALUAR · '+result['version']+' · corrige primero la versión correcta')
            continue
        if result['version']=='correcta':label='PASA' if result['ok'] else 'CORRIGE TU SUITE'
        else:label='DETECTADO' if result['ok'] else ('ESCAPA' if result['exit_code']==0 else 'REVISA EL DIAGNÓSTICO')
        print(label+' · '+result['version'])
        if not result['ok']:print(result['detail'][-1800:])
    print('No es una certificación: explica por qué cada caso distingue el contrato.')

Archivo de apoyo: escenario.json

Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.

{"reference": "def limpias(xs):\n    return [x.strip() for x in xs]\n", "mutants": {"Modifica original": "def limpias(xs):\n    xs[:]=[x.strip() for x in xs]\n    return xs\n", "Pierde filas": "def limpias(xs):\n    return [x.strip() for x in xs[:1]]\n", "Ordena sin permiso": "def limpias(xs):\n    return sorted(x.strip() for x in xs)\n", "Elimina duplicados": "def limpias(xs):\n    return list(dict.fromkeys(x.strip() for x in xs))\n"}}

Comprueba: Añade una cadena vacía y conserva las repeticiones.

Reto 3 · Funciona solo la primera vez

Una aplicación puede acumular estado entre llamadas aunque cada test aislado parezca funcionar. Reproduce varias llamadas dentro de la misma prueba. Cada llamada devuelve solo el valor recibido, sin arrastrar anteriores. Demuestra tres llamadas distintas.

import pytest
from objetivo import nueva_cesta

def test_contrato():
    # Escribe un caso con entrada y resultado esperado independientes.
    assert False, "Sustituye esta prueba pendiente"

if __name__ == '__main__':
    from laboratorio import evaluar, mostrar
    resultado = evaluar(__file__)
    mostrar(resultado)
Necesito una pista

No reinicies el módulo entre llamadas del mismo test. → La segunda no debe contener A. → Comprueba A, B y C en una misma prueba.

Ver solución de este reto
from objetivo import nueva_cesta
def test_repetir():
    assert nueva_cesta("A")==["A"]
    assert nueva_cesta("B")==["B"]
    assert nueva_cesta("C")==["C"]

if __name__ == '__main__':
    from laboratorio import evaluar, mostrar
    resultado = evaluar(__file__)
    mostrar(resultado)

La secuencia reproduce un fallo que un único ejemplo no ve. No depende del orden de otros tests.

Archivo de apoyo: objetivo.py

Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.

def nueva_cesta(valor):
    return [valor]

Archivo de apoyo: laboratorio.py

Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.

"""Runner docente de suites y mutantes. No es una evaluación inviolable."""
from pathlib import Path
import contextlib,io,json,os,sys,tempfile,types,uuid

def evaluar(archivo):
    import pytest
    scenario=json.loads(Path('escenario.json').read_text(encoding='utf8'))
    source=Path(archivo).read_text(encoding='utf8')
    results=[]
    previous=sys.modules.get('objetivo')
    old_env=os.environ.get('PYTEST_DISABLE_PLUGIN_AUTOLOAD')
    os.environ['PYTEST_DISABLE_PLUGIN_AUTOLOAD']='1'
    class Reports:
        def __init__(self):self.passed=0;self.failed=0;self.skipped=0;self.errors=0
        def pytest_runtest_logreport(self,report):
            if report.skipped:self.skipped+=1
            if report.when=='call':
                self.passed+=int(report.passed);self.failed+=int(report.failed)
            elif report.failed:self.errors+=1
        def pytest_collectreport(self,report):self.errors+=int(report.failed)
    try:
        for label,implementation in [('correcta',scenario['reference'])]+list(scenario['mutants'].items()):
            if label!='correcta' and not results[0]['ok']:
                results.append(dict(version=label,ok=False,passed=0,failed=0,skipped=0,errors=0,exit_code=None,not_run=True,detail='Primero consigue que tu suite pase la versión correcta, sin errores ni saltos.'))
                continue
            module=types.ModuleType('objetivo');exec(implementation,module.__dict__);sys.modules['objetivo']=module
            reports=Reports();capture=io.StringIO()
            with tempfile.TemporaryDirectory(prefix='labs-suite-') as folder:
                root=Path(folder);test=root/('test_'+uuid.uuid4().hex+'.py');test.write_text(source,encoding='utf8')
                config=root/'pytest.ini';config.write_text('[pytest]\n',encoding='utf8')
                try:
                    with contextlib.redirect_stdout(capture),contextlib.redirect_stderr(capture):
                        status=int(pytest.main([str(test),'-q','--color=no','--tb=short','--capture=sys','--import-mode=importlib','-p','no:cacheprovider','-c',str(config)],plugins=[reports]))
                except BaseException as error:
                    status=3;capture.write(type(error).__name__+': '+str(error))
            valid=reports.errors==0 and reports.skipped==0
            ok=(valid and status==0 and reports.passed>0) if label=='correcta' else (valid and status==1 and reports.failed>0)
            results.append(dict(version=label,ok=ok,passed=reports.passed,failed=reports.failed,skipped=reports.skipped,errors=reports.errors,exit_code=status,not_run=False,detail=capture.getvalue()[-5000:]))
    finally:
        if previous is None:sys.modules.pop('objetivo',None)
        else:sys.modules['objetivo']=previous
        if old_env is None:os.environ.pop('PYTEST_DISABLE_PLUGIN_AUTOLOAD',None)
        else:os.environ['PYTEST_DISABLE_PLUGIN_AUTOLOAD']=old_env
    return results

def mostrar(results):
    for result in results:
        if result.get('not_run'):
            print('SIN EVALUAR · '+result['version']+' · corrige primero la versión correcta')
            continue
        if result['version']=='correcta':label='PASA' if result['ok'] else 'CORRIGE TU SUITE'
        else:label='DETECTADO' if result['ok'] else ('ESCAPA' if result['exit_code']==0 else 'REVISA EL DIAGNÓSTICO')
        print(label+' · '+result['version'])
        if not result['ok']:print(result['detail'][-1800:])
    print('No es una certificación: explica por qué cada caso distingue el contrato.')

Archivo de apoyo: escenario.json

Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.

{"reference": "def nueva_cesta(valor):\n    return [valor]\n", "mutants": {"Comparte estado": "_cesta=[]\ndef nueva_cesta(valor):\n    _cesta.append(valor)\n    return list(_cesta)\n", "Ignora valor": "def nueva_cesta(valor):\n    return [\"A\"]\n"}}

Comprueba: Repite con cadenas vacías y duplicadas.

Tu turno

Detecta todos los fallos y explica qué caso distingue cada uno. Repite en local: pytest contra una versión defectuosa, repara objetivo.py y vuelve a ejecutar.

Ver solución y explicación
from objetivo import nueva_cesta
def test_repetir():
    assert nueva_cesta("A")==["A"]
    assert nueva_cesta("B")==["B"]
    assert nueva_cesta("C")==["C"]

if __name__ == '__main__':
    from laboratorio import evaluar, mostrar
    resultado = evaluar(__file__)
    mostrar(resultado)

Python y pytest reales en un worker aislado, sin red ni disco del equipo. No se invocan servicios externos. El ZIP de la guía incluye los apoyos y un laboratorio independiente. Guardar es manual: código y selección, no resultados ni archivos. Detectar estos mutantes no demuestra ausencia de otros errores.

Explícalo con tus palabras

Repite con datos distintos antes de marcarlo como practicado. Esta es una autoevaluación, no una certificación.

Si quieres continuar

El curso amplía a fixtures, archivos, tiempo, dobles, SQLite, rollback, contratos y un proyecto reparado con evidencias.

Ver el curso Tu proyecto Python a prueba de fallos y su temario

Fuente: Recurso original de Labs. Puedes guardar este HTML, abrirlo sin conexión o imprimirlo como PDF desde tu navegador. No guarda tus respuestas ni el progreso de tu cuenta.