如何使用pytest测试网络爬虫:实用指南
Expert in Web Scraping Technologies
TL;DR:
- 将
fetch从parse中分离,解析变成一个纯函数 — 输入 HTML 字符串,输出记录 — 可在没有网络和没有模拟库的情况下进行测试。 - 离线套件在 0.16 秒内运行了 14 个测试;两个现场合同测试默认被取消选择,单独运行需要 0.88 秒。
- 覆盖率报告为 85%,唯一未覆盖的行是
fetch()和scrape()。这是预期的形状,而不是需要弥补的缺口。 - 让字段解析器抛出错误。重命名一份夹具中的 CSS 类在命名行上产生了
ValueError: missing price,而不是 20 行空值。 - 一项夹具测试证明了解析器处理你保存的 HTML。只有针对实时页面的合同测试才能检测到网站是否发生了变化。
- 一个绿色套件无法告诉你目标仍然提供该 HTML,仍然进行服务器端渲染,或仍然根本返回一个页面。
- 在 Scrapeless 免费计划 上对实际渲染页面运行直播部分。
抓取器以大多数软件无法比拟的方式中断:仓库中没有任何更改,代码停止工作,因为其他人编辑了一个页面。这使得通常的直觉 — 编写测试,观察它们变绿,发布 — 是必要的但不充足的,并且改变了测试应该检查的内容。
下面的套件覆盖了一个小型书籍目录抓取器。它分为两部分,回答不同的问题:询问解析器是否正确的离线部分,以及询问网站是否仍然符合解析器预期的实时部分。
抓取器测试的实际用途
三个失败值得分开,因为其中只有两个是你的:
| 失败 | 检测方法 | 示例 |
|---|---|---|
| 解析器错误处理有效 HTML | 离线单元测试 | 带有货币符号的价格变成字符串,而不是浮点数 |
| 网站更改了其标记 | 实时合同测试 | price_color 变成 product-price |
| 网站停止提供页面 | 无 | 响应是一个挑战页面或一个空壳 |
大多数发布的抓取器测试建议涵盖第一行。第二行需要一个与网站进行交互的测试;第三行根本无法被测试套件捕获,在构建一个之前值得大声说出来。
安装
bash
python3 -m venv .venv
./.venv/bin/pip install pytest pytest-cov responses parsel requests
此套件运行的版本:
text
pytest 9.1.1
pytest-cov 7.1.0
responses 0.26.3
parsel 1.11.0
requests 2.34.2
lxml 6.1.3
responses 包含在内,因为 HTTP 模拟是通常下一个问题。解析测试不需要任何它,原因是结构上的,而非风格上的。
使解析可测试的拆分
一个函数接触网络。其他一切都接受字符串。
python
import requests
from parsel import Selector
CATEGORY_URL = "https://books.toscrape.com/catalogue/category/books/mystery_3/index.html"
RATINGS = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}
def fetch(url: str = CATEGORY_URL) -> str:
"""The only function that touches the network."""
response = requests.get(url, timeout=30)
response.raise_for_status()
return response.content.decode("utf-8")
def parse_price(raw: str | None) -> float:
if not raw:
raise ValueError("missing price")
return float(raw.replace("£", "").strip())
def parse_rating(css_class: str | None) -> int:
word = (css_class or "").replace("star-rating", "").strip()
if word not in RATINGS:
raise ValueError(f"unknown rating: {word!r}")
return RATINGS[word]
def parse(html: str) -> list[dict]:
"""Pure function: HTML in, records out."""
sel = Selector(text=html)
return [{
"title": card.css("h3 a::attr(title)").get(),
"price": parse_price(card.css("p.price_color::text").get()),
"rating": parse_rating(card.css("p.star-rating::attr(class)").get()),
"in_stock": bool(card.css("p.instock.availability").get()),
} for card in sel.css("article.product_pod")]
parse 不涉及 I/O、时钟和全局状态,因此测试它根本不需要模拟。标准库的模拟工具 在这里非常出色,而且大多数情况下都是不必要的 — 一个已经将其输入作为参数的函数不需要其依赖项进行打补丁。
注意这两个字段解析器 抛出 错误,而不是返回 None。这个单一的决定使得静默的标记变化变成了一个命名的失败。
将真实页面保存为夹具
测试需要在其下面不发生变化的 HTML,因此保存一个真实响应一次并提交它。
python
import requests, pathlib
response = requests.get(CATEGORY_URL, timeout=30)
response.raise_for_status()
pathlib.Path("fixtures/mystery.html").write_bytes(response.content)
text
fixture saved: 50388 bytes
通过会话范围的夹具加载它,因此该文件在整个运行过程中只读取一次:
python
# tests/conftest.py
import pathlib
import pytest
FIXTURES = pathlib.Path(__file__).parent.parent / "fixtures"
@pytest.fixture(scope="session")
def mystery_html() -> str:
return (FIXTURES / "mystery.html").read_text(encoding="utf-8")
提交夹具。它是记录在解析器编写时页面外观的记录,与新副本的差异是查看网站更改的最快方法。
编写值得拥有的断言
对值和不变量进行断言,而不是某个东西回来了这一事实。
python
import pytest
from bookscraper import parse, parse_price, parse_rating
def test_parse_returns_every_card(mystery_html):
assert len(parse(mystery_html)) == 20
def test_record_shape(mystery_html):
record = parse(mystery_html)[0]
assert set(record) == {"title", "price", "rating", "in_stock"}
assert record["title"] == "Sharp Objects"
assert record["price"] == 47.82
assert record["rating"] == 4
assert record["in_stock"] is True
def test_every_price_is_positive(mystery_html):
assert all(r["price"] > 0 for r in parse(mystery_html))
@pytest.mark.parametrize("raw,expected", [("£47.82", 47.82), ("£9.99", 9.99), ("£100.00", 100.0)])
def test_parse_price(raw, expected):
assert parse_price(raw) == expected
def test_parse_price_rejects_missing():
with pytest.raises(ValueError):
parse_price(None)
def test_parse_rating_rejects_unknown():
with pytest.raises(ValueError, match="unknown rating"):
parse_rating("star-rating Eleven")
def test_empty_html_yields_no_records():
assert parse("<html><body></body></html>") == []
三种断言正在执行不同的工作。确切的值锁定一个已知记录。不变量(all prices > 0,介于 1 和 5 之间的评分)适用于夹具尚未包含的记录。而 pytest.raises 案例锁定了 失败 行为,这正是标记变化所涉及的部分。
将实时测试排除在默认运行之外
合同测试访问真实网站,因此速度较慢,并依赖于其他人的正常运行时间。一个标记使它们不在快速循环中,而不需要删除它们。
python
# tests/test_selector_contract.py
import pytest
from bookscraper import fetch, parse
pytestmark = pytest.mark.live
@pytest.fixture(scope="module")
def live_html():
return fetch()
def test_live_page_still_yields_records(live_html):
assert len(parse(live_html)) == 20
def test_live_selectors_match_fixture_shape(live_html, mystery_html):
live, saved = parse(live_html), parse(mystery_html)
assert {r["title"] for r in live} == {r["title"] for r in saved}
ini
[pytest]
pythonpath = .
testpaths = tests
markers =
live: hits the real site; excluded from the default run
addopts = -m "not live"
在配置中注册标记是阻止 pytest 的标记系统 对未知标记发出警告的原因,而 addopts 使排除成为默认设置,而不是每个人都必须记住的内容。
text
$ pytest -q
.............. [100%]
14 passed, 2 deselected in 0.16s
$ pytest -q -m live
.. [100%]
2 passed, 14 deselected in 0.88s
拆分是重要的,因为这两个测试套件属于不同的调度。离线的 14 在每次提交时运行。在线的 2 基于时间运行,它们的失败意味着是网站发生了变化而不是代码——这就是实际测试金字塔在快速隔离测试和跨越真实边界的少数测试之间划定的界限。
将覆盖率视为架构检查
text
$ pytest -q --cov=bookscraper --cov-report=term-missing
Name Stmts Miss Cover Missing
----------------------------------------------
bookscraper.py 26 4 85% 14-16, 47
----------------------------------------------
TOTAL 26 4 85%
14 passed, 2 deselected in 0.50s
第 14-16 行是 fetch 的主体;第 47 行是 scrape,它组合了这两个。每一行解析逻辑都被覆盖,而每一行未覆盖的则是与网络交互的行。
这是我们想要的数字。在这里追求 100% 意味着伪造 requests 来证明 requests.get 被调用,这测试了该模拟。关于抓取器的覆盖率报告的有用解读是哪些行缺失,以及它们是否是你故意保留在边缘的行。
在页面上测试抓取器,该页面是客户端渲染的?Scrapeless 免费计划 覆盖了足够的会话,以捕获一个值得提交的渲染固定装置。
标记更改是什么样的
获取保存的固定装置,重命名一个类,就像网站重设计一样,然后运行解析器进行处理:
python
html = pathlib.Path("fixtures/mystery.html").read_text(encoding="utf-8")
drifted = html.replace("price_color", "product-price")
pathlib.Path("fixtures/mystery_drifted.html").write_text(drifted, encoding="utf-8")
print("price_color occurrences:", html.count("price_color"), "->", drifted.count("price_color"))
text
price_color occurrences: 20 -> 0
text
raw = None
def parse_price(raw: str | None) -> float:
if not raw:
> raise ValueError("missing price")
E ValueError: missing price
bookscraper.py:21: ValueError
=========================== short test summary info ============================
FAILED tests/test_drift_demo.py::test_parse_survives_price_class_rename - Val...
1 failed in 0.11s
失败指明了字段和行。如果 parse_price 在缺失匹配项时返回 None,那么运行将完成并写入 20 条记录,价格为 null——而管道将报告成功。HTML 规范的类属性完全不提供任何稳定性保证;它是表现性的,将类名视为合同意味着当合同被破坏时,解析器必须发出警告。
出于同样的原因,在解析后验证记录的形状值得与这些测试配对——我们关于验证抓取数据的指南覆盖了同一问题的运行时部分。
测试套件的停止点
绿色的测试套件意味着解析器在 fixtures/ 中处理 HTML。它并未提及三件会在生产中破坏抓取器的事情:
- 页面现在在客户端渲染。 一个普通客户端接收的 HTML 是一个外壳;选择器是正确的,却什么也匹配不上。
- 响应不是页面。 一条挑战或插页以 HTTP 200 到达,而仅内容声明可以在不包含任何记录的标记上通过。
- 固定装置已过时。 它仍然可以干净地解析,因为它是一个文件,这正是它不能告诉你网站已移动的原因。
前两个需要一个真实的浏览器,而不是一次真实的请求。通过 Scrapeless 抓取浏览器 捕获固定装置意味着保存的 HTML 是浏览器组装的 DOM,因此离线套件测试的正是在线运行将看到的同一文档。合同测试是基于计时器的一小部分会话,而不是每次提交的成本,定价 列出了这个节奏带来的成本。第三个问题由合同测试回答,比较在线标题与固定装置的标题——这是可用的最便宜的早期警告,也是为什么这两个测试存在的原因。
故障排除
fixture 'mystery_html' not found — 固定装置位于 tests/conftest.py,pytest 仅在测试目录或其上方发现 conftest.py。
ModuleNotFoundError: No module named 'bookscraper' — 在 pytest.ini 中设置 pythonpath = .,或以可编辑模式安装该包。测试从 rootdir 运行,而不是从 tests/。
PytestUnknownMarkWarning: Unknown pytest.mark.live — 在配置的 markers 部分注册标记。
在线测试失败而离线套件通过 — 这是合同测试在工作。对新页面与已提交的固定装置进行差异化比较,然后再接触解析器。
结论
使抓取器可测试的设计决策不是测试框架,而是拆分:fetch 返回一个字符串,parse 接受一个字符串,而所有有趣的事情发生在一个纯函数中。覆盖率确认了形状——85%,fetch 和 scrape 是唯一未覆盖的行。
除此之外,两种习惯带来了大多数价值。让字段解析器引发错误,这样重命名的类可以在命名行生成 ValueError: missing price 而不是二十个空价格。并且在一个标记后保持一个小的实时合同套件,因为测试只能告诉你解析器在你保存的页面上仍然有效。
准备好测试一个在解析之前渲染页面的抓取器吗? 从 Scrapeless 的免费计划开始 并从真实的 DOM 捕获一个测试数据。
常见问题
问:如何在不访问网站的情况下对网页抓取器进行单元测试?
将获取与解析分开并测试解析。如果 parse 接受一个 HTML 字符串并返回记录,则保存的测试数据文件就是整个测试设置——不需要模拟库,不需要 HTTP 拦截。上面的 14 个离线测试在 0.16 秒内运行,因为它们都没有打开套接字。
问:我需要像 responses 或 unittest.mock 这样的模拟库吗?
仅对调用网络本身的代码需要。一旦解析接受字符串参数,就没有东西需要修补。当你想测试获取层自己的行为——状态处理、超时、头部构建——而不是测试解析时,使用 HTTP 模拟。
问:我如何检测网站是否更改了我的选择器?
一个获取实时页面并与提交的测试数据进行比较的合同测试。上面的 test_live_selectors_match_fixture_shape 断言标题集合匹配;当它停止匹配时,网站移动了。将其放在一个标记后,以便它按计划运行,而不是在每次提交时运行。
问:抓取器测试应该在 CI 中运行吗?
离线测试在每次提交时运行——它们是确定性且快速的。实时合同测试不应阻止合并,因为失败意味着其他人的网站已更改,而拉取请求是无辜的。改为在计时器上运行它们并在结果上发出警报。
问:抓取器应瞄准什么覆盖率?
关注缺失的行,而不是百分比。85% 的覆盖率有 fetch 和 scrape 未覆盖是一个良好的测试套件;同样的 85% 覆盖率如果解析分支未覆盖就不是。推动到 100% 通常意味着断言一个模拟被调用,这对数据没有任何证明意义。
问:当字段缺失时,解析器应返回 None 还是引发错误?
引发错误。一个 None 会作为空值传播到数据库,运行报告成功,因此失败会在几天后显现为数据缺失。引发错误会在标记更改的那一刻命名字段和行,这正是将一个重命名的类变为上面的 ValueError: missing price 的原因。
在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。



