En date du 7 octobre 2026, Shifter dispose du plus grand pool d'IP US actives, devant Oxylabs et Bright Data, pour une fraction du coût.Shifter a désormais le plus grand pool d'IP US actives

Voir les benchmarks

Scraping

Tester les scrapers : fixtures, replay et suites de régression

Les scrapers cassent quand les sites changent, et les tests qui ciblent le site en direct cassent avec eux. Comment tester un scraper avec des fixtures enregistrées, du replay et des canaris quotidiens.

Chris Collins

9 octobre 2026 · 12 min de lecture

La plupart des scrapers sont testés de la même manière : quelqu’un les exécute contre le site en direct, regarde le résultat, et décide que cela semble correct. Cela fonctionne jusqu’au jour où ce n’est plus le cas. Le site change un nom de classe, le scraper renvoie des champs vides, et personne ne le remarque avant qu’un rapport en aval ne sorte faux.

La difficulté est qu’un scraper dépend de quelque chose que vous ne contrôlez pas. Les tests qui appellent le site en direct sont lents, instables et changent de réponse sans prévenir, si bien que les équipes cessent de les exécuter. Les tests qui ne touchent jamais de vraies pages passent indéfiniment pendant que la production casse.

La solution consiste à diviser le problème en deux. Testez votre code contre des pages enregistrées qui ne changent jamais, et testez le site séparément avec une petite vérification quotidienne qui vous indique quand il a changé. Ce guide construit les deux, avec du code que vous pouvez adapter : des fixtures enregistrées, une session de relecture qui exécute tout le crawler hors ligne, des tests par fichiers de référence pour le parseur, et un canari pour le site en direct.

Trois choses qui peuvent casser

Quand un scraper produit de mauvaises données, la cause est presque toujours l’une de ces trois choses, et chacune nécessite un type de test différent :

Ce qui a casséExempleTest qui le détecte
Votre parseurUne refactorisation transforme “1,099.00” en 1.099Tests par fichiers de référence sur des pages enregistrées
Votre logique de crawlLa pagination s’arrête après la première page ; un 503 est traité comme une page videTests de relecture qui exécutent le crawler hors ligne
Le siteUn nom de classe change, un champ se déplace, une page de blocage apparaîtUn canari quotidien contre le site en direct

Les deux premiers sont des bugs dans votre code et devraient être détectés avant le déploiement, dans des tests qui s’exécutent en quelques secondes sans réseau. Le troisième n’est pas un bug du tout ; c’est le monde qui change, et cela nécessite une surveillance plutôt qu’un test unitaire.

Séparer la récupération et l’analyse

Les tests commencent par la structure. Si la fonction qui analyse une page la récupère aussi, chaque test doit gérer le réseau. Gardez l’analyse comme une fonction pure : du HTML en entrée, des enregistrements en sortie. Le crawler devient alors une boucle légère qui récupère les pages et les transmet au parseur.

Voici le scraper que nous allons tester, pour un site de catalogue avec pagination :

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() prend la session en argument au lieu d’en créer une. Ce seul choix est ce qui le rend testable : en production vous passez une requests.Session, dans les tests vous passez quelque chose qui sert des pages enregistrées.

Enregistrer les fixtures une fois

Une fixture est une réponse réelle, enregistrée sur disque avec suffisamment de métadonnées pour la rejouer exactement : l’URL, l’URL finale après redirections, le code de statut et l’encodage. Enregistrez le corps en octets bruts, pas en texte décodé, afin que les problèmes d’encodage soient reproduits plutôt que masqués.

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 possède la même méthode get() que requests.Session, donc crawl() ne peut pas faire la différence. Elle lève une erreur pour toute URL qui n’a pas été enregistrée, ce qui compte : un test qui retomberait silencieusement sur le réseau serait rapide sur votre ordinateur et lent et instable en CI.

Enregistrez plus que des pages conformes. Les cas qui cassent les scrapers en production sont ceux qui valent la peine d’être conservés : la dernière page de pagination, un résultat de recherche vide, un produit épuisé, un 503, une page de blocage. Chacun devient un cas de test permanent. Si vous archivez les réponses brutes en production, comme le décrit notre guide sur l’archivage des pages scrapées au format WARC, ces archives sont une source toute prête de fixtures issues d’échecs réels.

Valider ce que tout crawl correct doit satisfaire

Les fichiers de référence vérifient une sortie exacte. La validation vérifie des règles qui doivent tenir quelle que soit la façon dont le contenu du site change : chaque enregistrement a un ID, les ID sont uniques, les noms ne sont pas vides, les prix sont des nombres plausibles, et le crawl a renvoyé à peu près autant d’enregistrements qu’attendu.

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 même fonction validate() s’exécute à trois endroits : dans la suite de tests, dans le canari quotidien et, idéalement, à chaque exécution en production avant que les données ne soient écrites. Notre guide sur la dérive de schéma dans les données scrapées approfondit les vérifications au niveau des champs dans le temps.

La suite de tests

