Scrapeless Wiki

How to Test a Scraper – Definition and How It Works

Informational P3 how to test a scraper

Learn how to test a scraper with saved fixtures, contract checks, and canary runs, and which tests must not hit the network.

Testing a scraper is unusual because the system under test depends on something you do not control. The answer is to separate the parts that can be tested deterministically from the parts that must be observed live.

1. Split the Code First

Extraction is a pure function of a response. Fetching is I/O. A scraper that interleaves them cannot be tested at all.

def parse_listing(html: str, base_url: str) -> list[dict]:
    ...          # pure: same input, same output, no network

def collect(urls, client):
    for url in urls:
        yield from parse_listing(client.get(url), url)

This one separation makes the majority of the code unit-testable, and it is the prerequisite for everything below.

2. Fixture Tests Are the Backbone

Save real HTML responses and test extraction against them:

tests/fixtures/
β”œβ”€β”€ example.com/listing-2026-08-18.html
β”œβ”€β”€ example.com/listing-empty.html
β”œβ”€β”€ example.com/product-no-price.html
β”œβ”€β”€ example.com/product-sale-price.html
└── example.com/challenge-page.html
def test_listing_extracts_all_items():
    html = read_fixture("example.com/listing-2026-08-18.html")
    records = parse_listing(html, "https://example.com/listing")
    assert len(records) == 24
    assert all(r["title"] for r in records)
    assert records[0]["price_cents"] == 1999

These run offline, in milliseconds, deterministically. Fixtures are also documentation: the saved file is exactly what the site returned on that date.

3. Fixtures for the Cases That Break Things

The valuable fixtures are the awkward ones, not the happy path:

  • An empty result set β€” must return [], not raise.
  • A missing optional field β€” must yield null, not crash.
  • A sale price alongside a struck-through original.
  • Unicode and accents in text fields.
  • A truncated response, to confirm it is detected.
  • A challenge or block page, so it is not parsed as content.
  • A soft 404 returning 200 with an error page.

The block-page fixture is the one most often missing, and its absence is why scrapers store challenge pages as records.

4. Contract Tests Against the Live Site

Fixtures prove your parser handles known HTML. They cannot tell you the site changed. That needs a small, separate suite that does hit the network:

@pytest.mark.live
def test_live_listing_still_matches_contract():
    html = fetch("https://example.com/listing")
    records = parse_listing(html, "https://example.com/listing")
    assert len(records) >= 10
    assert sum(1 for r in records if r["price_cents"]) / len(records) > 0.9

Mark them so they do not run in ordinary CI β€” they are slow, they can fail for reasons unrelated to your code, and they impose load. Run them on a schedule, and treat a failure as a signal to investigate the site rather than as a broken build.

5. A Canary Page

One URL whose correct extracted values you know, asserted every production run.

It is the cheapest possible early-warning system: when the canary's output changes, extraction changed, and you learn it from one page rather than from a week of nulls in a column.

6. What Not to Test

Do not assert on exact full-page HTML. It changes constantly for irrelevant reasons.

Do not mock the HTTP library. Mock at your own client interface, or the tests verify your mocks rather than your code.

Do not test against live data values. A price changes; asserting price == 1999 against the live site fails tomorrow. Assert shape and plausibility live, exact values only against fixtures.

Do not let unit tests touch the network. A test suite that fails when the internet is slow is not a test suite.

7. The Full Layering

Layer Runs Catches
Unit tests on extraction Every commit, offline Parser regressions
Fixture tests for edge cases Every commit, offline Mishandled shapes
Integration with a fake client Every commit Pipeline wiring
Contract tests, live Scheduled Site changes
Canary, live Every production run Silent breakage
Field fill-rate monitoring Continuous Gradual degradation

The first three are tests; the last three are monitoring. A scraper needs both, and the common mistake is having only one β€” usually neither the fixtures nor the monitoring, just a script that worked once.

8. Summary

Test a scraper by separating pure extraction from fetching, then covering extraction with offline fixture tests that include the awkward cases β€” empty results, missing fields, block pages, truncated bodies.

Add a small live contract suite run on a schedule rather than in CI, a canary page asserted every production run, and continuous per-field fill-rate monitoring β€” because tests catch your regressions while only monitoring catches the site's changes.