Crawlee Python 单元测试稳定性治理:pytest 标记与 CI 并行环境下的 Flaky 测试处理指南
【免费下载链接】crawlee-pythonCrawlee—A web scraping and browser automation library for Python to build reliable crawlers. Extract data for AI, LLMs, RAG, or GPTs. Download HTML, PDF, JPG, PNG, and other files from websites. Works with Parsel, BeautifulSoup, Playwright, and raw HTTP. Both headful and headless mode. With proxy rotation.项目地址: https://gitcode.com/GitHub_Trending/cr/crawlee-python
本篇技术指南以 tests/unit/README.md 为核心骨架,系统讲解 Crawlee(Python 版 Web 爬取与浏览器自动化库)单元测试套件在 CI 并行环境下如何处理易失败(flaky)测试:从“先查根因、再谈标记”的处理优先级,到run_alone_on_mac、run_alone、@pytest.mark.flaky、@pytest.mark.skip四种 pytest 标记的适用场景与底层实现。读者将掌握一套可直接复用的测试稳定性治理方法论,并理解 Crawlee 仓库中 xdist 并行调度、跨平台 CI 矩阵与测试隔离机制是如何协同工作的。
一、背景:为什么单元测试会“偶发失败”
Crawlee 的单元测试套件规模庞大,覆盖爬虫核心(BasicCrawler、Playwright、BeautifulSoup、HTTP 客户端)、自动扩缩容、会话管理、存储层等模块。这些测试在 CI 中运行时,存在一个反复出现的现象:某些测试在本地稳定通过,却在 CI 上偶发失败。
根据 tests/unit/README.md 的说明,这种偶发失败(flaky behavior)本身就是一个值得重视的信号,其原因通常可以分为两类:
- 代码缺陷或测试设计缺陷:测试偶发失败可能暗示被测代码存在时序、竞态或资源管理 bug,也可能是测试自身的断言方式、隔离性设计不合理;
- 测试执行环境的客观限制:包括测试之间未能完全隔离(共享了全局状态、端口、文件或浏览器进程)、CI 执行器的资源约束(内存、CPU 被并行 worker 挤占)等。
从仓库的 CI 配置可以直观看到这种并行压力来自何处。.github/workflows/_checks.yaml 中的unit_tests任务在Ubuntu、Windows、macOS 三个操作系统上、以Python 3.10 至 3.14 五个版本的矩阵运行测试,并设置tests_concurrency: "8"——这意味着同一时刻有大量测试 worker 并行执行,任何资源敏感型测试(真实浏览器启动、内存读数断言、端口绑定)都可能互相干扰。
二、核心原则:先查根因,后谈缓解
Crawlee 团队在文档中给出的第一原则是:面对 flaky 测试,首要任务是理解其失败原因——因为这可能指向代码中的 bug,或测试设计中的缺陷。
因此,处理 flaky 测试的建议手段按优先级严格排序:
- 调查根因并修复代码或测试本身(最高优先级);
- 若无法立即修复根因,应用 pytest 标记缓解偶发性;
- 最后兜底才是跳过测试。
这一原则在仓库中有大量呼应。例如 tests/unit/conftest.py 通过autouse=True的 fixture 在测试期间统一压制UserWarning(主要针对 SqlStorageClient 的实验性状态警告),并配合_isolate_test_environmentfixture 在每个测试前后重置全局状态(重置 service locator、清空存储实例缓存、归零Statistics与BasicCrawler的类级计数器、将存储目录指向tmp_path),从源头上减少因测试间共享全局状态而引发的 flaky——这正是“查根因、修设计”思想的制度化落地。
三、四种 pytest 标记的适用场景与使用规则
当根因调查无法立即完成、或资源约束无法根除时,文档给出了四种按优先级排列的 pytest 标记方案:
3.1@run_alone_on_mac:仅在 macOS 上串行执行
带此标记的测试在 CI 的 macOS 执行器上单独运行(正常情况下多个测试并行执行,可能导致资源敏感型测试失败),适用于已知仅在 macOS 上偶发失败的资源敏感型测试。
该标记的实际定义位于 tests/unit/utils.py:
run_alone_on_mac = pytest.mark.run_alone if sys.platform == 'darwin' else lambda x: x从源码可以看到其精妙之处:它在非 macOS 平台(如 Linux、Windows)上是一个恒等函数(no-op),不影响测试原本的并行调度;只有当前平台是 macOS(sys.platform == 'darwin')时才真正挂上pytest.mark.run_alone标记,使测试在 macOS 执行器上退化为单独串行运行。这避免了让所有平台的测试都失去并行能力,实现了“按需降级”。
3.2@run_alone:在所有执行器上独立运行
带此标记的测试在任何执行器上都会单独运行,适用于已知在所有平台都易失败、或因测试设计原因无法与其他测试并行的资源敏感型测试(文档强调这种情况“应极其罕见”)。
run_alone是仓库中唯一通过 pytest 配置显式注册的自定义标记,见 pyproject.toml:
markers = [ "run_alone: marks tests that must run in isolation", ]仓库中有大量真实用例。最典型的是 tests/unit/_utils/test_system.py 中的内存估算测试:
# The estimation is asserted on absolute memory readings, which hold only as long as nothing else on the machine makes # the kernel reclaim the pages allocated below. Running alongside the other test workers is enough to break that. @pytest.mark.run_alone @pytest.mark.skipif(sys.platform != 'linux', reason='Improved estimation available only on Linux') def test_memory_estimation_does_not_overestimate_due_to_shared_memory() -> None:注释非常直白:该测试基于绝对内存读数做断言,只要机器上其他测试 worker 导致内核回收了已分配的内存页,断言就会失败——即“并行本身就会破坏测试前提”。这类测试天然无法并行,必须打上run_alone。
同类用例还包括 tests/unit/browsers/test_browser_pool.py 中启动真实 Firefox 浏览器的测试(注释指出“启动真实浏览器资源开销大,在 xdist 并行下会超时”)、tests/unit/_autoscaling/test_autoscaled_pool.py 的并发运行测试、tests/unit/_autoscaling/test_snapshotter.py 的 CPU 采样测试,以及 tests/unit/crawlers/_playwright/test_playwright_crawler.py 中 Firefox headless 请求头等测试。
3.3@pytest.mark.flaky:失败后自动重试
带此标记的测试在失败后会被重试若干次,适用于已知偶发失败、但失败原因尚未查明或难以缓解的测试。
该标记由 dev 依赖中的pytest-rerunfailures插件提供(见 pyproject.toml 的"pytest-rerunfailures<17.0.0")。一个教科书式的组合用法出现在 tests/unit/crawlers/_basic/test_basic_crawler.py:
@pytest.mark.run_alone @pytest.mark.flaky( reruns=3, reason='Test is flaky on Windows and MacOS, see https://github.com/apify/crawlee-python/issues/1652.' ) @pytest.mark.skipif(sys.version_info[:3] < (3, 11), reason='asyncio.timeout was introduced in Python 3.11.') @pytest.mark.parametrize( 'sleep_type', [ pytest.param('async_sleep'), pytest.param('sync_sleep', marks=pytest.mark.skip(reason='https://github.com/apify/crawlee-python/issues/908')), ], ) async def test_timeout_in_handler(sleep_type: str) -> None:这段代码几乎是整套标记体系的“全家福”:
run_alone:该测试涉及 handler 超时与重试语义,对时序敏感,先保证隔离;@pytest.mark.flaky(reruns=3, ...):明确记录在 Windows 和 macOS 上存在已知偶发失败(并附 issue 链接),失败自动重试 3 次;@pytest.mark.skipif:asyncio.timeout是 Python 3.11 才引入的 API,因此在旧版本上直接跳过;@pytest.mark.skip(通过pytest.param内嵌):sync_sleep参数组合存在未解决的 issue,暂时跳过该参数化分支。
这种“多层标记叠加”的模式清晰展示了各标记的分工边界:隔离(run_alone)解决环境干扰,重试(flaky)兜底未查明原因的不稳定,跳过(skip)处理版本兼容与已知未修复问题。
3.4@pytest.mark.skip:最后的兜底手段
带此标记的测试会被跳过。文档明确强调:只有在以上所有手段都无法缓解时才应使用 skip,因为跳过测试会隐藏潜在 bug 并制造虚假的安全感(false sense of security)。被跳过的测试必须在 GitHub issue 中登记追踪,以便后续恢复。
在 tests/unit/crawlers/_basic/test_basic_crawler.py 中可以看到,跳过总是带着明确的reason(如指向 issue 的链接),这正是“被跳过的测试要可追踪”原则的体现——任何跳过都不是无理由的、临时的,而是有据可查、可回溯、未来可恢复的。
四、标记背后的调度机制:串行前置 + 并行分流
run_alone标记并非 pytest 的内置行为,而是仓库在测试任务编排层赋予它的语义。查看 pyproject.toml 中的 Poe 任务定义即可看清:
uv run pytest \ -m "run_alone" \ tests/unit && \ uv run pytest \ --numprocesses=${TESTS_CONCURRENCY:-auto} \ -m "not run_alone" \ tests/unit执行unit-tests任务时,测试被拆分为两个阶段:
- 第一阶段:仅运行带
run_alone标记的测试(-m "run_alone"),此时不指定--numprocesses,即串行执行,确保资源敏感型测试独占整个执行器; - 第二阶段:运行其余所有测试(
-m "not run_alone"),并通过--numprocesses=${TESTS_CONCURRENCY:-auto}以 xdist 并行调度(本地默认自动探测 CPU 核数,CI 中由tests_concurrency控制)。
同时,pyproject.toml 中的 pytest 全局配置还包含:
addopts = "-r a --verbose --dist worksteal" asyncio_default_fixture_loop_scope = "function" asyncio_mode = "auto" timeout = 1800--dist worksteal:使用 xdist 的 worksteal 调度策略,动态均衡各 worker 的负载,避免某些 worker 积压、另一些空转;asyncio_mode = "auto"与asyncio_default_fixture_loop_scope = "function":配合 pytest-asyncio,使异步测试与 fixture 的循环作用域得到统一管理;timeout = 1800:配合 pytest-timeout 为单个测试设置半小时超时,防止失控测试拖垮整个任务;- 另有 filterwarnings 针对 Uvicorn 内部依赖的
websockets弃用警告做定向忽略,避免噪音干扰失败归因。
这一“先串行跑隔离测试,再并行跑其余测试”的两阶段编排,是run_alone语义能够落地的关键基础设施,也是本仓库治理 flaky 测试最具工程参考价值的部分。
五、从根因层面降低 flaky 的配套实践
除了标记体系,Crawlee 仓库还在测试基建层面提供了多种“治本”手段,与文档的优先级原则形成互补。
5.1 全局状态自动隔离
tests/unit/conftest.py 中的prepare_test_env与_isolate_test_environment(均为autouse=True)保证每个测试都在干净环境中启动:设置CRAWLEE_DISABLE_BROWSER_SANDBOX(CI 环境无法使用浏览器沙箱)、将CRAWLEE_STORAGE_DIR指向tmp_path、重置 service locator 的三个内部状态、清空存储实例缓存、归零类级计数器。这消除了测试间最普遍的一类 flaky 源头——全局状态污染。
5.2 轮询等待替代固定 sleep
tests/unit/utils.py 提供的poll_until_condition辅助函数明确建议“用条件轮询替代固定asyncio.sleep”来等待某个状态收敛(例如自动扩缩容的并发度变化),并支持backoff_factor指数退避:
async def poll_until_condition( fn: Callable[[], Awaitable[T] | T], condition: Callable[[T], bool] = bool, *, timeout: float = 5, poll_interval: float = 0.05, backoff_factor: float = 1, ) -> T:固定 sleep 的时长是拍脑袋定的,机器负载高时不够、负载低时浪费;而基于真实条件的轮询在“状态未就绪”与“状态已就绪”之间自适应,从根本上减少了时序类 flaky。
5.3 测试内资源降级
例如 tests/unit/conftest.py 启动本地代理Proxy时强制--num-workers 1 --num-acceptors 1,注释明确解释:默认情况下每个 CPU 核都会派生一个 acceptor 和 executor 进程,这对单个测试用的代理是浪费,且会在 CI 并行负载下压垮 xdist worker。这种“在测试内部主动收敛资源占用”的做法,与文档所述“资源约束导致 flaky”的原因一一对应。
六、小结与决策速查
处理 Crawlee 仓库单元测试偶发失败时,可按以下决策路径操作(与 tests/unit/README.md 完全一致):
| 优先级 | 手段 | 适用场景 | 仓库佐证 |
|---|---|---|---|
| 1 | 调查根因并修复 | 一切 flaky 的第一选择 | 全局状态隔离 fixture、轮询等待替代固定 sleep |
| 2 | @run_alone_on_mac | 仅 macOS 上资源敏感的测试 | 定义于 tests/unit/utils.py,非 darwin 平台为 no-op |
| 2 | @run_alone | 所有平台资源敏感、无法并行的测试 | 内存估算、Firefox 启动等测试;Poe 任务串行前置执行 |
| 3 | @pytest.mark.flaky | 已知失败但原因未明 | pytest-rerunfailures提供,如reruns=3的 handler 超时测试 |
| 4 | @pytest.mark.skip | 最后手段,必须带 reason 并登记 issue | 参数化分支内嵌 skip,如sync_sleep分支 |
最后再次强调文档的核心结论:skip 永远不是免费的午餐——它只是把问题从测试失败列表转移到了 GitHub issue 列表,掩盖了潜在的代码 bug;只有“理解失败原因”才能真正带来测试套件的长期健康。本仓库将上述标记、两阶段测试编排(见 pyproject.toml 的unit-tests与unit-tests-coverage任务)与隔离基建组合使用,为构建大规模、跨平台、可并行的爬虫库测试套件提供了一个值得借鉴的完整范例。
【免费下载链接】crawlee-pythonCrawlee—A web scraping and browser automation library for Python to build reliable crawlers. Extract data for AI, LLMs, RAG, or GPTs. Download HTML, PDF, JPG, PNG, and other files from websites. Works with Parsel, BeautifulSoup, Playwright, and raw HTTP. Both headful and headless mode. With proxy rotation.项目地址: https://gitcode.com/GitHub_Trending/cr/crawlee-python
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考