爬虫回归测试实战:在网页改版后发现静默漏采

用保存的 HTML 和明确业务预期编写 pytest 回归用例,发现促销价错取、字段缺失和列表少项,并区分解析测试与浏览器流程检查。

亿牛云技术团队2026年10月28日5 分钟阅读

最难发现的爬虫故障不是异常退出,而是程序正常结束、价格却取成原价,或者列表少了一项。只断言“返回了一个对象”,这类错误仍会通过。

本文围绕一个商品解析器建立回归测试:同一商品在两种布局下价格应一致,缺少当前价时不能退回原价,列表覆盖需要与明确样本比对。所有页面和代码都在正文内;这里教读者编写和运行测试,不需要作者另建测试工程。

安装与确定检查层级

使用 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。

测试很慢。 将纯解析保留在离线层,仅把必要动作放到浏览器层。不要让每个字段用例都重新启动浏览器。

用例数量多但仍然不放心。 回到最初的价格、身份和覆盖问题,检查测试能否在对应错误实现下失败。通过数量不能证明覆盖了真正风险。

回归测试应保存读者想要的业务含义,让代码变化有一个独立参照。它的目标不是证明页面永远不变,而是在变化破坏结果时及时指出哪条约定失效。

需要企业代理方案?

我们可根据目标站点、并发规模与稳定性目标提供定制方案。