爬虫回归测试实战:在网页改版后发现静默漏采
用保存的 HTML 和明确业务预期编写 pytest 回归用例,发现促销价错取、字段缺失和列表少项,并区分解析测试与浏览器流程检查。
最难发现的爬虫故障不是异常退出,而是程序正常结束、价格却取成原价,或者列表少了一项。只断言“返回了一个对象”,这类错误仍会通过。
本文围绕一个商品解析器建立回归测试:同一商品在两种布局下价格应一致,缺少当前价时不能退回原价,列表覆盖需要与明确样本比对。所有页面和代码都在正文内;这里教读者编写和运行测试,不需要作者另建测试工程。
安装与确定检查层级
使用 Python 3.12:
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install pytest beautifulsoup4解析规则优先在离线 HTML 层检查,运行快、输入可控。登录、点击、异步加载和分页触发需要另外的浏览器流程检查,不能靠静态样本证明。pytest 的参数化与异常断言见 官方使用文档。
先约定结果:p1、名称“红茶”、现价 12.90 CNY,使用分表示为 1290。促销页可以出现 16.00 原价,但不能改变当前价含义。
先用业务预期暴露一个错误
创建 test_extraction.py,放入完整的页面、初始解析器和用例:
from decimal import Decimal, InvalidOperation
from bs4 import BeautifulSoup
import pytest
V1 = """<article data-product-id="p1" data-currency="CNY">
<h1 data-testid="product-title">红茶</h1>
<span class="price" data-testid="current-price">12.90</span></article>"""
V2 = """<section data-product-id="p1" data-currency="CNY">
<h2 data-testid="product-title">红茶</h2>
<del class="price">16.00</del>
<strong data-testid="current-price">12.90</strong></section>"""
def parse_product(html):
root = BeautifulSoup(html, "html.parser").select_one("[data-product-id]")
if root is None:
raise ValueError("不是商品页")
return {
"id": root["data-product-id"],
"name": root.select_one('[data-testid="product-title"]').get_text(strip=True),
"price_minor": int(Decimal(root.select_one(".price").get_text(strip=True)) * 100),
"currency": root["data-currency"],
}
def parse_catalog(html, expected_ids):
soup = BeautifulSoup(html, "html.parser")
ids = [node.get("data-product-id") for node in soup.select('[data-testid="product-card"]')]
if len(ids) != len(set(ids)) or set(ids) != set(expected_ids):
raise ValueError("目录覆盖与预期不一致")
return ids
@pytest.mark.parametrize("html", [V1, V2])
def test_product_layouts_keep_business_meaning(html):
assert parse_product(html) == {
"id": "p1", "name": "红茶", "price_minor": 1290, "currency": "CNY"
}
def test_missing_current_price_does_not_use_original():
changed = V2.replace('data-testid="current-price"', 'data-testid="retired-price"')
with pytest.raises(ValueError):
parse_product(changed)
def test_nonnumeric_current_price_is_rejected():
changed = V1.replace(
'<span class="price" data-testid="current-price">12.90</span>',
'<span class="price" data-testid="current-price">pending</span>',
)
with pytest.raises(ValueError):
parse_product(changed)
def test_access_page_is_not_a_product():
with pytest.raises(ValueError):
parse_product("<h1>请完成访问验证</h1>")
def test_catalog_requires_both_expected_products():
html = '<div data-testid="product-card" data-product-id="p1"></div>'
with pytest.raises(ValueError):
parse_catalog(html, expected_ids={"p1", "p2"})
def test_catalog_accepts_complete_distinct_products():
html = """<div data-testid="product-card" data-product-id="p1"></div>
<div data-testid="product-card" data-product-id="p2"></div>"""
assert parse_catalog(html, {"p1", "p2"}) == ["p1", "p2"]运行:
python -m pytest -q按代码推导,V2 会取到 16.00,缺少现价时仍会返回原价,而非数字金额会抛出尚未统一的异常类型。这三类测试应失败。这是预期的回归演示,不是本文报告的实际运行结果。初始实现没有异常并不代表业务正确,测试的价值就在于暴露这个差异。
修复的是价格含义,不只是选择器名称
在同一文件中仅替换 parse_product 为下面的实现:
def parse_product(html):
root = BeautifulSoup(html, "html.parser").select_one("[data-product-id]")
if root is None:
raise ValueError("不是商品页")
title = root.select_one('[data-testid="product-title"]')
price = root.select_one('[data-testid="current-price"]')
if title is None or not title.get_text(strip=True) or price is None:
raise ValueError("缺少名称或当前价")
try:
amount = Decimal(price.get_text(strip=True))
except InvalidOperation as error:
raise ValueError("invalid_current_price_format") from error
if not amount.is_finite() or amount < 0 or amount.as_tuple().exponent < -2:
raise ValueError("价格不符合两位小数合同")
if root.get("data-currency") != "CNY":
raise ValueError("币种不符合本任务")
return {
"id": root["data-product-id"],
"name": title.get_text(strip=True),
"price_minor": int(amount * 100),
"currency": root["data-currency"],
}再次执行同一测试命令。正确实现应满足两种布局、现价缺失拒绝、访问页拒绝和列表覆盖等预期。
这里通过稳定字段标记读取现价,避免商品容器样式与 h1/h2 改变影响价格语义。真实站点没有这些属性时,选择与其实际现价区域对应的规则,并保留“原价不能替代现价”的用例,而不是要求所有网站统一使用测试属性。
样本应该怎样选择
至少包含一种正常布局、一种有业务歧义的布局和明确失败输入。对于列表,保存预期 ID 集合,比只保存“应该有十条”更容易发现错抓和重复。
同一个解析函数生成 expected 再比较自己,没有独立判断价值。预期应由原始页面和业务含义人工确定,保留其来源、适用范围与必要说明。
网页样本要保留影响目标行为的结构,但移除账号凭据与无关个人数据。样本精简后重新确认原故障仍存在,不能删掉导致错位的原价节点,最后得到一份永远通过的测试。
浏览器层还需要检查什么
静态测试不能验证页面是否等待到正确状态。对动态任务增加一条最小流程:打开明确入口、执行筛选或翻页、等待结果更新、提取并检查业务 ID 集合。
该检查应使用固定输入或明确的测试站。不要把每天改变的线上商品总量作为长期固定断言。若目标只能在线访问,应把站点变化、环境故障与解析回归分开报告。
截图可以帮助理解当前页面,但不能代替文本、SKU 与字段结果的断言。页面看起来正常时,也可能拿到了旧响应或错误金额。
接入日常维护与常见问题
在现有 CI 中加入同一 pytest 命令即可建立离线规则检查,不需要为了这几条用例部署完整浏览器集群。解析器或样本变更时看 diff,确认修改对应哪条业务预期。
改版后测试仍过,线上却漏采。 检查样本是否覆盖了实际变化,发现层与等待层是否根本没被测试。补充真正失败的页面或过程,不盲目增加断言数量。
测试失败后直接更新 expected。 先确认业务是否真的改变。原价变成现价不是合理的“更新快照”,可能是错误规则被固定下来。
所有异常都符合 raises。 使用明确输入和异常类型,避免一个导入错误或无关 TypeError 冒充业务拒绝。本文约定的数据错误是 ValueError。
测试很慢。 将纯解析保留在离线层,仅把必要动作放到浏览器层。不要让每个字段用例都重新启动浏览器。
用例数量多但仍然不放心。 回到最初的价格、身份和覆盖问题,检查测试能否在对应错误实现下失败。通过数量不能证明覆盖了真正风险。
回归测试应保存读者想要的业务含义,让代码变化有一个独立参照。它的目标不是证明页面永远不变,而是在变化破坏结果时及时指出哪条约定失效。
需要企业代理方案?
我们可根据目标站点、并发规模与稳定性目标提供定制方案。