截至2026年10月7日,Shifter拥有规模最大的美国实时IP池,超过Oxylabs和Bright Data,而成本只是其一小部分。Shifter 现在拥有数量最多的美国活跃 IP 资源池

查看基准测试

数据抓取

测试爬虫:Fixtures、重放与回归套件

网站一变化,爬虫就会失效,而直接访问真实网站的测试也会随之失效。如何用录制的 fixtures、重放和每日金丝雀测试来测试爬虫。

Chris Collins

2026年10月9日 · 4 分钟阅读

大多数爬虫的测试方式都是一样的:有人针对真实网站运行一遍,看看输出结果,觉得看起来没问题就算了。这种方式在出问题之前都管用。网站改了一个类名,爬虫返回空字段,直到下游的报告出错才有人注意到。

难点在于,爬虫依赖的是你无法控制的东西。调用真实网站的测试又慢又不稳定,答案还会在没有任何预警的情况下发生变化,于是团队干脆不再运行这些测试。而那些从不接触真实页面的测试则会永远通过,哪怕生产环境早已出了问题。

解决办法是把问题一分为二。针对永不改变的录制页面测试你的代码,再用一个简短的每日检查单独测试网站本身,告诉你网站何时发生了变化。本指南将搭建这两部分内容,并附上可供你直接套用的代码:录制固件、离线运行整个爬虫的回放会话、针对解析器的黄金文件测试,以及针对真实网站的金丝雀检测。

三种可能出错的情况

当爬虫产出错误数据时,原因几乎总是以下三种之一,而每一种都需要不同类型的测试:

出了什么问题示例能捕获它的测试
你的解析器重构把 “1,099.00” 变成了 1.099针对录制页面的黄金文件测试
你的爬取逻辑翻页在第一页后就停止;503 被当作空页面处理离线运行爬虫的回放测试
网站本身类名变了,字段位置变了,出现了拦截页面针对真实网站的每日金丝雀检测

前两种是你代码中的 bug,应该在部署前,在无需网络、几秒内就能运行完的测试中被发现。第三种根本不是 bug;而是外部世界发生了变化,需要的是监控而不是单元测试。

把抓取和解析分开

测试从结构开始。如果解析页面的函数同时也负责抓取页面,那么每个测试都得处理网络问题。要让解析保持为纯函数:输入 HTML,输出记录。爬虫本身则变成一个很薄的循环,负责抓取页面并把它们交给解析器。

下面是我们要测试的爬虫,用于一个带分页的列表网站:

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() 把 session 作为参数传入,而不是自己创建一个。正是这一个选择让它变得可测试:生产环境中你传入一个 requests.Session,测试中你传入一个提供录制页面的替代对象。

一次性录制固件

固件是一份真实响应,保存到磁盘上并附带足够的元数据以便精确重放:URL、重定向后的最终 URL、状态码和编码。请以原始字节而非解码后的文本形式保存响应体,这样编码问题会被重现出来,而不是被掩盖掉。

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 具有和 requests.Session 相同的 get() 方法,所以 crawl() 根本分辨不出区别。它对任何未被录制的 URL 都会抛出错误,这一点很重要:如果测试在固件缺失时悄悄回退到真实网络,那它在你本地机器上会很快,但在 CI 中就会变慢且不稳定。

录制的不应只是正常页面。那些在生产中会弄坏爬虫的情况才最值得保留:分页的最后一页、空的搜索结果、售罄的商品、503 响应、拦截页面。每一种都要变成一个永久的测试用例。如果你在生产环境中归档原始响应,正如我们关于以 WARC 格式归档抓取页面的指南中所描述的那样,那么这些归档就是来自真实故障的现成固件来源。

验证任何正确爬取都必须满足的条件

黄金文件检查的是精确的输出。验证检查的则是无论网站内容如何变化都必须成立的规则:每条记录都有 ID,ID 唯一,名称不为空,价格是合理的数字,并且本次爬取返回的记录数大致符合预期。

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]

同一个 validate() 在三个地方运行:在测试套件中、在每日金丝雀检测中,以及理想情况下,在数据写入之前的每次生产运行中。我们关于抓取数据中的 schema 漂移的指南对字段级别随时间推移的检查做了更深入的探讨。

测试套件

有了固件和验证逻辑之后,测试本身会很简短。它们只用标准库:

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()
  • 黄金文件测试会解析每一个录制的页面,并把结果与一份存储好的 JSON 文件进行比较。第一次运行时,用 UPDATE_GOLDEN=1 来写入黄金文件,然后手动检查它们:它们就是解析器应该产出什么的规格说明。此后,解析器输出的任何变化都会导致测试失败,直到有人查看并判断这是一次修复还是一个 bug。
  • 回放测试离线运行真实的 crawl()。其中一个检查分页是否被追踪到底;另一个检查第二页上的 503 是否被正确报告,而不是被悄悄当成结果的结束。
  • 验证测试检查这些规则是否真的能捕获到损坏的记录,以及金丝雀检测是否能报告出一次不完整的爬取。

