Fixtures integradas — tmp_path, capsys y caplog
Las fixtures que incluye pytest para las partes difíciles de probar: archivos, salida impresa y logs. Prueba código que escribe CSV y JSON sin tocar rutas reales, y comprueba lo que imprime y registra.
¿Cómo quieres aprender?
El problema que vamos a resolver
Aquí tienes una función que escribe un archivo CSV, y un test para ella:
import csv
def write_csv(path, rows):
with open(path, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["product", "quantity"])
writer.writerows(rows)
def test_write_csv():
write_csv("report.csv", [("pen", 12), ("bag", 2)])
with open("report.csv") as f:
assert f.read().splitlines()[1] == "pen,12"$ pytest -q
. [100%]
1 passed in 0.08s
$ ls
report.csv test_report.pyEl test pasó y dejó algo atrás. Ahora report.csv se encuentra en tu proyecto, junto a tu código. Ejecuta pytest desde otra carpeta y el archivo irá a parar allí, porque "report.csv" es relativo a dondequiera que estés situado. Dos tests que escriban report.csv leerán los restos del otro. Y si tu proyecto ya tenía un report.csv real, el test acaba de sobrescribirlo.
Un test debería dejar el mundo exactamente como lo encontró. Los archivos son el caso más evidente, pero la salida impresa y los mensajes de log plantean la misma cuestión desde el otro lado: la función envía algo hacia afuera, y el test necesita atraparlo.
Los dos últimos capítulos te enseñaron a escribir fixtures. Este trata sobre las fixtures que pytest ya incluye para estas partes difíciles: solo tienes que pedirlas por su nombre.
Al terminar este capítulo podrás
- Darle a cada test su propia carpeta vacía con
tmp_pathy probar funciones que escriben CSV o JSON - Compartir una carpeta temporal a lo largo de toda una sesión con
tmp_path_factory - Probar lo que imprime un programa con
capsysy saber cuándo se necesitacapfden su lugar - Probar lo que registra un programa en el log con
caplog, incluidos los mensajes por debajo deWARNING - Usar
request.node.namepara averiguar qué test se está ejecutando - Interpretar los errores que producen estas fixtures cuando se usan de forma incorrecta
Requisitos previos: alcance de fixtures y conftest.py.
Antes de escribir el test
El código que toca archivos, la terminal o el log tiene efectos secundarios (side effects): su resultado real es algo que deja en el mundo, no solo lo que devuelve. Así que, antes de escribir nada de código de test, anota a dónde va a parar cada efecto.
1. Nombra cada salida. Para una función que exporta filas de inventario, hay hasta cuatro:
- el valor de retorno (la ruta que escribió)
- el contenido del archivo
- lo que imprime, dividido en salida estándar y error estándar
- lo que registra en el log y en qué nivel
Cada una es una promesa distinta y cada una necesita una forma de ser observada. El valor de retorno lo obtienes sin esfuerzo. Para las otras tres, pytest tiene una fixture: tmp_path para archivos, capsys para texto impreso y caplog para logs.
2. Asegúrate de que el código permita que el test elija a dónde van los archivos. Esta es la primera pregunta que debes hacerte, porque si la respuesta es no, ninguna fixture te salvará. Una función que construye su propia ruta —open("/var/reports/stock.csv", "w")— solo se puede probar escribiendo en ese lugar real. A una función que toma la carpeta o la ruta como parámetro se le puede indicar tmp_path. Si el código es tuyo, cámbialo para que reciba la ruta. (Cuando no puedas cambiarlo, monkeypatch, en el siguiente capítulo, es la vía de escape).
3. Comprueba tu entorno. No hay nada que instalar: todas las fixtures de este capítulo vienen incluidas con pytest. Necesitas lo habitual: un entorno virtual con pytest instalado y que el código sea importable desde el archivo de test. Ten claro el nombre de tu logger (logging.getLogger("export")), ya que caplog.set_level puede dirigirse a él.
4. Planifica los casos. Para export_stock(rows, out_dir), que escribe filas válidas en stock.csv, advierte sobre cantidades negativas e imprime un resumen:
| Caso | Entrada | Esperado | |---|---|---| | Camino feliz (happy path) | [("pen", 12), ("eraser", 30)], tmp_path | el archivo tiene encabezado + 2 filas; imprime wrote 2 rows to stock.csv | | Fila no válida | una fila con cantidad -1 | fila excluida; un WARNING nombrándola | | Carpeta inexistente | tmp_path / "exports" / "2026" | carpetas creadas; archivo en su interior | | Nada válido | [("bag", -1)] | solo encabezado; nothing exported en el error estándar | | Salida limpia | cualquier fila válida | nada en el error estándar |
5. Decide qué no probar. No pruebes que el módulo csv de Python entrecomilla las comas correctamente ni que json.dumps produce JSON válido: esas son promesas de otros, ya probadas. No hagas asserts sobre todo el caplog.text, que incluye nombres de archivo y números de línea que cambian cada vez que editas el código. Y no pruebes la ubicación exacta de tmp_path: es diferente en cada máquina. Prueba tus decisiones: qué filas se conservan, qué va a dónde y qué dicen los mensajes.
El resto del capítulo te da las herramientas para cada columna de esa tabla, y el ejemplo completo la implementa.
tmp_path — una carpeta nueva para cada test
Pide tmp_path y pytest le entregará a tu test un directorio que no existía hace un instante:
def test_first(tmp_path):
print(tmp_path)
print(type(tmp_path))
def test_second(tmp_path):
print(tmp_path)$ pytest -q -s
/tmp/pytest-of-you/pytest-0/test_first0
<class 'pathlib.PosixPath'>
./tmp/pytest-of-you/pytest-0/test_second0
.
2 passed in 0.07s(-s desactiva la captura de salida propia de pytest para que las llamadas a print lleguen a la terminal. En Windows, la ruta comienza en algún lugar dentro de la carpeta Temp de tu usuario y el tipo es WindowsPath).
Tres cosas que observar en esa salida:
- Es un
pathlib.Path, no una cadena. Unes rutas con/, lees con.read_text()y compruebas con.exists(). - Cada test obtiene su propia carpeta, nombrada según el test.
test_first0ytest_second0nunca coinciden, por lo que ambos pueden escribir un archivo llamadoreport.csv. - Cada ejecución obtiene una carpeta base nueva:
pytest-0, luegopytest-1, y así sucesivamente. pytest conserva las carpetas de las tres últimas ejecuciones para que puedas mirar dentro tras un fallo, y borra las más antiguas por sí mismo. Tú nunca tienes que limpiar.
Probar una función que escribe un archivo
Ahora el test de CSV otra vez, bien hecho —más un par de funciones de JSON, ya que guardar la configuración es la otra tarea de escritura de archivos que casi todo programa tiene—:
import csv
import json
def write_csv(path, rows):
with open(path, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["product", "quantity"])
writer.writerows(rows)
def save_settings(path, settings):
path.write_text(json.dumps(settings, indent=2))
def load_settings(path):
return json.loads(path.read_text())
def test_write_csv(tmp_path):
target = tmp_path / "report.csv"
write_csv(target, [("pen", 12), ("bag", 2)])
assert target.exists()
assert target.read_text().splitlines() == [
"product,quantity",
"pen,12",
"bag,2",
]
def test_csv_reads_back(tmp_path):
target = tmp_path / "report.csv"
write_csv(target, [("pen", 12)])
with target.open(newline="") as f:
rows = list(csv.DictReader(f))
assert rows == [{"product": "pen", "quantity": "12"}]
def test_settings_round_trip(tmp_path):
target = tmp_path / "settings.json"
settings = {"currency": "BDT", "tax": 0.15}
save_settings(target, settings)
assert load_settings(target) == settings
assert '"currency": "BDT"' in target.read_text()$ pytest -q
... [100%]
3 passed in 0.07s
$ ls
test_files.pyEsta vez no queda nada atrás. El patrón es siempre el mismo: construye una ruta dentro de tmp_path, pásasela al código bajo prueba, luego vuelve a leer el archivo y compruébalo.
Hay dos formas de comprobarlo, y los tests anteriores usan ambas. test_write_csv comprueba el texto exacto —el test más estricto, y el adecuado cuando otro programa vaya a leer el archivo—. test_csv_reads_back y test_settings_round_trip vuelven a leer el archivo de la forma en que lo haría un lector real y comparan los datos. Observa que DictReader devolvió "12", una cadena: CSV no tiene números, solo texto. Un test de ida y vuelta (round-trip) es donde este tipo de sorpresas sale a la luz.
tmp_path empieza vacío. Si tu código espera que exista una subcarpeta, créala en el test con (tmp_path / "exports").mkdir() o —mejor— haz que el propio código la cree. Verás qué ocurre cuando nadie lo hace en «Cuando algo falla».
tmp_path_factory — una carpeta para toda la sesión
tmp_path tiene alcance de función: una carpeta nueva para cada test. A veces eso es un desperdicio. Imagina que varios tests leen un archivo de muestra grande que tarda un tiempo en construirse. En el capítulo anterior recurrías a scope="session", pero una fixture de sesión no puede usar tmp_path, porque una fixture que dura toda la sesión no puede depender de algo que se desecha tras cada test.
Para eso existe tmp_path_factory, que a su vez tiene alcance de sesión. Le solicitas carpetas con mktemp:
import pytest
@pytest.fixture(scope="session")
def big_catalogue(tmp_path_factory):
folder = tmp_path_factory.mktemp("data")
target = folder / "catalogue.csv"
lines = ["product,quantity"]
lines += [f"item{i},{i}" for i in range(10_000)]
target.write_text("\n".join(lines))
print("built", target)
return target
def test_has_header(big_catalogue):
assert big_catalogue.read_text().startswith("product,quantity")
def test_has_every_row(big_catalogue):
assert len(big_catalogue.read_text().splitlines()) == 10_001$ pytest -q -s
built /tmp/pytest-of-you/pytest-6/data0/catalogue.csv
..
2 passed in 0.07sbuilt aparece una sola vez, para dos tests. mktemp("data") añade un número al nombre —data0, y data1 si lo llamas de nuevo—, de modo que dos llamadas nunca colisionan.
La contrapartida es la del capítulo anterior: ambos tests ahora comparten un único archivo. Eso está bien aquí porque solo lo leen. Un test que modifique un archivo compartido necesita su propia copia en tmp_path.
capsys — probar lo que imprime un programa
Empecemos por el error que casi todo el mundo comete alguna vez. Una función imprime un saludo; el test comprueba su resultado:
def greet(name):
print(f"hello, {name}")
def test_greet():
assert greet("Rina") == "hello, Rina"$ pytest -q
F [100%]
=================================== FAILURES ===================================
__________________________________ test_greet __________________________________
def test_greet():
> assert greet("Rina") == "hello, Rina"
E AssertionError: assert None == 'hello, Rina'
E + where None = greet('Rina')
test_wrong.py:6: AssertionError
----------------------------- Captured stdout call -----------------------------
hello, Rina
=========================== short test summary info ============================
FAILED test_wrong.py::test_greet - AssertionError: assert None == 'hello, Rina'
1 failed in 0.09sprint envía texto; no lo devuelve, de modo que greet devuelve None. Sin embargo, mira al final del informe: Captured stdout call — pytest ya capturó el texto. Hace eso en cada test para mantener limpia la terminal. capsys es la forma de tener acceso a él:
def greet(name):
print(f"hello, {name}")
def test_greet(capsys):
greet("Rina")
assert capsys.readouterr().out == "hello, Rina\n"$ pytest -q
. [100%]
1 passed in 0.07sLlama primero a la función y luego lee lo que imprimió. Los programas reales de línea de comandos imprimen más de una línea, en dos flujos (streams), y devuelven un código de salida. Aquí tienes uno, con tests:
import sys
def main(argv):
if not argv:
print("usage: stock ITEM QUANTITY", file=sys.stderr)
return 2
item, quantity = argv[0], int(argv[1])
print(f"{item}: {quantity} in stock")
if quantity < 5:
print("low stock")
return 0
def test_prints_the_stock(capsys):
code = main(["pen", "12"])
captured = capsys.readouterr()
assert code == 0
assert captured.out == "pen: 12 in stock\n"
assert captured.err == ""
def test_warns_when_low(capsys):
main(["bag", "2"])
out = capsys.readouterr().out
assert out.splitlines() == ["bag: 2 in stock", "low stock"]
def test_usage_goes_to_stderr(capsys):
code = main([])
captured = capsys.readouterr()
assert code == 2
assert captured.out == ""
assert "usage:" in captured.err$ pytest -q
... [100%]
3 passed in 0.08scapsys.readouterr() devuelve todo lo escrito en la salida estándar y en el error estándar desde la última vez que preguntaste, como un objeto con dos campos de tipo cadena: .out y .err. Es una tupla con nombre (named tuple), por lo que out, err = capsys.readouterr() también funciona.
Dos detalles que importan en la práctica:
printañade un salto de línea. El texto capturado es"pen: 12 in stock\n", no"pen: 12 in stock". Para varias líneas,.splitlines()proporciona una lista limpia con la que comparar.- Los errores van a
err. Un mensaje de uso pertenece al error estándar, ytest_usage_goes_to_stderrcomprueba tanto que fue allí como que nada fue a la salida estándar.
«Desde la última vez que preguntaste» es literal: la lectura vacía el búfer:
def test_read_twice(capsys):
print("first")
one = capsys.readouterr()
print("second")
two = capsys.readouterr()
assert one.out == "first\n"
assert two.out == "second\n"$ pytest -q
. [100%]
1 passed in 0.07sEso es útil: puedes comprobar la salida paso a paso. También es el motivo por el cual un segundo readouterr() a veces vuelve vacío cuando no lo esperabas.
capfd — cuando la salida no pasa por Python
capsys sustituye sys.stdout y sys.stderr de Python. Cualquier cosa que escriba en la salida del sistema operativo directamente —un proceso hijo, una extensión en C— la esquiva. capfd captura un nivel más abajo, en el descriptor de archivo, y atrapa eso también:
import os
def test_with_capsys(capsys):
os.system("echo from the shell")
assert capsys.readouterr().out == ""
def test_with_capfd(capfd):
os.system("echo from the shell")
assert capfd.readouterr().out == "from the shell\n"$ pytest -q
.. [100%]
2 passed in 0.07sMismo readouterr(), mismos .out y .err. Usa capsys por defecto y cambia a capfd solo cuando falte salida proveniente de fuera de Python.
caplog — probar lo que registra un programa en el log
Los programas reales usan logging en lugar de imprimir. Una advertencia en el log es un comportamiento como cualquier otro, por lo que merece un test:
import logging
logger = logging.getLogger("importer")
def parse_quantities(lines):
result = {}
for line in lines:
item, _, raw = line.partition(",")
if not raw.strip().isdigit():
logger.warning("skipped %r: bad quantity", item)
continue
result[item] = int(raw)
logger.info("parsed %d of %d lines", len(result), len(lines))
return result
def test_bad_row_is_logged(caplog):
result = parse_quantities(["pen,12", "bag,two"])
assert result == {"pen": 12}
assert len(caplog.records) == 1
record = caplog.records[0]
assert record.levelname == "WARNING"
assert record.getMessage() == "skipped 'bag': bad quantity"
def test_text_is_one_string(caplog):
parse_quantities(["bag,two"])
print(repr(caplog.text))
assert "bad quantity" in caplog.text$ pytest -q -s
."WARNING importer:test_importer.py:11 skipped 'bag': bad quantity\n"
.
2 passed in 0.07scaplog te ofrece el mismo log de tres formas:
caplog.records: una lista de objetoslogging.LogRecord, uno por mensaje. Cada uno tiene.levelname("WARNING"),.name(el logger,"importer") y.getMessage(), que rellena el%rcon su valor.caplog.messages: solo las cadenas de texto con los mensajes terminados, en forma de lista.caplog.text: todo junto en una sola cadena con formato. Adecuado para una comprobación rápida conin; demasiado impreciso para algo exacto.
Prefiere records cuando el nivel importe y messages cuando solo importe la redacción del mensaje.
Los mensajes por debajo de WARNING no se capturan por defecto
parse_quantities también registra un resumen con nivel INFO. Añadamos un test para él:
def test_summary_is_logged(caplog):
parse_quantities(["pen,12"])
assert caplog.messages == ["parsed 1 of 1 lines"]$ pytest -q -k summary
F [100%]
=================================== FAILURES ===================================
____________________________ test_summary_is_logged ____________________________
caplog = <_pytest.logging.LogCaptureFixture object at 0x70495b4f43e0>
def test_summary_is_logged(caplog):
parse_quantities(["pen,12"])
> assert caplog.messages == ["parsed 1 of 1 lines"]
E AssertionError: assert [] == ['parsed 1 of 1 lines']
E
E Right contains one more item: 'parsed 1 of 1 lines'
E Use -v to get more diff
test_importer.py:38: AssertionError
=========================== short test summary info ============================
FAILED test_importer.py::test_summary_is_logged - AssertionError: assert [] =...
1 failed, 2 deselected in 0.08sNo se capturó nada. La función sí escribió en el log, pero el logging de Python empieza en WARNING, así que el mensaje INFO se descartó antes de que caplog llegara a verlo. Reduce el nivel para el test con caplog.set_level:
import logging
def test_summary_is_logged(caplog):
caplog.set_level(logging.INFO)
parse_quantities(["pen,12"])
assert caplog.messages == ["parsed 1 of 1 lines"]
def test_only_the_importer_logger(caplog):
caplog.set_level(logging.INFO, logger="importer")
parse_quantities(["pen,12", "bag,two"])
assert [r.levelname for r in caplog.records] == ["WARNING", "INFO"]$ pytest -q
.. [100%]
2 passed in 0.07sLlama a set_level antes del código que escribe en el log. La segunda variante, con logger="importer", reduce el nivel únicamente para ese logger, de modo que las bibliotecas ruidosas permanezcan en silencio. De cualquier forma, pytest restablece el nivel anterior cuando el test termina.
request y pytestconfig
Una fixture integrada más aparece dentro de otras fixtures. request describe el test que solicitó la fixture; request.node.name es el nombre de ese test. Resulta práctico para dar a los archivos nombres legibles y únicos:
import pytest
@pytest.fixture
def export_file(tmp_path, request):
return tmp_path / f"{request.node.name}.csv"
def test_pens(export_file):
print(export_file.name)
@pytest.mark.parametrize("quantity", [1, 50])
def test_bulk(export_file, quantity):
print(export_file.name)
def test_config(pytestconfig):
print(pytestconfig.rootpath.name, pytestconfig.getoption("verbose"))$ pytest -q -s
test_pens.csv
.test_bulk[1].csv
.test_bulk[50].csv
.shop -1
.
4 passed in 0.07sEl nombre de un test parametrizado incluye sus parámetros entre corchetes. Y el último test muestra pytestconfig: el objeto de configuración de la ejecución, con la carpeta raíz del proyecto y cada opción de la línea de comandos (-q establece la verbosidad en -1). Te lo volverás a encontrar en los capítulos sobre configuración y plugins.
Un ejemplo completo
export.py escribe filas válidas de inventario en un CSV, registra una advertencia en el log por cada fila que omite e imprime un resumen:
import csv
import logging
import sys
logger = logging.getLogger("export")
def export_stock(rows, out_dir):
"""Write the valid rows to out_dir/stock.csv and return its path."""
out_dir.mkdir(parents=True, exist_ok=True)
target = out_dir / "stock.csv"
written = 0
with target.open("w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["product", "quantity"])
for product, quantity in rows:
if quantity < 0:
logger.warning("skipped %s: negative quantity %d", product, quantity)
continue
writer.writerow([product, quantity])
written += 1
print(f"wrote {written} rows to {target.name}")
if written == 0:
print("nothing exported", file=sys.stderr)
return targettest_export.py comprueba sus tres salidas: el archivo, el log y la terminal:
import csv
from export import export_stock
ROWS = [("pen", 12), ("bag", -1), ("eraser", 30)]
def read_rows(path):
with path.open(newline="") as f:
return list(csv.reader(f))
def test_writes_only_valid_rows(tmp_path):
target = export_stock(ROWS, tmp_path)
assert target == tmp_path / "stock.csv"
assert read_rows(target) == [
["product", "quantity"],
["pen", "12"],
["eraser", "30"],
]
def test_creates_missing_folders(tmp_path):
out_dir = tmp_path / "exports" / "2026"
target = export_stock(ROWS, out_dir)
assert target.parent == out_dir
assert target.exists()
def test_skipped_row_is_logged(tmp_path, caplog):
export_stock(ROWS, tmp_path)
assert [r.levelname for r in caplog.records] == ["WARNING"]
assert caplog.messages == ["skipped bag: negative quantity -1"]
def test_prints_a_summary(tmp_path, capsys):
export_stock(ROWS, tmp_path)
captured = capsys.readouterr()
assert captured.out == "wrote 2 rows to stock.csv\n"
assert captured.err == ""
def test_empty_export_warns_on_stderr(tmp_path, capsys):
export_stock([("bag", -1)], tmp_path)
out, err = capsys.readouterr()
assert out == "wrote 0 rows to stock.csv\n"
assert err == "nothing exported\n"$ pytest -v
collecting ... collected 5 items
test_export.py::test_writes_only_valid_rows PASSED [ 20%]
test_export.py::test_creates_missing_folders PASSED [ 40%]
test_export.py::test_skipped_row_is_logged PASSED [ 60%]
test_export.py::test_prints_a_summary PASSED [ 80%]
test_export.py::test_empty_export_warns_on_stderr PASSED [100%]
============================== 5 passed in 0.09s ===============================
$ ls
export.py test_export.pyVale la pena señalar tres cosas.
En primer lugar, export_stock toma la carpeta como un parámetro. Esa sola decisión es lo que la hace fácil de probar: el programa le pasa una carpeta real y los tests le pasan tmp_path. Una función que codifica de forma fija "/var/reports/stock.csv" en su interior solo se puede probar escribiendo en esa ruta real —que es exactamente lo que un test nunca debe hacer—.
En segundo lugar, cada test solicita únicamente las fixtures que comprueba. Dos de ellos combinan tmp_path con capsys o caplog; las fixtures integradas se combinan libremente entre sí y con las tuyas propias.
En tercer lugar, el directorio de trabajo permanece intacto tras cinco tests que escribieron archivos.
Cuando algo falla
fixture 'tmp_dir' not found Una errata en el nombre del parámetro. pytest hace coincidir las fixtures por su nombre exacto, y el error continúa imprimiendo available fixtures: con todas las que conoce: tmp_path, tmp_path_factory, capsys, capfd, caplog y las demás. Copia el nombre correcto de esa lista. (También verás tmpdir allí; es la versión anterior que devuelve un objeto py.path en lugar de un pathlib.Path. Usa tmp_path en código nuevo).
ScopeMismatch: You tried to access the function scoped fixture tmp_path with a session scoped request object. Una fixture con scope="session" (o "module") solicitó tmp_path. Una fixture de larga duración no puede depender de una que es por test. Usa tmp_path_factory.mktemp("name") en su lugar.
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/pytest-of-you/pytest-3/test_nested0/exports/report.csv' tmp_path existe, pero la carpeta exports en su interior no: nada crea carpetas por ti. Llama primero a .mkdir(parents=True, exist_ok=True) en la carpeta padre, idealmente dentro del código bajo prueba, tal como hace export_stock.
Aparece un archivo en /tmp/pytest-of-you/pytest-5/test_str0report.csv No hay ningún error: la ruta se construyó con cadenas: str(tmp_path) + "report.csv". Faltó el separador, por lo que el archivo terminó junto a la carpeta del test en lugar de dentro de ella. Une con / sobre el propio objeto Path: tmp_path / "report.csv".
AssertionError: assert [] == ['parsed 1 of 1 lines'] con caplog El mensaje se registró por debajo de WARNING y nunca se capturó. Llama a caplog.set_level(logging.INFO) —o logging.DEBUG— antes de que se ejecute el código.
capsys.readouterr().out es "" cuando esperabas texto O bien la salida ya fue consumida por un readouterr() anterior en el mismo test, o no pasó a través del sys.stdout de Python (una llamada a un subproceso o a os.system). En el segundo caso, cambia a capfd.
cannot use capfd and capsys at the same time Un test solicitó ambas. Capturan los mismos flujos a diferentes niveles; elige una.
Paso 4 de 6 — Predice
Comprueba lo que has entendido
Tras la última línea, ¿qué hay exactamente en captured.out?
def test_twice(capsys):
print("first")
capsys.readouterr()
print("second")
print("third")
captured = capsys.readouterr()- A'second\nthird\n'
- B'third\n'
- C'first\nsecond\nthird\n'
- D''
restock sí escribe en el log, pero el test falla con assert [] == ['restocked pen']. ¿Por qué?
import logging
logger = logging.getLogger("shop")
def restock(item):
logger.info("restocked %s", item)
def test_restock_is_logged(caplog):
restock("pen")
assert caplog.messages == ["restocked pen"]- AEl `%s` nunca se rellena, por lo que el mensaje es `'restocked %s'`
- B`caplog.messages` incluye el nombre del nivel, así que compara con `'INFO restocked pen'`
- C`INFO` está por debajo del nivel `WARNING` por defecto, por lo que no se capturó nada; llama primero a `caplog.set_level(logging.INFO)`
- DEl logger se llama `"shop"`; `caplog` solo escucha al logger raíz
Una fixture con scope="session" quiere crear un archivo de muestra grande una sola vez. ¿Dónde debe guardarse el archivo?
- AEn la carpeta devuelta por `tmp_path_factory.mktemp("data")`
- BEn una carpeta `data/` dentro del proyecto, para que sobreviva a la siguiente ejecución
- CEn `tmp_path`: solo pídela en los parámetros de la fixture
- DEn `tmpdir`: es la versión de sesión de `tmp_path`
Para responder necesitas una cuenta
Inicia sesión para comprobar tus respuestas
Las preguntas están arriba, y resolverlas mentalmente es lo que de verdad importa. Inicia sesión para ver las respuestas, las explicaciones y las pistas de tres niveles.
Tu turno
Escribe un módulo notes.py con una función export_notes(notes, out_dir). Recibe una lista de diccionarios como {"title": "Buy pens", "done": False} y:
- Crea
out_dirsi no existe y escribe las notas enout_dir / "notes.json" - Registra un
WARNINGen el log para cualquier nota cuyo título esté vacío y la excluye del archivo - Registra un mensaje
INFO"exported N notes" - Imprime
saved notes.jsoncuando termina
Luego escribe test_notes.py, que compruebe, utilizando únicamente fixtures integradas:
- Que el archivo JSON se vuelve a leer exactamente como las notas que eran válidas
- Que se crea una subcarpeta que no existía, como
tmp_path / "a" / "b" - Que la advertencia se registra con
levelname == "WARNING"y que el mensajeINFOtambién se captura (¿a qué necesitas llamar primero?) - Que se imprime exactamente
saved notes.json\ny que no va nada al error estándar
Ejecuta pytest -q, luego ejecuta ls (o dir en Windows). Si aparece algún notes.json en la carpeta de tu proyecto, uno de tus tests —o tu función— está escribiendo en una ruta real. Detectar eso y corregirlo es el hábito del que trata este capítulo.
Solución
Primero el plan, con la forma de la tabla de «Antes de escribir el test»:
| Caso | Entrada | Esperado | |---|---|---| | Se conservan las notas válidas | tres notas, una con título " " | el archivo contiene las otras dos, sin cambios | | Carpeta inexistente | tmp_path / "a" / "b" | ambas carpetas creadas, archivo en su interior | | Título vacío | las mismas tres notas | un WARNING, luego INFO exported 2 notes | | Salida | las mismas tres notas | saved notes.json\n en stdout, nada en stderr | | Nada que exportar | [] | el archivo contiene [] |
notes.py:
import json
import logging
logger = logging.getLogger("notes")
def export_notes(notes, out_dir):
"""Write notes with a title to out_dir/notes.json and return its path."""
out_dir.mkdir(parents=True, exist_ok=True)
valid = []
for note in notes:
if not note["title"].strip():
logger.warning("skipped a note with an empty title")
continue
valid.append(note)
target = out_dir / "notes.json"
target.write_text(json.dumps(valid, indent=2))
logger.info("exported %d notes", len(valid))
print("saved notes.json")
return targettest_notes.py:
import json
import logging
from notes import export_notes
NOTES = [
{"title": "Buy pens", "done": False},
{"title": " ", "done": True},
{"title": "Call the printer", "done": True},
]
def test_file_holds_only_valid_notes(tmp_path):
target = export_notes(NOTES, tmp_path)
assert target == tmp_path / "notes.json"
assert json.loads(target.read_text()) == [NOTES[0], NOTES[2]]
def test_creates_missing_folders(tmp_path):
out_dir = tmp_path / "a" / "b"
target = export_notes(NOTES, out_dir)
assert target.parent == out_dir
assert target.exists()
def test_empty_title_is_warned_about(tmp_path, caplog):
caplog.set_level(logging.INFO, logger="notes")
export_notes(NOTES, tmp_path)
assert [(r.levelname, r.getMessage()) for r in caplog.records] == [
("WARNING", "skipped a note with an empty title"),
("INFO", "exported 2 notes"),
]
def test_prints_one_line_and_no_errors(tmp_path, capsys):
export_notes(NOTES, tmp_path)
out, err = capsys.readouterr()
assert out == "saved notes.json\n"
assert err == ""
def test_no_notes_still_writes_an_empty_list(tmp_path):
target = export_notes([], tmp_path)
assert json.loads(target.read_text()) == []$ pytest -q
..... [100%]
5 passed in 0.10s
$ ls
notes.py test_notes.pyEl porqué de cada decisión:
out_dires un parámetro. Eso es lo que permite que cada test pasetmp_path; ellsfinal no muestra ningúnnotes.jsonen el proyecto.- El test de JSON vuelve a leer el archivo con
json.loadsen lugar de comparar texto. La promesa es «estas notas, como datos»; la sangría exacta es asunto dejson.dumps, no nuestro. Comparar conNOTES[0]yNOTES[2]también demuestra que las notas válidas se escribieron sin alteraciones. - El título
" "son espacios, no"". Un título compuesto únicamente por espacios es el caso límite: parece no estar vacío paraif not note["title"]y solo.strip()lo detecta. El test afianza esa decisión. caplog.set_levelva primero y tiene como objetivo"notes". Sin él, la líneaINFOnunca se captura. Comparar pares(levelname, message)comprueba el nivel, la redacción y el orden en un solo assert, sin tocarcaplog.text, cuyos nombres de archivo y números de línea romperían el test en cada edición.err == ""se comprueba intencionadamente mediante un assert. «Nada salió mal» es parte del contrato, y unprintde depuración extraviado hacia el error estándar se colaría de lo contrario.- La lista vacía tiene su propio test. Es el caso límite con el que un lector del archivo se topará primero en una cuenta nueva, y escribir
[]en lugar de no escribir nada en absoluto es una decisión que vale la pena mantener.
Paso 6 de 6
Reto — el quiz del capítulo
Diez preguntas de fácil a difícil. Las últimas son difíciles a propósito.
Inicia sesión para hacer el quiz