Avec les fixtures et la validation en place, les tests sont courts. Ils n’utilisent que la bibliothèque standard :

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()
  • Le test par fichier de référence analyse chaque page enregistrée et compare le résultat avec un fichier JSON stocké. La première fois, exécutez-le avec UPDATE_GOLDEN=1 pour écrire les fichiers de référence, puis relisez-les à la main : ils constituent la spécification de ce que le parseur doit produire. Ensuite, tout changement dans la sortie du parseur fait échouer le test jusqu’à ce que quelqu’un l’examine et décide s’il s’agit d’une correction ou d’un bug.
  • Les tests de relecture exécutent le véritable crawl() hors ligne. L’un vérifie que la pagination est suivie jusqu’au bout ; l’autre vérifie qu’un 503 sur la page deux est signalé plutôt que traité silencieusement comme la fin des résultats.
  • Les tests de validation vérifient que les règles détectent effectivement les enregistrements cassés, et que le canari signale un crawl partiel.

Exécution de la suite contre des fixtures enregistrées depuis une petite boutique de test :

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

Cinq tests en une fraction de seconde, sans réseau. Cette rapidité est l’essentiel : des tests aussi rapides s’exécutent à chaque commit. Notre guide sur l’exécution des scrapers en CI/CD couvre le pipeline qui les entoure, y compris pourquoi les tests par fixtures n’ont besoin d’aucun identifiant de proxy.

Le canari : tester le site, pas votre code

Les fixtures figent le site tel qu’il était. Pour savoir quand il change, exécutez le vrai crawl contre le site en direct selon un calendrier et validez le résultat. C’est la fonction canary() ci-dessus : elle renvoie une liste vide quand tout va bien et une liste de problèmes précis quand ce n’est pas le cas.

Exécutez-la une fois par jour, ou avant chaque grand crawl, avec une petite URL de départ et un nombre minimal d’enregistrements réaliste. Quand elle échoue :

  1. Enregistrez la page qui a cassé comme une nouvelle fixture.
  2. Observez l’échec du test par fichier de référence ou de relecture sur celle-ci, ce qui confirme que le test reproduit le problème.
  3. Corrigez le parseur, relisez la nouvelle sortie de référence, et validez ensemble la fixture, la correction et le fichier de référence.

Le changement du site devient un test de non-régression permanent, de sorte que la même casse ne peut plus jamais revenir inaperçue. Cette boucle, canari vers fixture vers correction, est ce qui transforme un scraper fragile en un scraper entretenu.

Un canari vous informe aussi d’échecs qui ne concernent pas du tout l’analyse : une page de blocage qui renvoie le statut 200, une redirection régionale, ou un mur de consentement à la place du contenu. Notre étude sur le taux d’échec silencieux montre à quel point les réponses qui semblent réussies mais portent un contenu erroné sont fréquentes. Pour une surveillance continue de tout un pipeline, voir comment surveiller un pipeline de web scraping.

Règles pratiques

  • N’appelez jamais le réseau depuis les tests unitaires. Faites des fixtures manquantes une erreur, comme le fait ReplaySession.
  • Gardez les fixtures petites et réelles. Une page par cas suffit. Ne retirez rien du HTML : le bruit que vous seriez tenté de couper est souvent ce qui casse un parseur.
  • Relisez les fichiers de référence comme du code. Un fichier de référence non relu ne prouve que le parseur fait ce qu’il faisait hier.
  • Nommez le cas, pas la date. Une fixture appelée “produit épuisé” est utile dans un an ; une appelée “page de mardi” ne l’est pas.
  • Actualisez les fixtures délibérément. Quand le site change, ajoutez de nouvelles fixtures à côté des anciennes plutôt que de les remplacer, sauf si l’ancienne mise en page ne peut jamais revenir.
  • Gardez les secrets hors des fixtures. Les pages enregistrées peuvent contenir des jetons de session ou des données personnelles. Retirez les cookies et vérifiez les corps avant de les valider.

FAQ

Comment tester un scraper web sans solliciter le site en direct ?

Enregistrez de vraies réponses une fois comme fixtures, puis remplacez la session HTTP par une autre qui sert ces enregistrements. Le parseur et le crawler s’exécutent exactement comme en production, mais hors ligne, en millisecondes et avec la même réponse à chaque fois.

Qu’est-ce qu’un test par fichier de référence (golden file test) ?

Un test qui compare la sortie d’une fonction avec une copie stockée et relue de la sortie attendue. Pour un scraper, le fichier de référence est la liste des enregistrements qu’une page enregistrée devrait produire. Tout changement dans la sortie fait échouer le test jusqu’à ce que quelqu’un l’accepte ou le corrige.

Comment savoir quand un changement du site web casse mon scraper ?

Exécutez un canari : un petit crawl réel contre le site en direct, selon un calendrier, avec des règles de validation que tout résultat correct doit satisfaire. Quand il signale des problèmes, enregistrez la nouvelle page comme fixture, corrigez le parseur et conservez la fixture comme test de non-régression.

Les tests de scraper doivent-ils utiliser des proxies ?

Les tests unitaires et de relecture ne devraient pas utiliser le réseau du tout, ils n’ont donc besoin d’aucun proxy ni identifiant. Le canari s’exécute contre le site en direct et devrait utiliser la même configuration de proxy que la production, afin qu’il voie ce que voit la production.

En résumé

Testez votre code et testez le site séparément. Les fixtures enregistrées et une session de relecture permettent de tester le parseur et le crawler hors ligne, rapidement et de manière déterministe, à chaque commit. Un canari quotidien avec des règles de validation vous indique quand le site change. Transformez chaque casse détectée par le canari en fixture, et le scraper devient plus fiable à chaque changement du site, pas moins.

Prêt à commencer ?

Essayez les proxies résidentiels de Shifter, 205M+ IPs, 195+ pays, à partir de 0,75 $/GB.

Commencer