资讯动态

爬虫代码如何用单元测试与集成测试加固?实战解析

发布时间:2026/10/9 18:42:35 来源:尧图企业网站定制
1. 为什么爬虫必须做测试而不是“跑通就行”做爬虫的人大多有过这种经历脚本写完那一刻跑得非常顺畅上线后半个月也没出问题结果某天数据方突然说“你们抓的数据不对”一查才发现目标站点悄悄改了页面结构旧选择器全部失效抓回来的全是空值和历史残留。这个项目《爬虫测试单元测试与集成测试实践》就是要把这类问题彻底摁死核心目标很简单给爬虫代码补上单元测试和集成测试两层防线让每次改动、每轮上线都有可验证的依据而不是靠“跑一遍没报错”来赌运气。这套实践适合两种人参考一是被线上爬虫折腾过、想规范化的工程师二是准备把爬虫做成正式数据源、马上要接入接口或定时任务的同学。1.1 爬虫代码真正容易挂的地方先聊聊爬虫项目里最常见的翻车点你会发现它们都不是“语法错误”级别的低级 bug而是那种非常隐蔽、非常难复现的逻辑问题。首当其冲的是页面结构变化。今天是div.product-title明天就可能变成h2.item-name又或者把详情页里原来只有一个的span.price拆成了两个节点。如果你没有测试这类变化不会直接报错它只会表现为解析出来的字段变空、变多、或者错位而且往往是在数据已经污染了一大批之后才被发现。其次是反爬策略的干扰。目标站点可能加了前端安全校验比如隐藏字段、动态 token、请求频率限制也可能是把真实数据塞进了一段被 JS 加密过的字符串里等页面加载完再还原。这些东西单独看都是“前端安全技术操作”但从爬虫工程的角度看它们就是集成测试里最常出现的不确定性来源。你没法保证每次真实请求都拿到同样的 HTML这时候如果没有稳定的测试基线你根本无法判断是代码坏了还是目标站变了。第三个坑是数据清洗的边界情况。比如价格字段里混入了“价格面议”、“暂无报价”、折扣前后价、含税与否比如时间字段有时是2024-06-01 10:11:12有时是2024/6/1再比如多语言站点的标题里混着全角半角符号。这些情况靠人工跑一遍是看不全的只有写成带参数化输入的单元测试用一批真实采集回来的样本去验证才能真正兜住。第四个是工程层面的问题分布式调度下重复抓取、任务队列里失败重试导致的数据重复、Redis 里去重键过期导致的漏抓。这些问题根本不属于某个函数而是“多个模块协作”才能暴露出来的缺陷所以它需要的是集成测试不是单元测试。1.2 两条测试线的分工把测试拆成单元测试和集成测试不是教条而是成本与收益的平衡。单元测试跑得快、环境隔离、不依赖网络适合验证“给一个 HTML 片段能不能解析出正确字段”这类纯逻辑问题。它的问题是只能证明单个零件没问题不能证明整条流水线能出合格产品。集成测试则反过来它要拉起一个尽量接近真实的环境验证从“发请求”到“写入存储”的完整链路能发现模块之间的接口不匹配、状态传递丢失、超时重试策略失效等问题。缺点是慢、对运行环境有要求、偶尔还容易被外部因素影响。所以我在这套实践里定了一个原则能用单元测试锁死的逻辑绝不放进集成测试里去碰集成测试只负责验证那些“必须真的发生一次网络交互或者数据库写入”的关键路径。这样既能保证每次提交代码后几分钟内得到反馈又能在发版前对整体行为有把握。2. 单元测试把爬虫“解剖”到可以离线验证2.1 第一步把爬虫拆成纯函数很多爬虫脚本写得像一坨“面条代码”发请求、解 HTML、取字段、清洗、入库全部写在一个main()里层层嵌套连自己都难以维护。这种代码没法单元测试因为常量不可控、状态全局共享、断言位置找不到。我接手这个项目后做的第一件事就是重构把一个大的抓取流程拆成四个相对独立的部分——URL 构造、页面请求、HTML 解析、数据清洗。其中 URL 构造和数据清洗是天然的纯函数输入输出明确非常适合做单元测试HTML 解析虽然依赖 HTML 文本但只要把“喂进去的 HTML”作为入参它照样可以做成一个高度可控的纯函数。举一个典型的例子比如我们抓的是商品列表页那我会这么拆# url_builder.py def build_list_url(base: str, category_id: int, page: int) - str: return f{base}/category/{category_id}?page{page} # parser.py def parse_products_from_html(html: str) - list[dict]: tree etree.HTML(html) items [] for node in tree.xpath(//div[contains(class,product-item)]): items.append({ name: extract_text(node, .//h2[contains(class,title)]), price: normalize_price(extract_text(node, .//span[contains(class,price)])), link: node.xpath(.//a/href)[0] if node.xpath(.//a/href) else , }) return items # cleaner.py def normalize_price(raw: str) - float | None: if 面议 in raw or 暂无 in raw: return None digits re.sub(r[^\d.], , raw.replace(,, ).replace(, )) return float(digits) if digits else None这三个函数拆出来之后单元测试就非常清晰了。我可以只测试build_list_url会不会在页码为负数时抛异常可以测试parse_products_from_html在“缺少标题节点”“缺少价格节点”“一个商品出现多个价格节点”时分别拿到什么结果可以测试normalize_price对“12,800.50”和“价格面议”的处理。这是爬虫项目的测试地基所有更复杂的测试场景都建立在这种“可拆解”的基础上。2.2 用固定 HTML 快照替代真实请求爬虫单元测试的另一个关键技巧是永远不要在单元测试里去发真实的 HTTP 请求。我见过很多同学在单测里直接requests.get(https://example.com/list)然后断言返回内容。这个做法有两个致命问题第一网络抖动或目标站波动会让测试随机失败第二目标站的 HTML 一旦变化你会分不清到底是代码出问题还是页面变了。正确做法是把某次真实抓取到的 HTML 保存下来作为一份“固定快照”测试时直接把这个快照字符串喂给解析函数。你可以把它放在tests/fixtures/html/list_page.html这样的文件里用 pytest 的 fixture 读取import pytest from pathlib import Path from parser import parse_products_from_html pytest.fixture def category_page_html(): return Path(__file__).parent / fixtures / html / category_page.html这样测试的输入完全确定任何人跑、任何时间跑结果都一样。我还要强调一个细节不要只存一份“完美快照”要刻意保存几个“残缺快照”。比如队友近期抓回来过一页商品里缺了价格、一页里标题超长我会把这些病例也存成单独文件并用一组带注释的用例把差异点标出来。为什么因为真实网站几乎不可能是教科书一样的规整结构能够稳定应对“脏数据”的解析器才是好解析器。2.3 xpath 的 text() 这类坑必须写进用例做 Python 爬虫绕不开 XPath而 XPath 里最容易被忽略的坑就是text()的取法。太多人写过类似//div[contains(text(), 热销)]的表达式结果发现某些节点明明有“热销”两个字却匹配不上。为什么因为text()只会取当前节点的直接文本节点如果目标文本在子节点里或者当前节点的文本被多个子标签切成了多段contains(text(), ...)就失效了。正确做法通常是用.//text()把所有后代文本节点拼起来或者用string(.)获取节点内的字符串值。但这个“通常”并不总是对的具体还要看页面结构。所以在我的单元测试里我会为这类选择器专门写两个用例一个用直接文本节点、一个用嵌套子节点确保解析函数不会因为 HTML 层级变化而悄悄失明。下面这个示例是我压箱底的一个测试模式pytest.mark.parametrize(html_snippet, expected, [ (divspan苹果/span手机/div, 苹果手机), (diviPhone b15/b Pro/div, iPhone 15 Pro), (divspan classtag热销/span连衣裙/div, 热销连衣裙), ]) def test_extract_full_text(html_snippet, expected): assert extract_full_text(html_snippet) expected这条用例帮我抓住过至少三次因 XPath 表达式太脆弱而导致的线上数据残缺。做爬虫工程的同学我建议把 XPath 相关测试当作“高危专项”来处理所有涉及text()、contains()、normalize-space()的选择器都值得单独写一个断言。2.4 覆盖率是手段不是 KPI聊完具体的单测写法再说说覆盖率。我见过不少人把“覆盖率必须到 90%”挂在嘴边但在爬虫项目里死磕全局覆盖率并没有多少意义。爬虫代码的规模通常不大但复杂度和不确定性都集中在解析和清洗逻辑上。所以我更看重的是“关键函数覆盖率”URL 构造、字段解析、数据清洗、去重键生成这些函数的行覆盖和分支覆盖必须接近 100%而请求发送、日志记录、命令行入口这类胶水代码跑到即可不必为了凑覆盖率去写一堆没什么营养的断言。我会在 pytest 配置里加上--covsrc --cov-reportterm-missing --cov-fail-under85但如果某些模块因为涉及真实网络而很难在单测里覆盖我会在coverage.ini里把它们从“必达”清单中排除掉而不是用 mock 层层打补丁去打肿脸充胖子。覆盖率的意义是告诉你“还有哪些分支没验证过”不是给领导看的表。3. 集成测试验证“真的能抓到东西”3.1 本地假服务起一个真实链路单元测试把函数内部逻辑锁死了但集成测试要回答的问题是把各个模块接起来之后整套爬虫能不能正常运转。最稳妥的做法不是直接去打线上站点而是先在本地起一个“假目标站”。我常用pytest-localserver这个库它能快速拉起一个本地 HTTP 服务给测试提供可控的响应内容。比直接 mockrequests.get更好的地方在于它走的是真实的 HTTP 协议栈能测出 URL 拼接错误、请求头遗漏、重定向处理不当这类“mock 永远发现不了”的问题。举个例子我想验证爬虫遇到 503 时会不会正确重试那我就在假服务里写一个“前两次返回 503第三次返回正常页面”的路由from pytest_localserver.http import ContentServer session_state {count: 0} def fake_handler(request): session_state[count] 1 if session_state[count] 3: return 503, {Retry-After: 2}, Service Unavailable return 200, {}, htmlh1ok/h1/html def test_spider_retry_on_503(httpserver): httpserver.serve_content(, headers{}) # 配置 spider 指向 httpserver.url spider MySpider(targethttpserver.url, max_retry3) result spider.run() assert result.success is True assert session_state[count] 3这种测试的价值在于它是从“爬虫代码”的视角真正发了一次 HTTP 请求所以验证的是包括 requests 会话、代理设置、重试中间件在内的整套链路而不是某个单独的函数。3.2 重试、超时、限速怎么在测试里验证爬虫集成测试里最容易放水的地方是超时和限速。很多人的测试里压根不管这两个参数默认认为只要能抓到页面就算成功。实践中不是这么回事。先说超时。如果目标站响应很慢你的爬虫是不是会一直卡在那里如果全部线程都卡住了整个分布式任务会不会雪崩集成测试应该构造一个“延迟响应”的服务来验证。在本地假服务里我可以让路由time.sleep(5)然后给爬虫设置timeout2断言它抛出超时异常且重试机制被触发。这里要注意 sleep 时间别设得太长否则测试会慢得让人不想跑。再说限速。限速这类逻辑原本是为了不“打扰”目标服务。集成测试里我会在假服务端记录收到请求的时间戳然后对比相邻两次请求的间隔是否大于配置的阈值。比如我设置了delay1.5秒就断言两次请求间隔不低于 1.2 秒留出一点浮点余量别把断言写得太苛刻。这个用例的价值不仅仅在于验证代码逻辑它也提醒我们这些做爬虫工程的人要始终对目标站点保持基本的礼貌控制频率、设置合理的 User-Agent、遵守网站的访问规则这是这个领域的基本操守。3.3 分布式爬虫的集成测试要点如果你的项目是分布式爬虫比如用 Scrapy-Redis 或自研的调度队列那么集成测试的复杂度会上一个台阶。除了“发请求、解析页面”这条链路你还得验证节点之间的协作行为。我在这里会重点测三个点任务去重、失败重投、以及结果写入。任务去重要靠 Redis 里的指纹集合比如我用md5(url 请求参数)作为唯一指纹那集成测试就要覆盖“同一 URL 被推入队列两次最终只会被抓一次”。失败重投则要验证“某条消息处理异常后不会无限重试把队列入口堵死”。结果写入要看“采集结果从队列消费者传到数据库之后字段是否完整、主键是否冲突”。这类测试可以不全用真实 Redis本地用docker run redis临时起一个容器就够了既能测真实协议又能保证测试环境干净。跑完测试后记得在teardown里清空 key别让脏数据污染下次运行。3.4 数据管道验证抓回来的数据对不对很多爬虫项目的集成测试止步于“能抓到页面”却没有验证“数据落库之后长什么样”。这是不够的。我习惯在集成测试里加一条“从抓取到入库”全链路用例让爬虫跑完一个只有三条商品的假站点然后直接查询数据库断言库里恰好有 3 条记录、价格字段是Decimal类型、URL 字段去重后与源站一致、时间字段被正确转成了本地时区。这条用例经常能抓到一个非常诡异的问题抓取阶段一切正常但写入 MySQL 时因为表的unique key设置不对或者 ORM 的字段映射写错导致数据丢失或重复。如果集成测试只看“请求成功”这些错误永远会在被实际使用时才炸出来。我还会把“空页面”也纳入数据管道测试当解析结果为空列表时下面的入库逻辑是否会明确拒绝写入并产生告警日志而不是静默当一个成功任务处理掉。4. 工程工具链pytest 脚手架、配置管理与持续集成4.1 pytest fixtures 与 requests_mock 的配合测试写得多了你会发现 fixtures 的设计直接决定代码好不好维护。在爬虫项目里我习惯把 fixture 按“数据来源”分成几层第一层是“固定 HTML 快照”供解析函数单测使用第二层是“假站点 URL”供集成测试使用第三层是“配置对象”从环境变量读取但允许测试覆盖。对于涉及 requests 的单元测试我除了用本地假服务也会用requests_mock来做更细粒度的控制。它跟假服务的区别在于假服务测的是自己启动的真实 serverrequests_mock则是直接在 requests 库层面拦截响应不需要监听端口速度快适合验证各种 header、params 和响应内容的组合。但注意它有一个局限它拦的是 requests如果你的爬虫用 httpx 或 aiohttp就需要对应的respx或者aioresponses库了。我会按这个原则选择涉及协议、重试、跳转的测试用真服务只关注“某个 URL 返回某个 HTML 后解析结果对不对”的测试用 mock。4.2 配置项集成测试让环境变量参与进来搜索热词里有一条“配置项集成测试”这个确实值得单独聊。爬虫工程的配置往往散落在多个地方目标 URL 前缀、请求头、爬取频率、代理地址、数据库连接串、去重键前缀。你如果把这些配置直接写死在代码里那测试完全没有意义因为生产环境的配置不同行为可能天差地别。我的做法是把所有配置收敛到一个类里例如SpiderConfig它从环境变量读取默认值同时允许在初始化时传入覆盖值。这样集成测试就可以这样写config SpiderConfig( base_urlhttpserver.url, download_delay0.0, # 测试时把延迟压到最低 redis_urlredis://localhost:6379/15, db_dsntest_db_dsn, ) spider MySpider(config)这里有一个很关键的细节测试环境的配置项越接近生产越有意义但“下载延迟”和“并发数”这类会影响测试速度的项要允许测试单独调小。如果某个配置项在生产环境被改坏了集成测试能立刻发现吗这取决于你有没有构造一个“冒烟用例”来覆盖所有线程池、重试次数、代理模式。我会专门留一个test_config_prod_shape.py它不跑真正的爬取任务只是读取一份形似生产环境的配置文件实例化爬虫对象再断言关键参数没有丢失。4.3 前端安全技术对集成测试的干扰这个话题要从一个真实案例说起。我们曾经接一个目标站点它在页面里嵌入了大量前端校验逻辑一个不可见的 input 字段每隔一段时间会刷新 token实际数据在 ajax 接口里还要带一段页面计算出来的加密签名。从技术栈上猜它大概率是个 Vue 或 React 的单页应用渲染全在浏览器端完成。这种站对爬虫的“对抗”实际上也给集成测试带来了麻烦本地假站返回的原始 HTML 是一堆 JS 框架的挂载点不经过浏览器执行根本看不到真实数据更没法放进测试里去断言。我处理这类问题的方式是分层妥协解析层仍然用快照测试但快照从“执行完 JS 之后的渲染结果”里抓取集成测试则绕开重型的浏览器渲染链路改为验证我们的代理层、任务调度层和存储层。如果你的爬虫是用 Playwright、Selenium 这类浏览器自动化框架实现的那集成测试里也确实应该安排一部分用例启动真实浏览器去访问假站但这类用例要单独标记为slow放到发版前跑而不是每次提交都跑。另外我想提醒一句如果前端做了“防止查看页面源码”“防止打开控制台”之类的操作它本质上对渲染结果影响有限但对爬虫工程师的心态影响很大。遇到这类站点时守住边界、遵守协议优先想办法从公开接口或开放数据渠道获取内容而不是投入大量资源去绕开防护。这也是我在团队里一以贯之的原则。4.4 要不要用 LLM 辅助生成单元测试近年“基于 LLM 的单元测试”是个热词我也实际试过。用起来确实能省时间比如你给它一段解析函数代码它能立刻生成十个边界用例包括空字符串、缺字段、多层嵌套等等覆盖面比你手写的还广。但我的态度是LLM 生成的测试可以当“素材”不能直接当“家底”。原因有两个。第一它生成的断言往往是“照着实现写”也就是说如果实现逻辑本身是错的它的测试大概率也是错的因为它是从代码行为里归纳出来的期望不是从需求文档里推导出来的。第二爬虫领域的断言高度依赖具体页面结构LLM 不知道你的目标站不知道text()和.//text()的坑它能生成的只是通用型测试。所以我用它的方式是批量生成初稿然后逐条审查每个断言是否符合预期把不合理的去掉把缺失的边界补上。核心的还是人的判断力。5. 常见问题与排查技巧实录5.1 本地测试全覆盖一上线就挂这是爬虫测试里最经典的悖论。你本地明明用假站测得很欢一到生产环境就各种问题IP 被限制、需要登录 cookie、某个地区才能访问、返回的是验证码页面而不是数据页。排查思路是先把“本地覆盖”和“线上可访问性”分开。假站测试只能验证你的代码逻辑验证不了真实网络的复杂性。所以我会额外维护一小撮“线上冒烟用例”数量控制在 5 个以内选几个相对稳定、且不敏感的目标页面只用只读方式去验证“URL 可达、解析不为空、字段数量合理”。这些用例不放进常规 CI而是放到发版前的pre-release阶段每天手动或定时触发一次一旦挂了就立刻通知值班的人去看是目标站改版还是访问受限。5.2 测试经常性随机失败随机失败是集成测试里最消耗耐心的东西。常见原因有测试假服务启动端口冲突、测试依赖了本地时间或时区、数据库连接串指向了共享环境导致数据互相污染、限速断言过于苛刻。我处理随机失败的套路是三步。第一步给所有外部依赖打上明确的 tag像pytest.mark.integration和pytest.mark.slow常规提交默认跳过这些用例只有显式指定时才跑。第二步凡是涉及 Redis、MySQL 的测试必须建独立库或使用redis://localhost:6379/15这类独立 db并且每条用例都带 cleanup fixture。第三步把所有网络相关的断言都加上容差范围比如“请求间隔超过 0.8 秒”而不是“必须等于 1 秒”。5.3 页面结构改版之后怎么保住测试资产页面改版是爬虫项目绕不开的日常。我在这方面的经验是不要一发现某个用例失败就赶紧去改选择器先看快照对比。把新抓取的 HTML 和旧快照做一次 diff找出变化的范围然后判断是“局部字段挪了位置”还是“整个页面模板重建了”。如果是后者光改选择器不够还得重做解析层测试否则你等于在用旧地图找新路。我还习惯在解析函数里加一个“锚点断言”如果解析结果里连续 10 条记录的标题都为空就认为页面结构可能异常直接抛一个PageStructureError而不是默默继续。这个小改动让我在改版初期就能看到告警而不是等数据污染一周后才发现。5.4 常见问题速查表症状可能原因排查方向单元测试通过集成测试抓不到数据依赖的页面结构是 JS 渲染后生成的改用渲染后快照或用浏览器自动化跑集成测试偶尔超时目标响应慢或本地假站忙检查超时配置、并发线程数、DNS 解析耗时数据入库重复去重键生成遗漏参数检查指纹生成是否包含 query 参数、请求方法解析出的价格有时是字符串有时是浮点清洗函数分支未覆盖补normalize_price的参数化用例集成测试连本地假站都失败端口被占用或 pytest 插件冲突用独立临时端口、隔离 venv 重跑用 requests 的脚本用 requests_mock 后失效mock 匹配规则太严格检查request.cookies、params断言6. 写完这套测试后我不再怕改版了最后说点实际体会。做完这个项目的单元测试和集成测试改造后最直观的改变不是覆盖率数字而是我的心理状态。以前每次改爬虫代码都像在走钢丝只敢往线上丢一个临时脚本跑到一半盯着日志看有没有报错。现在改完解析逻辑本地跑一遍单测再加上集成测试最多五分钟就能得到一份明确的结论合理且合规的抓取策略还能沉淀成团队的工程资产。有一个小技巧想分享给所有做爬虫工程的人给每个解析函数配一个“样本档案”把曾经让它翻过车的所有 HTML 片段都记录下来做成参数化测试。这个档案是你的团队最值钱的测试资产因为每一条记录背后都对应一次真实事故。下次再有新人接手爬虫项目看到这些用例就基本能摸清全部容易踩坑的地方。这套测试体系后续还能继续扩展比如把抓取结果的校验做成独立的 schema 校验模块让价格、库存、上架状态这些关键字段在入库前就先过一遍“体检”也可以把线上冒烟测试的告警接入通知渠道让异常在第一分钟就被发现。不过这些都是后话先把单元测试和集成测试这两条基础线做好爬虫项目就能从“能跑”真正走向“可信”。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