对从一个小型测试商店录制的固件运行该测试套件:

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

五个测试在不到一秒的时间内完成,且不涉及任何网络。这种速度正是关键所在:如此快的测试可以在每次提交时都运行一遍。我们关于在 CI/CD 中运行爬虫的指南涵盖了围绕这些测试的整个流水线,包括为什么固件测试根本不需要代理凭据。

金丝雀检测:测试的是网站,不是你的代码

固件把网站冻结在了录制时的状态。要知道网站何时发生了变化,就要按计划对真实网站运行真正的爬取并验证结果。这就是上面的 canary() 函数:一切正常时它返回空列表,出问题时它返回一份具体问题的列表。

每天运行一次,或者在每次大规模爬取之前运行,使用一个小的起始 URL 和一个现实的最小记录数。当它报错时:

  1. 把出问题的页面录制下来,作为一个新的固件。
  2. 观察黄金文件测试或回放测试在该固件上失败,这能确认测试确实复现了这个问题。
  3. 修复解析器,审查新的黄金输出,然后把固件、修复和黄金文件一起提交。

网站的这次变化就变成了一个永久的回归测试,这样同样的问题就再也不会在无人察觉的情况下卷土重来。从金丝雀检测到固件再到修复,这个循环正是把一个脆弱的爬虫变成一个被妥善维护的爬虫的关键。

金丝雀检测还能告诉你一些根本与解析无关的故障:返回状态码 200 的拦截页面、地区重定向,或者用同意弹窗替代了内容。我们关于静默失败率的研究表明,看起来成功实则携带错误内容的响应有多么常见。要对整条流水线进行持续监控,请参阅如何监控网页抓取流水线。

实用规则

  • 永远不要在单元测试中调用网络。 让缺失的固件成为一个错误,就像 ReplaySession 所做的那样。
  • 保持固件小而真实。 每种情况一个页面就够了。不要从 HTML 中删减任何内容:你想删掉的那些“噪音”往往正是弄坏解析器的罪魁祸首。
  • 像审查代码一样审查黄金文件。 一份未经审查的黄金文件只能证明解析器和昨天的行为一致。
  • 以用例命名,而不是以日期命名。 一个叫做“售罄商品”的固件一年后依然有用;一个叫做“周二的页面”的固件则不然。
  • 有意识地刷新固件。 当网站发生变化时,在旧固件旁边新增固件,而不是直接替换掉,除非旧版布局绝不可能再出现。
  • 不要把密钥放进固件。 录制的页面可能包含会话令牌或个人数据。在提交之前要剥离 cookie 并检查响应体内容。

常见问题

如何在不访问真实网站的情况下测试网页爬虫?

先把真实响应录制成固件,然后用一个提供这些录制内容的对象替换掉 HTTP 会话。解析器和爬虫的运行方式和生产环境中完全一样,只是离线进行,耗时仅为毫秒级,且每次结果都一致。

什么是黄金文件测试?

一种将函数输出与一份存储好、经过审查的预期输出副本进行比较的测试。对于爬虫来说,黄金文件就是一个录制页面应当产出的记录列表。输出的任何变化都会导致测试失败,直到有人接受或修复它。

我怎么知道网站变化弄坏了我的爬虫?

运行一个金丝雀检测:按计划对真实网站进行一次小规模的真实爬取,并用任何正确结果都必须满足的验证规则来检查它。当它报告问题时,把这个新页面录制成固件,修复解析器,并把这份固件保留为回归测试。

爬虫测试应该使用代理吗?

单元测试和回放测试根本不应该涉及网络,所以它们不需要任何代理或凭据。金丝雀检测是针对真实网站运行的,应该使用和生产环境相同的代理配置,这样它看到的内容才会和生产环境看到的一致。

结论

把代码测试和网站测试分开进行。录制的固件和回放会话让你能够离线、快速、确定性地测试解析器和爬虫,在每次提交时都能运行。带有验证规则的每日金丝雀检测会告诉你网站何时发生了变化。把金丝雀检测发现的每一次故障都变成一个固件,这样爬虫就会随着网站的每一次变化变得更加可靠,而不是更加脆弱。

准备好开始了吗?

试用 Shifter 住宅代理,205M+ 个 IP,195+ 个国家,低至 $0.10/GB。

立即开始