Cómo probar scrapers web con pytest: Una guía práctica
Expert in Web Scraping Technologies
TL;DR:
- Separar
fetchdeparsey el análisis se convierte en una función pura: cadena HTML de entrada, registros de salida; comprobable sin red y sin biblioteca de simulación. - La suite offline ejecutó 14 pruebas en 0.16 s; las dos pruebas de contrato en vivo se deseleccionan de forma predeterminada y tardan 0.88 s por sí solas.
- La cobertura reportó 85%, y las únicas líneas no cubiertas fueron
fetch()yscrape(). Esa es la forma intencionada en lugar de un vacío que cerrar. - Hacer que los analizadores de campo generen excepciones. Renombrar una clase CSS en una copia del fixture produjo
ValueError: missing priceen una línea nombrada en lugar de 20 filas de nulos. - Una prueba de fixture demuestra que el analizador maneja el HTML que guardaste. Solo una prueba de contrato contra la página en vivo detecta que el sitio ha cambiado.
- Una suite verde no puede decirte que el objetivo todavía sirve ese HTML, que todavía se renderiza del lado del servidor, o que todavía devuelve una página en absoluto.
- Ejecutar la mitad en vivo contra páginas renderizadas reales en el plan gratuito de Scrapeless.
Los raspadores se rompen de una manera que la mayoría del software no: nada en el repositorio cambia, y el código deja de funcionar porque alguien más editó una página. Eso hace que el instinto habitual —escribir pruebas, verlas volverse verdes, enviar— sea necesario pero no suficiente, y cambia lo que las pruebas deberían estar comprobando.
La suite a continuación cubre un raspador de catálogo de libros pequeño. Está escrito en dos mitades que responden a diferentes preguntas: una mitad offline que pregunta si el analizador es correcto, y una mitad en vivo que pregunta si el sitio todavía coincide con lo que el analizador espera.
Para Qué Sirven Realmente las Pruebas de Raspadores
Tres fallos merecen ser separados, porque solo dos de ellos son tuyos:
| Fallo | Detectado por | Ejemplo |
|---|---|---|
| El analizador maneja incorrectamente HTML válido | prueba unitaria offline | un precio con un símbolo de moneda se convierte en una cadena, no en un flotante |
| El sitio cambió su marcado | prueba de contrato en vivo | price_color se convierte en product-price |
| El sitio dejó de servir la página | ninguno | la respuesta es una página de desafío o una shell vacía |
La mayoría de los consejos publicados sobre pruebas de raspadores cubren la primera fila. La segunda necesita una prueba que se comunique con el sitio; la tercera no puede ser atrapada por una suite de pruebas en absoluto, lo cual vale la pena decir en voz alta antes de construir una.
Instalación
bash
python3 -m venv .venv
./.venv/bin/pip install pytest pytest-cov responses parsel requests
Las versiones contra las que se ejecutó esta suite:
text
pytest 9.1.1
pytest-cov 7.1.0
responses 0.26.3
parsel 1.11.0
requests 2.34.2
lxml 6.1.3
responses está incluido porque la simulación HTTP es la siguiente pregunta habitual. Las pruebas de análisis no necesitan ninguna de ellas, y la razón es estructural más que estilística.
La División Que Hace que el Análisis Sea Comprobable
Una función toca la red. Todo lo demás toma una cadena.
python
import requests
from parsel import Selector
CATEGORY_URL = "https://books.toscrape.com/catalogue/category/books/mystery_3/index.html"
RATINGS = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}
def fetch(url: str = CATEGORY_URL) -> str:
"""The only function that touches the network."""
response = requests.get(url, timeout=30)
response.raise_for_status()
return response.content.decode("utf-8")
def parse_price(raw: str | None) -> float:
if not raw:
raise ValueError("missing price")
return float(raw.replace("£", "").strip())
def parse_rating(css_class: str | None) -> int:
word = (css_class or "").replace("star-rating", "").strip()
if word not in RATINGS:
raise ValueError(f"unknown rating: {word!r}")
return RATINGS[word]
def parse(html: str) -> list[dict]:
"""Pure function: HTML in, records out."""
sel = Selector(text=html)
return [{
"title": card.css("h3 a::attr(title)").get(),
"price": parse_price(card.css("p.price_color::text").get()),
"rating": parse_rating(card.css("p.star-rating::attr(class)").get()),
"in_stock": bool(card.css("p.instock.availability").get()),
} for card in sel.css("article.product_pod")]
parse no tiene E/S, no tiene reloj y no tiene estado global, por lo que probarlo no necesita ninguna simulación. Las herramientas de simulación de la biblioteca estándar son excelentes y en su mayoría innecesarias aquí: una función que ya toma su entrada como argumento no necesita que se parcheen sus dependencias.
Ten en cuenta que los dos analizadores de campo generan excepciones en lugar de devolver None. Esa única decisión es lo que convierte un cambio de marcado silencioso en un fallo nombrado.
Guarda una Página Real como Fixture
Las pruebas necesitan HTML que no cambie debajo de ellas, así que guarda una respuesta real una vez y comprométela.
python
import requests, pathlib
response = requests.get(CATEGORY_URL, timeout=30)
response.raise_for_status()
pathlib.Path("fixtures/mystery.html").write_bytes(response.content)
text
fixture saved: 50388 bytes
Cárgalo a través de un fixture de sesión para que el archivo se lea una vez para toda la ejecución:
python
# tests/conftest.py
import pathlib
import pytest
FIXTURES = pathlib.Path(__file__).parent.parent / "fixtures"
@pytest.fixture(scope="session")
def mystery_html() -> str:
return (FIXTURES / "mystery.html").read_text(encoding="utf-8")
Compromete el fixture. Es el registro de cómo se veía la página cuando se escribió el analizador, y un diff contra una copia fresca es la forma más rápida de ver qué cambió en un sitio.
Escribe Afirmaciones Que Vale la Pena Tener
Asegúrate de valores e invariantes, no del hecho de que algo volvió.
python
import pytest
from bookscraper import parse, parse_price, parse_rating
def test_parse_returns_every_card(mystery_html):
assert len(parse(mystery_html)) == 20
def test_record_shape(mystery_html):
record = parse(mystery_html)[0]
assert set(record) == {"title", "price", "rating", "in_stock"}
assert record["title"] == "Sharp Objects"
assert record["price"] == 47.82
assert record["rating"] == 4
assert record["in_stock"] is True
def test_every_price_is_positive(mystery_html):
assert all(r["price"] > 0 for r in parse(mystery_html))
@pytest.mark.parametrize("raw,expected", [("£47.82", 47.82), ("£9.99", 9.99), ("£100.00", 100.0)])
def test_parse_price(raw, expected):
assert parse_price(raw) == expected
def test_parse_price_rejects_missing():
with pytest.raises(ValueError):
parse_price(None)
def test_parse_rating_rejects_unknown():
with pytest.raises(ValueError, match="unknown rating"):
parse_rating("star-rating Eleven")
def test_empty_html_yields_no_records():
assert parse("<html><body></body></html>") == []
Tres tipos de afirmaciones están realizando trabajos distintos. Los valores exactos fijan un registro conocido. Los invariantes (all prices > 0, calificaciones entre 1 y 5) se sostienen para registros que el fixture aún no contiene. Y los casos pytest.raises fijan el comportamiento de fallo, que es la parte que un cambio de marcado ejerce.
Mantén las Pruebas en Vivo Fuera de la Ejecución Predeterminada
Las pruebas de contrato impactan el sitio real, por lo que son lentas y dependen del tiempo de actividad de otra persona. Un marcador las mantiene fuera del bucle rápido sin borrarlas.
python
# tests/test_selector_contract.py
import pytest
from bookscraper import fetch, parse
pytestmark = pytest.mark.live
@pytest.fixture(scope="module")
def live_html():
return fetch()
def test_live_page_still_yields_records(live_html):
assert len(parse(live_html)) == 20
def test_live_selectors_match_fixture_shape(live_html, mystery_html):
live, saved = parse(live_html), parse(mystery_html)
assert {r["title"] for r in live} == {r["title"] for r in saved}
ini
[pytest]
pythonpath = .
testpaths = tests
markers =
live: hits the real site; excluded from the default run
addopts = -m "not live"
Registrar el marcador en la configuración es lo que evita que el sistema de marcadores de pytest advierta sobre un marcador desconocido, y addopts hace que la exclusión sea la predeterminada en lugar de algo que todos tengan que recordar.
text
$ pytest -q
.............. [100%]
14 passed, 2 deselected in 0.16s
$ pytest -q -m live
.. [100%]
2 passed, 14 deselected in 0.88s
La división importa porque las dos suites pertenecen a diferentes horarios. La offline 14 se ejecuta en cada commit. La live 2 se ejecuta en un temporizador, y su fallo significa que el sitio se movió en lugar del código — esta es la frontera la pirámide de pruebas práctica dibuja entre pruebas rápidas aisladas y el pequeño número que cruzan una verdadera frontera.
Leer Cobertura como una Verificación de Arquitectura
text
$ pytest -q --cov=bookscraper --cov-report=term-missing
Name Stmts Miss Cover Missing
----------------------------------------------
bookscraper.py 26 4 85% 14-16, 47
----------------------------------------------
TOTAL 26 4 85%
14 passed, 2 deselected in 0.50s
Las líneas 14-16 son el cuerpo de fetch; la línea 47 es scrape, que compone las dos. Cada línea de lógica de análisis está cubierta y cada línea descubierta es una que habla con la red.
Ese es el número que se quiere. Perseguir el 100% aquí significa burlarse de requests para probar que requests.get fue llamado, lo que prueba la burla. La lectura útil de un informe de cobertura en un scraper es qué líneas faltan, y si son las que deliberadamente mantuviste en el borde.
¿Probar un scraper contra una página que renderiza del lado del cliente? El plan gratuito de Scrapeless cubre suficientes sesiones para capturar un fixture renderizado que vale la pena comprometer.
Cómo se Ve un Cambio de Marcado
Toma el fixture guardado, renombra una clase como lo haría un rediseño del sitio, y ejecuta el analizador contra él:
python
html = pathlib.Path("fixtures/mystery.html").read_text(encoding="utf-8")
drifted = html.replace("price_color", "product-price")
pathlib.Path("fixtures/mystery_drifted.html").write_text(drifted, encoding="utf-8")
print("price_color occurrences:", html.count("price_color"), "->", drifted.count("price_color"))
text
price_color occurrences: 20 -> 0
text
raw = None
def parse_price(raw: str | None) -> float:
if not raw:
> raise ValueError("missing price")
E ValueError: missing price
bookscraper.py:21: ValueError
=========================== short test summary info ============================
FAILED tests/test_drift_demo.py::test_parse_survives_price_class_rename - Val...
1 failed in 0.11s
El fallo nombra el campo y la línea. Si parse_price hubiera devuelto None en una coincidencia faltante, la ejecución se habría completado y escrito veinte registros con un precio nulo — y el pipeline habría informado éxito. El atributo de clase de la especificación HTML no lleva ninguna garantía de estabilidad; es presentacional, y tratar un nombre de clase como un contrato significa que el analizador debe ser ruidoso cuando se rompe el contrato.
Por la misma razón, validar la forma del registro después de analizarlo vale la pena emparejarlo con estas pruebas — nuestra guía para validar datos raspados cubre la mitad de tiempo de ejecución del mismo problema.
Dónde se Detiene la Suite de Pruebas
Una suite verde significa que el analizador maneja el HTML en fixtures/. No dice nada sobre tres cosas que rompen scrapers en producción:
- La página ahora se renderiza del lado del cliente. El HTML que recibe un cliente común es una cáscara; los selectores son correctos y no coinciden con nada.
- La respuesta no es la página. Un desafío o intersticial llega con HTTP 200, y una afirmación solo de contenido puede pasar en un marcado que no contiene registros.
- El fixture ha envejecido. Todavía se analiza limpiamente porque es un archivo, que es exactamente por qué no puede decirte que el sitio se movió.
Los dos primeros necesitan un navegador real en lugar de una solicitud real. Capturar el fixture a través del Scrapeless Scraping Browser significa que el HTML guardado es el DOM que el navegador ensambló, así que la suite offline prueba el mismo documento que la ejecución live verá. Una suite de contrato es un puñado de sesiones en un temporizador en lugar de un costo por commit, y los precios enumeran a qué equivale esa cadencia. La tercera se responde mediante la prueba de contrato que compara títulos en vivo contra el fixture — la advertencia temprana más barata disponible, y la razón por la que existen esas dos pruebas.
Solución de Problemas
fixture 'mystery_html' not found — el fixture vive en tests/conftest.py, y pytest solo descubre conftest.py en el directorio de prueba o por encima de él.
ModuleNotFoundError: No module named 'bookscraper' — establece pythonpath = . en pytest.ini, o instala el paquete en modo editable. Las pruebas se ejecutan desde el rootdir, no desde tests/.
PytestUnknownMarkWarning: Unknown pytest.mark.live — registra el marcador en la sección markers de la configuración.
Las pruebas en vivo fallan mientras que la suite offline pasa — eso es la prueba de contrato haciendo su trabajo. Compara una copia fresca de la página con el fixture comprometido antes de tocar el analizador.
Conclusión
La decisión de diseño que hace que un scraper sea testeable no es el marco de prueba, es la división: fetch devuelve una cadena, parse toma una, y todo lo interesante sucede en una función pura. La cobertura confirma la forma — 85%, con fetch y scrape como las únicas líneas descubiertas.
Más allá de eso, dos hábitos llevan la mayor parte del valor. Hacer que los analizadores de campo generen excepciones, de modo que una clase renombrada produzca ValueError: missing price en una línea nombrada en lugar de veinte precios nulos. Y mantener un pequeño conjunto de contratos en vivo detrás de un marcador, porque el fixture solo puede decirte que el analizador sigue funcionando en la página que guardaste.
¿Listo para probar un scraper contra páginas que se renderizan antes de que las analices? Comienza con el plan gratuito de Scrapeless y captura un fixture del DOM real.
FAQ
Q: ¿Cómo realizas pruebas unitarias a un web scraper sin golpear el sitio?
Separa la obtención del análisis y prueba el análisis. Si parse toma una cadena HTML y devuelve registros, un archivo de fixture guardado es toda la configuración de prueba: ninguna biblioteca de simulación, ninguna interceptación HTTP. Las 14 pruebas en línea anteriores se ejecutaron en 0.16 s porque ninguna de ellas abre un socket.
Q: ¿Necesito una biblioteca de simulación como responses o unittest.mock?
Solo para el código que llama a la red en sí. Una vez que el análisis toma un argumento de cadena, no hay nada que parchear. Busca simulación HTTP cuando quieras probar el comportamiento de la capa de obtención: manejo de estado, tiempos de espera, construcción de encabezados, en lugar de probar el análisis.
Q: ¿Cómo puedo detectar que un sitio cambió mis selectores?
Una prueba de contrato que obtiene la página en vivo y la compara con el fixture comprometido. test_live_selectors_match_fixture_shape arriba afirma que el conjunto de títulos coincide; cuando deja de coincidir, el sitio se movió. Mantenla detrás de un marcador para que se ejecute en un horario en lugar de en cada commit.
Q: ¿Deben ejecutarse las pruebas de scraper en CI?
Las fuera de línea, en cada commit: son deterministas y rápidas. Las pruebas de contratos en vivo no deberían bloquear una fusión, porque un fallo significa que el sitio de otra persona cambió y la solicitud de extracción es inocente. Ejecútalas en un temporizador y alerta sobre el resultado en su lugar.
Q: ¿Qué cobertura debería buscar un scraper?
Mira qué líneas están faltando en lugar del porcentaje. 85% con fetch y scrape descubiertos es un conjunto bien formado; el mismo 85% con ramas de análisis descubiertas no lo es. Empujar hacia el 100% generalmente significa afirmar que se llamó a un mock, lo que no prueba nada sobre los datos.
Q: ¿Debería un analizador retornar None o lanzar una excepción cuando falta un campo?
Lanza una excepción. Un None se propaga a la base de datos como un nulo y la ejecución informa éxito, por lo que el fallo aparece días después como datos faltantes. Lanzar nombra el campo y la línea en el momento en que cambia el marcado, que es lo que convirtió una clase renombrada en ValueError: missing price arriba.
En Scrapeless, solo accedemos a datos disponibles públicamente y cumplimos estrictamente con las leyes, regulaciones y políticas de privacidad del sitio web aplicables. El contenido de este blog es sólo para fines de demostración y no implica ninguna actividad ilegal o infractora. No ofrecemos garantías y renunciamos a toda responsabilidad por el uso de la información de este blog o enlaces de terceros. Antes de realizar cualquier actividad de scraping, consulte a su asesor legal y revise los términos de servicio del sitio web de destino u obtenga los permisos necesarios.



