La mayoría de los scrapers se prueban de la misma manera: alguien los ejecuta contra el sitio en vivo, mira la salida y decide que parece correcta. Eso funciona hasta el día en que no. El sitio cambia un nombre de clase, el scraper devuelve campos vacíos, y nadie se da cuenta hasta que un informe posterior sale mal.
La dificultad es que un scraper depende de algo que no controlas. Las pruebas que llaman al sitio en vivo son lentas, inestables y cambian sus respuestas sin avisar, así que los equipos dejan de ejecutarlas. Las pruebas que nunca tocan páginas reales pasan para siempre mientras producción se rompe.
La salida es dividir el problema en dos. Prueba tu código contra páginas grabadas que nunca cambian, y prueba el sitio por separado con una pequeña comprobación diaria que te avise cuando haya cambiado. Esta guía construye ambas cosas, con código que puedes adaptar: fixtures grabados, una sesión de reproducción que ejecuta todo el crawler sin conexión, pruebas de archivo dorado (golden-file) para el analizador, y un canario para el sitio en vivo.
Tres cosas que pueden romperse
Cuando un scraper produce datos incorrectos, la causa es casi siempre una de tres cosas, y cada una necesita un tipo distinto de prueba:
| Qué se rompió | Ejemplo | Prueba que lo detecta |
|---|---|---|
| Tu analizador (parser) | Una refactorización convierte “1,099.00” en 1.099 | Pruebas de archivo dorado sobre páginas grabadas |
| Tu lógica de rastreo | La paginación se detiene tras la primera página; un 503 se trata como una página vacía | Pruebas de reproducción que ejecutan el crawler sin conexión |
| El sitio | Cambia un nombre de clase, un campo se mueve, aparece una página de bloqueo | Un canario diario contra el sitio en vivo |
Las dos primeras son errores en tu código y deben detectarse antes de desplegar, en pruebas que se ejecutan en segundos sin red. La tercera no es un error en absoluto; es el mundo cambiando, y necesita monitorización en lugar de una prueba unitaria.
Mantén separados el fetching y el parsing
Las pruebas empiezan por la estructura. Si la función que analiza una página también la descarga, cada prueba tiene que lidiar con la red. Mantén el análisis como una función pura: HTML entra, registros salen. El crawler se convierte entonces en un bucle fino que descarga páginas y se las pasa al analizador.
Aquí está el scraper que vamos a probar, para un sitio de listados con paginación:
from urllib.parse import urljoin
from lxml import html as lxml_html
def parse_listing(page_html):
"""Products on one listing page. Pure function: HTML in, records out."""
doc = lxml_html.fromstring(page_html)
items = []
for card in doc.cssselect("li.product"):
price = card.cssselect(".price")[0].text_content().strip()
items.append({
"sku": card.get("data-sku"),
"name": card.cssselect("h2")[0].text_content().strip(),
"price": float(price.replace("$", "").replace(",", "")),
"in_stock": not card.cssselect(".sold-out"),
})
return items
def next_page(page_html, base_url):
links = lxml_html.fromstring(page_html).cssselect("a[rel=next]")
return urljoin(base_url, links[0].get("href")) if links else None
def crawl(session, start_url, max_pages=50):
"""Follow pagination; return the records and the URLs that failed."""
records, failed, url = [], [], start_url
for _ in range(max_pages):
response = session.get(url, timeout=30)
if response.status_code != 200:
failed.append((url, response.status_code))
break
records.extend(parse_listing(response.text))
url = next_page(response.text, response.url)
if url is None:
break
return records, failed
crawl() recibe la sesión como argumento en lugar de crear una. Esa única decisión es lo que lo hace comprobable: en producción pasas una requests.Session, en las pruebas pasas algo que sirve páginas grabadas.
Graba fixtures una sola vez
Un fixture es una respuesta real, guardada en disco con suficientes metadatos para reproducirla exactamente: la URL, la URL final tras las redirecciones, el código de estado y la codificación. Graba el cuerpo como bytes sin procesar, no como texto decodificado, para que los problemas de codificación se reproduzcan en lugar de ocultarse.
import hashlib
import json
from pathlib import Path
def _name(url):
return hashlib.sha256(url.encode()).hexdigest()[:16]
def record(session, url, directory):
"""Fetch a page once and save everything a test needs to replay it."""
response = session.get(url, timeout=30)
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
name = _name(url)
(directory / f"{name}.html").write_bytes(response.content)
(directory / f"{name}.json").write_text(json.dumps({
"url": url,
"final_url": response.url,
"status": response.status_code,
"encoding": response.encoding,
"headers": {"Content-Type": response.headers.get("Content-Type", "")},
}, indent=2))
return name
class Replayed:
def __init__(self, meta, body):
self.url = meta["final_url"]
self.status_code = meta["status"]
self.headers = meta["headers"]
self.content = body
self.text = body.decode(meta["encoding"] or "utf-8", errors="replace")
class ReplaySession:
"""Stands in for requests.Session: serves recorded pages, never the network."""
def __init__(self, directory):
self.directory = Path(directory)
self.requested = []
def get(self, url, **kwargs):
self.requested.append(url)
name = _name(url)
meta_path = self.directory / f"{name}.json"
if not meta_path.exists():
raise LookupError(f"no fixture recorded for {url}")
meta = json.loads(meta_path.read_text())
return Replayed(meta, (self.directory / f"{name}.html").read_bytes())
ReplaySession tiene el mismo método get() que requests.Session, así que crawl() no puede distinguir la diferencia. Lanza un error para cualquier URL que no se haya grabado, lo cual importa: una prueba que recurriera silenciosamente a la red sería rápida en tu portátil y lenta e inestable en CI.
Graba más que páginas de camino feliz. Los casos que rompen scrapers en producción son los que merece la pena conservar: la última página de paginación, un resultado de búsqueda vacío, un producto agotado, un 503, una página de bloqueo. Cada uno se convierte en un caso de prueba permanente. Si archivas las respuestas en bruto en producción, como describe nuestra guía sobre archivar páginas raspadas en WARC, esos archivos son una fuente lista de fixtures a partir de fallos reales.
Valida lo que cualquier rastreo correcto debe satisfacer
Los archivos dorados comprueban la salida exacta. La validación comprueba reglas que deben cumplirse sin importar cómo cambie el contenido del sitio: todo registro tiene un ID, los ID son únicos, los nombres no están vacíos, los precios son números plausibles, y el rastreo devolvió aproximadamente tantos registros como se esperaba.
def validate(records, min_records=1):
"""Checks that hold for any correct crawl, whatever the site shows today."""
problems = []
if len(records) < min_records:
problems.append(f"only {len(records)} records, expected at least {min_records}")
seen = set()
for i, r in enumerate(records):
if not r.get("sku"):
problems.append(f"record {i}: missing sku")
elif r["sku"] in seen:
problems.append(f"record {i}: duplicate sku {r['sku']}")
seen.add(r.get("sku"))
if not r.get("name"):
problems.append(f"record {i}: empty name")
if not isinstance(r.get("price"), float) or not 0 < r["price"] < 100_000:
problems.append(f"record {i}: implausible price {r.get('price')!r}")
return problems
def canary(session, start_url, crawl, min_records):
"""Run the real crawl against the live site and list anything wrong.
Schedule it daily: an empty list means the scraper still works today.
"""
records, failed = crawl(session, start_url)
return validate(records, min_records) + [f"fetch failed: {url} ({status})" for url, status in failed]
La misma validate() se ejecuta en tres sitios: en la suite de pruebas, en el canario diario y, idealmente, en cada ejecución de producción antes de escribir los datos. Nuestra guía sobre desviación de esquema en datos raspados profundiza más en las comprobaciones a nivel de campo a lo largo del tiempo.
La suite de pruebas
Con los fixtures y la validación en su sitio, las pruebas son cortas. Usan solo la biblioteca estándar:
import json
import os
import unittest
from pathlib import Path
from fixtures import ReplaySession
from shop import crawl, parse_listing
from validate import canary, validate
FIXTURES = Path(__file__).parent / "fixtures"
GOLDEN = Path(__file__).parent / "golden"
UPDATE = os.environ.get("UPDATE_GOLDEN") == "1"
class ParserTests(unittest.TestCase):
def test_every_fixture_matches_its_golden_file(self):
for page in sorted(FIXTURES.glob("*.html")):
meta = json.loads(page.with_suffix(".json").read_text())
if meta["status"] != 200:
continue
with self.subTest(url=meta["url"]):
parsed = parse_listing(page.read_text())
golden = GOLDEN / f"{page.stem}.json"
if UPDATE:
golden.write_text(json.dumps(parsed, indent=2))
self.assertEqual(parsed, json.loads(golden.read_text()))
class CrawlTests(unittest.TestCase):
def test_crawl_follows_pagination_offline(self):
session = ReplaySession(FIXTURES)
records, failed = crawl(session, "https://shop.example/laptops?page=1")
self.assertEqual(len(session.requested), 2)
self.assertEqual(failed, [])
self.assertEqual(validate(records, min_records=4), [])
def test_crawl_reports_a_failed_page(self):
session = ReplaySession(FIXTURES)
records, failed = crawl(session, "https://shop.example/monitors?page=1")
self.assertEqual(failed, [("https://shop.example/monitors?page=2", 503)])
self.assertEqual(len(records), 2)
class ValidationTests(unittest.TestCase):
def test_catches_broken_records(self):
bad = [{"sku": "A1", "name": "", "price": 0.0}, {"sku": "A1", "name": "x", "price": "12"}]
self.assertEqual(validate(bad), [
"record 0: empty name",
"record 0: implausible price 0.0",
"record 1: duplicate sku A1",
"record 1: implausible price '12'",
])
def test_canary_flags_a_partial_crawl(self):
problems = canary(ReplaySession(FIXTURES), "https://shop.example/monitors?page=1", crawl, min_records=4)
self.assertEqual(problems, [
"only 2 records, expected at least 4",
"fetch failed: https://shop.example/monitors?page=2 (503)",
])
if __name__ == "__main__":
unittest.main()
- La prueba de archivo dorado analiza cada página grabada y compara el resultado con un archivo JSON almacenado. La primera vez, ejecútala con
UPDATE_GOLDEN=1para escribir los archivos dorados, y luego revísalos a mano: son la especificación de lo que el analizador debería producir. A partir de ahí, cualquier cambio en la salida del analizador hace fallar la prueba hasta que alguien lo revisa y decide si es una corrección o un error. - Las pruebas de reproducción ejecutan el
crawl()real sin conexión. Una comprueba que la paginación se sigue hasta el final; la otra comprueba que un 503 en la página dos se reporta en lugar de tratarse silenciosamente como el final de los resultados. - Las pruebas de validación comprueban que las reglas realmente detectan registros rotos, y que el canario reporta un rastreo parcial.
Al ejecutar la suite contra fixtures grabados de una pequeña tienda de prueba:
test_crawl_follows_pagination_offline (test_shop.CrawlTests) ... ok
test_crawl_reports_a_failed_page (test_shop.CrawlTests) ... ok
test_every_fixture_matches_its_golden_file (test_shop.ParserTests) ... ok
test_canary_flags_a_partial_crawl (test_shop.ValidationTests) ... ok
test_catches_broken_records (test_shop.ValidationTests) ... ok
----------------------------------------------------------------------
Ran 5 tests in 0.019s
OK
Cinco pruebas en una fracción de segundo, sin red. Esa velocidad es lo importante: pruebas tan rápidas se ejecutan en cada commit. Nuestra guía sobre ejecutar scrapers en CI/CD cubre el pipeline alrededor de ellas, incluyendo por qué las pruebas de fixtures no necesitan credenciales de proxy en absoluto.
El canario: probar el sitio, no tu código
Los fixtures congelan el sitio tal como era. Para saber cuándo cambia, ejecuta el rastreo real contra el sitio en vivo según un calendario y valida el resultado. Esa es la función canary() de arriba: devuelve una lista vacía cuando todo va bien y una lista de problemas concretos cuando no.
Ejecútalo una vez al día, o antes de cada rastreo grande, con una URL de inicio pequeña y un recuento mínimo de registros realista. Cuando falla:
- Graba la página que se rompió como un nuevo fixture.
- Observa cómo falla la prueba de archivo dorado o de reproducción sobre ella, lo que confirma que la prueba reproduce el problema.
- Corrige el analizador, revisa la nueva salida dorada, y haz commit del fixture, la corrección y el archivo dorado juntos.
El cambio del sitio se convierte en una prueba de regresión permanente, de modo que la misma rotura nunca puede volver a pasar desapercibida. Ese ciclo, de canario a fixture a corrección, es lo que convierte un scraper frágil en uno mantenido.
Un canario también te informa de fallos que no tienen nada que ver con el análisis: una página de bloqueo que devuelve el estado 200, una redirección regional, o un muro de consentimiento en lugar de contenido. Nuestro estudio sobre la tasa de fallo silencioso muestra cuán comunes son las respuestas que parecen exitosas pero llevan el contenido equivocado. Para una monitorización continua de todo un pipeline, consulta cómo monitorizar un pipeline de web scraping.
Reglas prácticas
- Nunca llames a la red desde las pruebas unitarias. Haz que la falta de fixtures sea un error, como hace
ReplaySession. - Mantén los fixtures pequeños y reales. Una página por caso es suficiente. No recortes nada del HTML: el ruido que recortarías es a menudo lo que rompe un analizador.
- Revisa los archivos dorados como código. Un archivo dorado no revisado solo demuestra que el analizador hace lo que hacía ayer.
- Nombra el caso, no la fecha. Un fixture llamado “producto agotado” es útil dentro de un año; uno llamado “la página del martes” no lo es.
- Actualiza los fixtures de forma deliberada. Cuando el sitio cambie, añade fixtures nuevos junto a los antiguos en lugar de reemplazarlos, a menos que el diseño antiguo nunca pueda volver.
- Mantén los secretos fuera de los fixtures. Las páginas grabadas pueden contener tokens de sesión o datos personales. Elimina las cookies y revisa los cuerpos antes de hacer commit de ellos.
Preguntas frecuentes
¿Cómo se prueba un scraper web sin acceder al sitio en vivo?
Graba respuestas reales una vez como fixtures, luego reemplaza la sesión HTTP por una que sirva esas grabaciones. El analizador y el crawler se ejecutan exactamente como en producción, pero sin conexión, en milisegundos y con la misma respuesta cada vez.
¿Qué es una prueba de archivo dorado (golden file)?
Una prueba que compara la salida de una función con una copia almacenada y revisada de la salida esperada. Para un scraper, el archivo dorado es la lista de registros que una página grabada debería producir. Cualquier cambio en la salida hace fallar la prueba hasta que alguien la acepta o la corrige.
¿Cómo sé cuándo un cambio en el sitio web rompe mi scraper?
Ejecuta un canario: un pequeño rastreo real contra el sitio en vivo, según un calendario, con reglas de validación que cualquier resultado correcto debe satisfacer. Cuando reporta problemas, graba la nueva página como un fixture, corrige el analizador y conserva el fixture como prueba de regresión.
¿Deberían las pruebas de scraper usar proxies?
Las pruebas unitarias y de reproducción no deberían usar la red en absoluto, así que no necesitan proxies ni credenciales. El canario se ejecuta contra el sitio en vivo y debería usar la misma configuración de proxy que producción, para que vea lo que ve producción.
La conclusión
Prueba tu código y prueba el sitio por separado. Los fixtures grabados y una sesión de reproducción te permiten probar el analizador y el crawler sin conexión, rápidamente y de forma determinista, en cada commit. Un canario diario con reglas de validación te avisa cuando el sitio cambia. Convierte cada rotura que el canario encuentre en un fixture, y el scraper se vuelve más fiable cada vez que el sitio cambia, no menos.