Die meisten Scraper werden auf dieselbe Weise getestet: Jemand lässt sie gegen die Live-Website laufen, schaut sich die Ausgabe an und entscheidet, dass sie richtig aussieht. Das funktioniert, bis zu dem Tag, an dem es nicht mehr funktioniert. Die Website ändert einen Klassennamen, der Scraper liefert leere Felder zurück, und niemand bemerkt es, bis ein nachgelagerter Report falsche Werte zeigt.
Die Schwierigkeit besteht darin, dass ein Scraper von etwas abhängt, das man nicht kontrolliert. Tests, die die Live-Website aufrufen, sind langsam, instabil und ändern ihre Ergebnisse ohne Vorwarnung, sodass Teams aufhören, sie laufen zu lassen. Tests, die nie echte Seiten berühren, bestehen für immer, während die Produktion zusammenbricht.
Der Ausweg besteht darin, das Problem in zwei Teile zu zerlegen. Testen Sie Ihren Code gegen aufgezeichnete Seiten, die sich nie ändern, und testen Sie die Website separat mit einer kleinen täglichen Prüfung, die Ihnen mitteilt, wenn sie sich geändert hat. Dieser Leitfaden baut beides auf, mit Code, den Sie anpassen können: aufgezeichnete Fixtures, eine Replay-Session, die den gesamten Crawler offline ausführt, Golden-File-Tests für den Parser und einen Kanarienvogel für die Live-Website.
Drei Dinge, die kaputtgehen können
Wenn ein Scraper fehlerhafte Daten produziert, ist die Ursache fast immer eine von drei Dingen, und jedes erfordert eine andere Art von Test:
| Was kaputtging | Beispiel | Test, der es erkennt |
|---|---|---|
| Ihr Parser | Ein Refactoring macht aus “1,099.00” 1.099 | Golden-File-Tests auf aufgezeichneten Seiten |
| Ihre Crawl-Logik | Die Paginierung stoppt nach Seite eins; ein 503 wird als leere Seite behandelt | Replay-Tests, die den Crawler offline ausführen |
| Die Website | Ein Klassenname ändert sich, ein Feld verschiebt sich, eine Blockseite erscheint | Ein täglicher Kanarienvogel gegen die Live-Website |
Die ersten beiden sind Bugs in Ihrem Code und sollten vor dem Deployment erkannt werden, in Tests, die in Sekunden ohne Netzwerk laufen. Das dritte ist überhaupt kein Bug; es ist die sich verändernde Welt, und es erfordert Monitoring statt eines Unit-Tests.
Abruf und Parsing getrennt halten
Testen beginnt mit Struktur. Wenn die Funktion, die eine Seite parst, sie auch abruft, muss sich jeder Test mit dem Netzwerk auseinandersetzen. Halten Sie das Parsing als reine Funktion: HTML rein, Datensätze raus. Der Crawler wird dann zu einer dünnen Schleife, die Seiten abruft und sie an den Parser übergibt.
Hier ist der Scraper, den wir testen werden, für eine Listing-Website mit Paginierung:
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() nimmt die Session als Argument entgegen, statt eine zu erstellen. Genau diese Entscheidung macht sie testbar: In der Produktion übergeben Sie eine requests.Session, in Tests übergeben Sie etwas, das aufgezeichnete Seiten ausliefert.
Fixtures einmal aufzeichnen
Ein Fixture ist eine echte Response, auf die Festplatte gespeichert mit genug Metadaten, um sie exakt wiederzugeben: die URL, die finale URL nach Redirects, der Statuscode und die Kodierung. Zeichnen Sie den Body als rohe Bytes auf, nicht als dekodierten Text, damit Kodierungsprobleme reproduziert statt verborgen werden.
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 hat dieselbe get()-Methode wie requests.Session, sodass crawl() den Unterschied nicht erkennen kann. Sie wirft einen Fehler für jede URL, die nicht aufgezeichnet wurde, und das ist wichtig: Ein Test, der stillschweigend auf das Netzwerk zurückfiele, wäre auf Ihrem Laptop schnell und in CI langsam und instabil.
Zeichnen Sie mehr als nur fehlerfreie Seiten auf. Die Fälle, die Scraper in der Produktion brechen, sind diejenigen, die es wert sind, aufbewahrt zu werden: die letzte Seite der Paginierung, ein leeres Suchergebnis, ein ausverkauftes Produkt, ein 503, eine Blockseite. Jeder wird zu einem permanenten Testfall. Wenn Sie rohe Responses in der Produktion archivieren, wie unser Leitfaden zum Archivieren von gescrapten Seiten in WARC beschreibt, sind diese Archive eine fertige Quelle für Fixtures aus echten Fehlern.
Validieren, was jeder korrekte Crawl erfüllen muss
Golden Files prüfen exakte Ausgaben. Validierung prüft Regeln, die gelten müssen, egal wie sich der Inhalt der Website ändert: Jeder Datensatz hat eine ID, IDs sind eindeutig, Namen sind nicht leer, Preise sind plausible Zahlen, und der Crawl hat ungefähr so viele Datensätze zurückgegeben wie erwartet.
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]
Dasselbe validate() läuft an drei Stellen: in der Testsuite, im täglichen Kanarienvogel und idealerweise bei jedem Produktionslauf, bevor Daten geschrieben werden. Unser Leitfaden zu Schema Drift in gescrapten Daten geht tiefer auf feldbezogene Prüfungen über die Zeit ein.
Die Testsuite
Mit Fixtures und Validierung an Ort und Stelle sind die Tests kurz. Sie verwenden nur die Standardbibliothek:
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()
- Der Golden-File-Test parst jede aufgezeichnete Seite und vergleicht das Ergebnis mit einer gespeicherten JSON-Datei. Beim ersten Mal führen Sie ihn mit
UPDATE_GOLDEN=1aus, um die Golden Files zu schreiben, und überprüfen sie dann manuell: Sie sind die Spezifikation dessen, was der Parser produzieren sollte. Danach schlägt jede Änderung der Parser-Ausgabe den Test fehl, bis jemand sie ansieht und entscheidet, ob es sich um eine Korrektur oder einen Bug handelt. - Die Replay-Tests führen das echte
crawl()offline aus. Einer prüft, dass die Paginierung bis zum Ende verfolgt wird; der andere prüft, dass ein 503 auf Seite zwei gemeldet wird, statt stillschweigend als Ende der Ergebnisse behandelt zu werden. - Die Validierungstests prüfen, dass die Regeln tatsächlich fehlerhafte Datensätze erkennen, und dass der Kanarienvogel einen partiellen Crawl meldet.
Das Ausführen der Suite gegen Fixtures, die von einem kleinen Test-Shop aufgezeichnet wurden:
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
Fünf Tests in einem Bruchteil einer Sekunde, ohne Netzwerk. Diese Geschwindigkeit ist der Punkt: Tests dieser Geschwindigkeit laufen bei jedem Commit. Unser Leitfaden zum Ausführen von Scrapern in CI/CD behandelt die Pipeline darum herum, einschließlich warum Fixture-Tests überhaupt keine Proxy-Zugangsdaten benötigen.
Der Kanarienvogel: Die Website testen, nicht Ihren Code
Fixtures frieren die Website so ein, wie sie war. Um herauszufinden, wann sie sich ändert, führen Sie den echten Crawl nach einem Zeitplan gegen die Live-Website aus und validieren das Ergebnis. Das ist die canary()-Funktion oben: Sie gibt eine leere Liste zurück, wenn alles in Ordnung ist, und eine Liste spezifischer Probleme, wenn nicht.
Führen Sie ihn einmal täglich aus, oder vor jedem großen Crawl, mit einer kleinen Start-URL und einer realistischen Mindestanzahl von Datensätzen. Wenn er fehlschlägt:
- Zeichnen Sie die Seite auf, die kaputtging, als neues Fixture.
- Beobachten Sie, wie der Golden-File- oder Replay-Test dabei fehlschlägt, was bestätigt, dass der Test das Problem reproduziert.
- Korrigieren Sie den Parser, überprüfen Sie die neue Golden-Ausgabe und committen Sie das Fixture, die Korrektur und die Golden-Datei zusammen.
Die Website-Änderung wird zu einem permanenten Regressionstest, sodass derselbe Fehler nie wieder unbemerkt zurückkehren kann. Diese Schleife, vom Kanarienvogel über das Fixture zur Korrektur, ist das, was einen zerbrechlichen Scraper in einen gewarteten verwandelt.
Ein Kanarienvogel sagt Ihnen auch etwas über Fehler, bei denen es überhaupt nicht um Parsing geht: eine Blockseite, die Status 200 zurückgibt, eine Regions-Weiterleitung oder eine Consent-Wall anstelle von Inhalt. Unsere Studie zur Silent-Failure-Rate zeigt, wie häufig Responses sind, die erfolgreich aussehen, aber den falschen Inhalt tragen. Für fortlaufendes Monitoring einer ganzen Pipeline siehe wie man eine Web-Scraping-Pipeline überwacht.
Praktische Regeln
- Rufen Sie in Unit-Tests niemals das Netzwerk auf. Machen Sie fehlende Fixtures zu einem Fehler, wie
ReplaySessiones tut. - Halten Sie Fixtures klein und echt. Eine Seite pro Fall genügt. Kürzen Sie nichts am HTML: Der Noise, den Sie entfernen würden, ist oft das, was einen Parser bricht.
- Überprüfen Sie Golden Files wie Code. Ein ungeprüftes Golden File beweist nur, dass der Parser das tut, was er gestern getan hat.
- Benennen Sie den Fall, nicht das Datum. Ein Fixture namens “sold-out product” ist in einem Jahr noch nützlich; eines namens “Dienstagsseite” nicht.
- Aktualisieren Sie Fixtures bewusst. Wenn sich die Website ändert, fügen Sie neue Fixtures neben den alten hinzu, statt sie zu ersetzen, es sei denn, das alte Layout kann unmöglich zurückkehren.
- Halten Sie Geheimnisse aus Fixtures heraus. Aufgezeichnete Seiten können Session-Tokens oder persönliche Daten enthalten. Entfernen Sie Cookies und prüfen Sie die Bodies, bevor Sie sie committen.
FAQ
Wie testet man einen Web-Scraper, ohne die Live-Website aufzurufen?
Zeichnen Sie echte Responses einmal als Fixtures auf, ersetzen Sie dann die HTTP-Session durch eine, die diese Aufzeichnungen ausliefert. Der Parser und der Crawler laufen exakt wie in der Produktion, aber offline, in Millisekunden und mit derselben Antwort jedes Mal.
Was ist ein Golden-File-Test?
Ein Test, der die Ausgabe einer Funktion mit einer gespeicherten, überprüften Kopie der erwarteten Ausgabe vergleicht. Für einen Scraper ist das Golden File die Liste der Datensätze, die eine aufgezeichnete Seite produzieren sollte. Jede Änderung der Ausgabe schlägt den Test fehl, bis jemand sie akzeptiert oder korrigiert.
Wie erkenne ich, wann eine Website-Änderung meinen Scraper bricht?
Führen Sie einen Kanarienvogel aus: einen kleinen echten Crawl gegen die Live-Website, nach einem Zeitplan, mit Validierungsregeln, die jedes korrekte Ergebnis erfüllen muss. Wenn er Probleme meldet, zeichnen Sie die neue Seite als Fixture auf, korrigieren Sie den Parser und behalten Sie das Fixture als Regressionstest.
Sollten Scraper-Tests Proxies verwenden?
Unit- und Replay-Tests sollten überhaupt kein Netzwerk verwenden, sodass sie keine Proxies oder Zugangsdaten benötigen. Der Kanarienvogel läuft gegen die Live-Website und sollte dieselbe Proxy-Konfiguration wie die Produktion verwenden, damit er sieht, was die Produktion sieht.
Fazit
Testen Sie Ihren Code und testen Sie die Website getrennt. Aufgezeichnete Fixtures und eine Replay-Session lassen Sie den Parser und den Crawler offline testen, schnell und deterministisch, bei jedem Commit. Ein täglicher Kanarienvogel mit Validierungsregeln sagt Ihnen, wenn sich die Website ändert. Verwandeln Sie jeden Fehler, den der Kanarienvogel findet, in ein Fixture, und der Scraper wird mit jeder Änderung der Website zuverlässiger, nicht weniger.