Python爬虫学着学着,你会发现一个很奇怪的现象:很多教程都在教你怎么发请求、怎么写解析器、怎么把数据入库,却极少有人认真讲“怎么证明你的爬虫是对的”。解析器写完了没有样本验证,清洗函数靠肉眼观察,数据入库之后才发现字段对不上。这一篇我聊测试驱动爬虫:用解析器、清洗、入库三类测试,把爬虫项目从“能跑就行”变成“敢改敢维护”。零基础刚学完Python基础、想往爬虫方向进阶的同学,或者已经写过几个爬虫但总觉得代码脆弱的初学者,都可以照着这篇把工程化补上。
1. 爬虫不写测试,省下的时间最后都要加倍还回去
1.1 为什么爬虫项目普遍跳过测试
先聊一个很现实的问题:为什么爬虫项目的测试总是被跳过?
我见过太多初学者(包括当年的自己)的逻辑是:爬虫就是个一次性脚本,跑完拿数据就完事了,写测试不是浪费时间吗?但实际情况是,爬虫是最容易出现“悄然变坏”的项目类型。网络请求失败会抛异常,你能立刻发现;但页面结构变了,解析代码可能依然正常运行,只不过select出来的结果全是None,入库之后全是空字符串。这种静默失败最危险。
我总结了三个最典型的翻车场景,每一个都靠人眼盯不出来:
- 页面结构变了。网站改版、某个class名调整、布局换了,你的解析函数表面上还在运行,但返回的字段全是空值。等你跑完整个任务、开始分析数据时才察觉,甚至说不清是哪一天开始错的。
- 脏数据没有兜底。价格字段有时候是“¥1,299.00”,有时候是“价格面议”,有时候干脆是个空标签。如果清洗逻辑没有处理边界,入库的数据就是垃圾场。
- 重复入库。爬虫任务通常不是跑一次就结束的,定时任务、断点续爬、调试时反复运行,同一个URL可能被解析很多次。如果入库逻辑没有去重,数据库会越跑越脏。
这三个问题,靠人眼盯肯定盯不过来。测试就是那个替你把关的“机器人”,任何一次解析结果异常、清洗结果越界、入库行为不对,它都能第一时间报警。
1.2 解析器、清洗、入库:三条检查线各管一摊
我在爬虫项目里只写三类测试,正好对应数据链路中三个最容易出错的位置:
- 解析器测试:输入一段HTML,断言解析结果是不是期望的结构化数据。它守护的是“网页转字典”这一步。
- 清洗测试:输入一个原始字符串,断言清洗后的值、类型、格式都正确。它守护的是“字典转干净字典”这一步。
- 入库测试:把干净数据交给存储层,断言数据库里实际发生了什么。它守护的是“干净字典落库”这一步。
三条线是递进关系:解析器错了,后面全错;解析器对了但清洗错了,入库也是错的;前两步都对但入库逻辑有bug,数据照样出问题。三层都测,才算完整覆盖。
1.3 爬虫场景下的TDD流程要怎么变
TDD的核心流程大家可能听过:红(写一个失败的测试)到绿(用最简实现让测试通过)再到重构(在测试保护下改进代码)。但在爬虫场景里,这个流程要做一点调整:你没法先写测试去测一个还没出现的网站,但你可以先把一小段页面样本固定下来,然后让解析代码去适配这段样本,并用测试保证“这段样本的解析结果永远符合预期”。
换句话说,爬虫里的测试驱动,本质上是让“历史页面”成为你的回归基线。网站改版不可怕,可怕的是改版之后你完全不知道哪里坏了。测试存在,改版影响一眼就能扫出来。
2. 可被测试的工程骨架:从分层设计到固定HTML样本
2.1 分层设计:每层只干一件事
很多零基础同学写爬虫,喜欢把所有逻辑堆在一个脚本里:发请求、解析、清洗、存库全在一起。好处是“看起来简单”,坏处是一旦想写测试,根本无从下手。要让代码可测试,第一步就是拆分层级。
我常用的项目结构长这样:
book_spider/ ├── spider/ │ ├── __init__.py │ ├── parser.py # 解析HTML -> dict │ ├── cleaner.py # 清洗dict -> 干净dict │ └── storage.py # 入库逻辑 ├── tests/ │ ├── __init__.py │ ├── fixtures/ # 存HTML样本 │ │ └── book_detail.html │ ├── test_parser.py │ ├── test_cleaner.py │ └── test_storage.py └── requirements.txt为什么这么分?原则只有一个:请求、解析、清洗、存储,每一层只干一件事,层与层之间通过普通数据结构(比如字典)传递。这样测解析器时不需要真的发网络请求;测清洗函数时不需要关心页面长什么样;测入库时更不需要连生产数据库。每一层都能独立验证,互不牵连。
2.2 最小依赖清单与pytest环境
依赖方面其实很简单,核心就两个:pytest负责测试,beautifulsoup4负责解析HTML。requests看你的抓取需求,sqlite3是标准库,不需要额外安装。
requests==2.32.3 beautifulsoup4==4.12.3 pytest==8.3.2版本号建议锁死。Python项目最怕的就是“昨天还能跑,今天升级依赖后挂了”,锁版本可以把这个风险降到最低。
安装完依赖后,在项目根目录运行pytest tests/ -v,pytest会自动发现tests目录下所有test_*.py文件。如果你用的是VS Code,装好Python插件后可以直接点击测试函数旁边的Run按钮,也挺方便。
2.3 把页面存成fixture,让测试确定下来
这里有个非常关键的实操细节:测试解析器时,千万别在代码里实时请求网站。原因有两个:第一,网站不是你的,随时可能改版、限流、甚至关站,测试会变得不稳定;第二,爬虫测试要的是“确定性输入”,线上页面每次返回的内容都不一样,没法做精确断言。
正确的做法是:把一个正常的页面HTML保存下来,放进tests/fixtures目录。我通常会写一个加载fixture的小函数:
from pathlib import Path FIXTURES_DIR = Path(__file__).parent / "fixtures" def load_fixture(name: str) -> str: return (FIXTURES_DIR / name).read_text(encoding="utf-8")这样测试永远基于一份固定的HTML,只要这份HTML不变,测试结果完全可预测。你甚至可以把这份HTML提交到Git仓库,项目组里任何人拉下来都能跑。
2.4 先跑通一个“链路验证”测试
在正式用例之前,我会先写一个最傻的验证测试,确保fixture能被加载、pytest环境没问题:
def test_fixture_can_be_loaded(): html = load_fixture("book_detail.html") assert "Python编程" in html命令行执行:
pytest tests/ -v看到绿色passed,说明测试链路已经通了。这步的价值是把“测试基础设施”先搭起来,后面再往上加真实用例,出了问题你也能确定是测试代码的问题,还是爬虫代码的问题。
3. 解析器测试:把不稳定的网页变成确定性的断言
3.1 解析器是第一个暴露风险的环节
解析器是爬虫项目里最脆弱的环节。网络请求失败会抛异常,你立刻能发现;但页面结构变了,解析代码可能依然“正常运行”,只不过返回的字段全是None或空字符串。这种静默失败是最危险的。
所以解析器测试的目标就是:给“HTML转字典”这一步加一个强约束。只要页面上该有的信息没被正确解析,测试立刻挂掉,让你第一时间知道改版了。
3.2 正常用例:断言解析结果是结构化字典
拿一个书籍详情页举例。假设要解析书名、价格、作者三个字段。第一个测试跑的是“一切正常”的情况:
from spider.parser import parse_book_detail def test_parse_book_detail_normal(): html = """ <html> <body> <h1 class="title">Python编程:从入门到实践</h1> <span class="price">¥89.00</span> <div class="author">Eric Matthes</div> </body> </html> """ result = parse_book_detail(html) assert result["title"] == "Python编程:从入门到实践" assert result["price"] == "¥89.00" assert result["author"] == "Eric Matthes"这个测试没过,说明解析函数有问题;过了,我们继续加边界条件。
3.3 边界用例:缺失字段与空白文本
页面并不是永远规规矩矩的。有的书没有作者信息,有的标题前后带着换行和空格,有的价格被套在一个很深的div结构里。这些都应该写进测试:
def test_parse_book_detail_missing_author(): html = """ <html> <body> <h1 class="title"> Python编程:从入门到实践 </h1> <span class="price">¥89.00</span> </body> </html> """ result = parse_book_detail(html) assert result["title"] == "Python编程:从入门到实践" assert result["author"] == ""def test_parse_book_detail_nested_price(): html = """ <html> <body> <h1 class="title">深入理解计算机系统</h1> <div class="price-wrapper"> <span class="price">¥139.00</span> </div> </body> </html> """ result = parse_book_detail(html) assert result["price"] == "¥139.00"看明白了吗?先写失败用例,再去实现解析函数,这正是测试驱动的“红”阶段。
3.4 实现时的容错写法与选择器稳定性
配合上面测试的解析实现,我推荐“能找到就返回,找不到就返回空字符串”的容错风格:
from bs4 import BeautifulSoup def parse_book_detail(html: str) -> dict: soup = BeautifulSoup(html, "html.parser") title = soup.select_one("h1.title") price = soup.select_one("span.price") author = soup.select_one("div.author") return { "title": title.get_text(strip=True) if title else "", "price": price.get_text(strip=True) if price else "", "author": author.get_text(strip=True) if author else "", }每个字段都做了None判断,配合get_text(strip=True)把首尾空白去掉。很多人刚学BeautifulSoup时习惯直接soup.find("h1").text,一旦找不到节点就抛AttributeError,整个爬虫直接崩。容错写法虽然多打几行字,但健壮性完全不同。
选择器是另一个容易踩坑的地方。比如页面class名从price变成price highlight,select_one("span.price")依然能匹配到,因为class属性包含price。但如果你写成select_one("span[class='price']"),这种属性精确选择就会挂。所以写选择器时,我会先去浏览器开发者工具里复制真实节点出来,确认class完整值再写测试。选择器宁可写得宽一点,也别写得窄到只能匹配当前一版页面。测试的价值就在这里:你改了选择器,跑一遍测试就知道有没有影响其他用例。
4. 清洗测试:边界条件才是脏数据治理的核心
4.1 清洗函数必须是纯函数
解析完成后,拿到的字段还是“原始状态”:价格是“¥1,299.00”这种字符串,日期是“2024年12月1日”,描述文本里可能还带着HTML标签。清洗层的任务,就是把五花八门的raw值变成规整的、可直接入库的数据。
这里有一个关键设计原则:清洗函数必须是“纯函数”。所谓纯函数,就是同样的输入永远得到同样的输出,不依赖外部状态,不修改传入参数。好处是测试极其好写——不用准备数据库、不用发网络请求,丢一个字符串进去,断言返回值即可。
4.2 参数化测试:一组用例覆盖价格、日期、文本
pytest有个非常好用的功能叫参数化,特别适合清洗场景。拿价格清洗举例:
import pytest from spider.cleaner import clean_price @pytest.mark.parametrize("raw,expected", [ ("¥1,299.00", 1299.0), ("¥89.00", 89.0), ("$1,234.56", 1234.56), ("价格:999", 999.0), ("", None), ("免费", None), ("暂无报价", None), ]) def test_clean_price(raw, expected): assert clean_price(raw) == expected这段代码的价值非常大:你扫一眼用例列表,就知道清洗函数支持哪些格式、遇到什么情况返回None。以后需求变了,比如要支持欧元,直接加一行参数继续跑,谁也没法偷偷改坏你之前调好的逻辑。
对应的实现可以这样写:
import re def clean_price(raw_price: str): if not raw_price: return None match = re.search(r"\d+(?:,\d{3})*(?:\.\d+)?", raw_price) if not match: return None return float(match.group(0).replace(",", ""))4.3 从脏数据里总结边界清单
实际项目里,我会把每遇到一种新脏数据都写成一条测试。下面是一份我常用的清洗层边界清单:
- 空字符串或None:统一返回None或默认值,绝不抛异常。
- 逗号和空格分隔:价格里的千分位逗号、空格,都要移除后再转数值。
- 中文标点:日期里的“年、月、日”、价格里的“¥”符号,需要剥离。
- 单位混杂:“1.2万”“1,000+”“约500”这类模糊表达,要么定义规则转为数值,要么返回None。
- 标签残留:描述字段从网页抓下来带着
<a>、<br>标签,需要strip_html。
与其在代码里靠感觉处理,不如把这些全部列成测试用例。每遇到一种新脏数据,先加用例让它失败,再改代码让它通过——这就是测试驱动清洗函数的方式。
4.4 日期清洗示例
再补一个日期清洗的常见例子,目标是支持“2024年12月1日”这种中文格式,输出“2024-12-01”:
import re def clean_date(raw: str): if not raw: return None match = re.search(r"(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日", raw) if not match: return None year, month, day = match.groups() return f"{year}-{int(month):02d}-{int(day):02d}"测试用例:
@pytest.mark.parametrize("raw,expected", [ ("2024年12月1日", "2024-12-01"), ("发布于2024年1月5日", "2024-01-05"), ("", None), ]) def test_clean_date(raw, expected): assert clean_date(raw) == expected注意,如果网站上给的是“2024-12-01”这种标准ISO格式,目前这个实现会返回None。这是合理的,测试会明确记录这个函数支持什么、不支持什么。后面真要支持ISO格式,再加一条用例,改动也会很安全。
5. 入库测试:用内存SQLite检验重复写入与字段正确性
5.1 入库层最常见的三类错误
解析和清洗都过了,数据终于到了入库这一步。很多人觉得存数据库还不简单?实际上,没有测试保护的入库逻辑,问题一点不比前两层少:
- 同一个URL爬了两次,数据库里出现两条几乎一样的记录。
- 表结构和字段对不上,数据插进去后列错位。
- 类型没转换好,把字符串塞进数值字段,有的库直接报错。
- 多任务并发时连接没处理好,数据互相覆盖。
入库测试要回答的问题是:数据交给存储层之后,数据库的状态是否符合预期?重复写入会不会产生脏数据?
5.2 fixture构建:内存SQLite与一次性表结构
我的建议是测试环境用SQLite的内存模式(:memory:),不碰真实数据。原因很实际:测试需要快速、可重复、不污染数据。内存SQLite连文件都不用创建,每个用例结束后自动消失,互不干扰。
import sqlite3 import pytest from spider.storage import create_schema, save_book @pytest.fixture def conn(): conn = sqlite3.connect(":memory:") create_schema(conn) yield conn conn.close() def test_save_book_creates_one_row(conn): save_book(conn, { "title": "Python编程:从入门到实践", "price": 89.0, "author": "Eric Matthes", "url": "http://books.example.com/1", }) rows = conn.execute("SELECT title, price, author FROM books").fetchall() assert len(rows) == 1 assert rows[0] == ("Python编程:从入门到实践", 89.0, "Eric Matthes")注意这里的关键:pytest的fixture在每个测试前创建表结构、结束后关闭连接,这是测试隔离的基础。如果多个测试共享同一个数据库,数据就会互相干扰,断言就很难写了。
表结构我用URL唯一约束:
def create_schema(conn): conn.execute(""" CREATE TABLE IF NOT EXISTS books ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT UNIQUE, title TEXT, price REAL, author TEXT ) """) conn.commit()5.3 幂等写入与UPSERT测试
生产环境里,爬虫任务经常需要重复执行:今天跑完明天再跑,调试时也可能跑好几遍。如果入库逻辑不处理重复,数据库很快就会堆满垃圾。所以我会专门为“幂等性”写一条测试:
def test_save_same_book_twice_only_stores_once(conn): book = { "title": "测试书", "price": 10.0, "author": "张三", "url": "http://books.example.com/unique", } save_book(conn, book) save_book(conn, book) count = conn.execute("SELECT COUNT(*) FROM books").fetchone()[0] assert count == 1如果你用的是普通INSERT,第二次写入会抛IntegrityError(因为url重复),测试会红。更合理的做法是用“存在则跳过、不存在则插入”的幂等写入:
def save_book(conn, book): conn.execute(""" INSERT OR IGNORE INTO books (url, title, price, author) VALUES (?, ?, ?, ?) """, (book["url"], book["title"], book["price"], book["author"])) conn.commit()如果你希望重复爬取时更新标题、价格等字段,就要用UPSERT语义:存在则更新,不存在则插入:
def upsert_book(conn, book): conn.execute(""" INSERT INTO books (url, title, price, author) VALUES (?, ?, ?, ?) ON CONFLICT(url) DO UPDATE SET title = excluded.title, price = excluded.price, author = excluded.author """, (book["url"], book["title"], book["price"], book["author"])) conn.commit()对应的测试就要断言:第一次插入一条,第二次更新同一条而不新增,并且价格字段被更新。
5.4 测试隔离:绝不碰生产数据
再补一点实战经验:在测试里尽量别碰真实数据库。我见过有人在开发库上直接跑测试,跑完了还得手动清数据,每次都心惊胆战。SQLite内存库零成本,最适合做测试。如果项目用的是MySQL或PostgreSQL,测试环境也要单独建schema或库,在fixture里做事务回滚或清表。
pytest里更稳健的做法是这样:
@pytest.fixture def db_conn(): conn = sqlite3.connect(":memory:", check_same_thread=False) create_schema(conn) yield conn conn.rollback() conn.close()每个用例的数据都在内存库里,测试结束后直接丢库,互不影响。这样无论你跑多少次测试,都不需要担心误删生产数据。
6. 红-绿-重构:一次小型TDD实战演示
6.1 从ImportError开始:先写一条失败测试
很多零基础同学听说TDD,觉得“先写测试再写代码”很反直觉。我用一个特别小的例子演示一下,你会发现这套流程其实很顺。
需求:写一个函数,从礼品卡页面解析面额字符串“$50”为数字50。
第一步不写实现,先写测试:
from spider.parser import parse_gift_card_amount def test_parse_gift_card_amount(): assert parse_gift_card_amount("$50") == 50此时我连parse_gift_card_amount函数都没建。运行pytest,它会报ImportError,测试失败。这就是“红”。
6.2 最小实现让测试转绿
第二步,写一个最笨的实现,让测试通过:
def parse_gift_card_amount(raw: str): return int(raw.replace("$", ""))运行测试,通过了,这就是“绿”。有人觉得这一步实现得太简单?没错,TDD的哲学就是:在测试约束下,用最小代价搞定功能,别提前设计一堆用不上的东西。
6.3 补充边界用例倒逼实现进化
第三步,回到测试里补充边界用例:
@pytest.mark.parametrize("raw,expected", [ ("$50", 50), ("$1,000", 1000), ("$50.5", 50.5), ("", None), ]) def test_parse_gift_card_amount(raw, expected): assert parse_gift_card_amount(raw) == expected第二条用例“$1,000”就会失败,说明当前实现没处理千分位逗号。这时候再去扩展实现:
def parse_gift_card_amount(raw: str): if not raw: return None cleaned = raw.replace("$", "").replace(",", "").strip() try: return float(cleaned) except ValueError: return None所有用例都通过。整个过程下来,你既拥有了一个被测试保护的函数,又让实现被需求逐步驱动着长出来,而不是一开始就凭感觉写一大堆代码。这套节奏用在正式爬虫项目里,体会会更明显。
6.4 日常跑测试的节奏与应对红测试的心态
项目搭好之后,我日常的节奏是:
- 爬虫代码有改动,先跑
pytest tests/ -v看是否全绿。 - 出现红的时候,第一反应不是“改代码把测试弄绿”,而是先想清楚:是测试预期过时了,还是代码改坏了?两者处理方式完全不同。
- 网站改版了,先打开fixtures里的HTML样本,看哪些字段变了,先改测试,再改代码,顺序不能反。
这里有个很容易搞反的地方:很多人看到测试红了就赶紧删用例,说“这个场景不重要”。我的建议是别急,先搞清楚为什么红。测试红了恰恰说明页面样本、解析逻辑、预期结果三者之间有冲突,这正是调试的好时机。直接删用例等于关掉警报器,风险只会更大。
最后分享一个我自己的习惯:每次页面改版,我会把抓到的HTML另存一份到tests/fixtures,文件名带上日期。这些HTML样本比任何文档都真实地记录了网站的变化轨迹。测试驱动爬虫这套做法,前期会多花一点时间写用例,但回报是长期的——你的爬虫不再是“跑完就扔”的一次性脚本,而是可以放心维护、随时修改的工程。这个转变,才是爬虫入门到进阶真正的分水岭。