LABS · JULIÁN LÓPEZ JIMÉNEZ · RECURSO GRATUITO
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.
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)
Prueba debajo del umbral. → Incluye exactamente 2000. → 1999→1999 y 2000→1800.
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.
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
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.')
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.
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)
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.
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.
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]
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.')
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.
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)
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.
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.
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]
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.')
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.
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.
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.
Repite con datos distintos antes de marcarlo como practicado. Esta es una autoevaluación, no una certificación.
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 temarioFuente: 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.