Friend 后端测试体系实战指南单元测试选择、文件隔离运行器与 CPU 时长守卫的设计与调优【免费下载链接】FriendAI that sees your screen, listens to your conversations and tells you what to do项目地址: https://gitcode.com/GitHub_Trending/fr/Friend本文基于backend/tests/README.md展开结合 Friend 仓库AI 助手项目后端源码系统讲解后端单测的选择机制、并行运行器、时长守卫、slow 守护型测试调度、CI 入口与集成测试边界帮助开发者理解并正确使用这套测试基础设施。一、总览Friend 后端的测试分层与事实来源Friend 的 Python 后端规模庞大含 database、models、routers、services、utils 等大量模块其测试体系被刻意拆分为多层每层有自己的运行入口与资源前提分层目录默认运行入口资源前提单元测试unit lanetests/unit、tests/services、tests/routers及顶层tests/test_*.pybash backend/test.sh无外部依赖hermetic守护型慢测slow guardrail记录于tests/slow_guardrail_manifest.txtscripts/run-slow-guardrails-ci.sh无外部依赖但为全仓 grep/清单类检查集成测试tests/integration/手动pytestRedis、Firebase 凭据、API Key、真实外部服务其中单元测试的事实来源source of truth是bash test.sh。它并不硬编码测试文件列表而是通过scripts/select_backend_unit_tests.py动态选择文件再以每文件一个 pytest 进程的方式并行执行并在底层挂接 CPU 时长守卫。本文后续章节将逐层拆解这条链路。二、单元测试选择器--all与--changed-files双入口2.1 全量选择--allscripts/select_backend_unit_tests.py --all是完整单测列表的生成器源码见 select_backend_unit_tests.py。它覆盖tests/unit/test_*.py含子目录递归tests/services/**/test_*.pytests/routers/**/test_*.py顶层tests/test_*.py中仍属于单测套件的文件同时它维护了一个LEGACY_UNLISTED_TESTS集合显式将tests/test_cache_manager.py、tests/unit/test_diarizer_dockerfile.py、tests/unit/test_lazy_conversation_processing.py等文件排除在全量发现之外避免被错误纳入单测车道。2.2 变更文件选择--changed-files日常迭代中不需要跑全量选择器支持按变更文件推导应跑的测试python scripts/select_backend_unit_tests.py --changed-files /tmp/changed-files --output /tmp/backend-tests BACKEND_UNIT_TEST_FILE_LIST/tmp/backend-tests bash test.sh选择逻辑见tests_for_changed_pathsselect_backend_unit_tests.py包括命中全量路径full-run path若变更涉及.github/workflows/backend-unit-tests.yml、backend/test.sh、backend/tests/conftest.py、锁文件pylock.*.toml、requirements.txt等基础设施文件直接返回整个单测套件区域映射AREA_TESTSbackend/llm_gateway/→tests/unit/test_llm_gateway_*.py、backend/utils/stt/→ 一系列test_*stt*通配符选择器用前缀/glob 把后端源码映射到对应测试区域工作流契约workflow contracts读取testing/workflow_contracts.json按sources→tests映射补充测试记忆策略核心memory policy corebackend/database/memory_*.py、backend/routers/memory_*.py等会额外选中tests/unit/test_inv_mem_1_guard.py与testing/e2e/test_canonical_memory_pipeline.py未映射即回退后端变更若匹配不到任何区域契约则回退到完整套件并输出原因例如xxx did not match a backend test-selection contract。这套能窄则窄、拿不准就全量的策略保证了 pre-push 阶段反馈聚焦同时让漏测风险兜底在 CI 的全量验证上。2.3 运行前的环境预检任何测试运行前都建议先执行bash test-preflight.sh位于 backend/test-preflight.sh。它会校验所选 Python 解释器版本是否匹配.python-versionpytest、pyright、black 等工具是否可用pydantic、fastapi、firebase_admin、fake_firestore、fakeredis等关键包能否导入Redis 连通性、GOOGLE_APPLICATION_CREDENTIALS等集成测试可选环境变量。test.sh本身也会在运行前默认导出ENCRYPTION_SECRET与假的OPENAI_API_KEY保证纯单元测试的密闭性。三、test.sh运行器环境固化、标记表达式与文件级隔离backend/test.sh源码见 backend/test.sh是整个单测车道的核心它做了几件容易被忽略的事3.1 环境固化UTF-8 强制export PYTHONUTF81避免原生 Windows Python 继承系统代码页导致读源码的测试出现 locale 相关差异清除 Git 钩子变量利用git rev-parse --local-env-vars在锚定到backend/目录后清除仓库级钩子环境变量见 test.sh防止测试中创建临时仓库时意外操作外层 worktree原生线程池收敛默认把OMP_NUM_THREADS、OPENBLAS_NUM_THREADS、MKL_NUM_THREADS、VECLIB_MAXIMUM_THREADS、NUMEXPR_NUM_THREADS、BLIS_NUM_THREADS全部置为 1test.sh。原因是进程级并行已经存在若每个 pytest 进程再各自启动 BLAS/OpenMP 原生线程池会过度订阅机器并使 CPU 归属取决于哪个测试先初始化数值库。原生内核类测试可在单测车道之外显式覆盖这些变量。3.2 标记表达式BACKEND_PYTEST_MARK_EXPR # 默认 not integration and not slow这是 PR 单测车道的默认车道所有需要真实服务、凭据、长时间等待、原生压力路径或更广组件覆盖的测试都应打上pytest.mark.slow/pytest.mark.integration以离开该车道详见第六节。3.3 文件隔离运行器file-isolated runner默认情况下BACKEND_PYTEST_FILE_ISOLATION1每个测试文件是一个独立的 pytest 进程并用BACKEND_PYTEST_WORKERS默认 auto取在线 CPU 数限制并发BACKEND_PYTEST_WORKERSauto # 默认getconf _NPROCESSORS_ONLN 或 sysctl -n hw.ncpu兜底 4这种设计的目的在 README 与代码注释中写得很明确遗留的模块级 stub 测试会互相污染在模块作用域写sys.modules文件级隔离让它们互不影响同时比旧的串行逐文件更快。注意标记表达式下若某文件没有匹配任何测试pytest exit code 5运行器会视为跳过而不会报错test.sh。如果你确信测试是干净的也可以尝试单会话 xdistBACKEND_PYTEST_FILE_ISOLATION0 bash test.sh这会让 pytest-xdist 用-n workers --distloadfile跑一个会话把模块导入成本分摊到每个 worker每次只付一次但前提是测试树没有模块污染问题——而该前提在当前仓库并不成立见下节。3.4 失败文件的重跑命令当某个文件失败时运行器会打印一条可直接复制的命令用同样的环境与时长守卫重跑失败文件: /tmp/omi-backend-unit-failures.txt echo tests/unit/xxx.py /tmp/omi-backend-unit-failures.txt BACKEND_UNIT_TEST_FILE_LIST/tmp/omi-backend-unit-failures.txt bash test.shREADME 明确告诫排查时长类失败时不要用裸pytest重跑因为它缺少test.sh的守卫设置。四、BACKEND_PYTEST_PARALLEL_SESSION被测量数据否决的并行会话实验test.sh中保留了一个默认关闭0的实验开关BACKEND_PYTEST_PARALLEL_SESSION1 # 实验性默认关闭其设想是只有tests/.module_stub_legacy_allowlist仓库内文件.module_stub_legacy_allowlist里列出的文件需要隔离其余约 875 个文件可以放进一个 xdist 会话--distloadfile从而省掉每个文件一次 pytest 进程的启动与收集成本——这个成本是真实存在的约 930 个进程合计约 2200 秒 CPU。但实测数据否决了直接开默认收集阶段 segfault把 875 个非白名单文件放进一个进程收集时直接 SIGSEGV——upbprotobuf 后端在某个被驱逐的google.cloud.firestore_v1类型模块被重新执行并重新注册 file descriptor 时 abort分块仍然失败拆成 35 个 25 文件块分别收集仍有 11/35 块失败42 个收集错误 1 个段错误说明污染是密集分布而非少数可点名文件OOM单进程收集 815 个文件被 OOM-kill且每个 xdist worker 都要收集整个选择集内存随 worker 数线性增长。根本原因在于scripts/check_module_stub_pollution.py只能识别直接的模块作用域sys.modules写入而当前测试树的常见模式是模块作用域调用同文件 helper 再写sys.modules或导入已废弃的tests/unit/memory_import_isolation——这些都会绕过检测。因此该开关目前只是机制与契约的存在证明README 明确指出完成backend/docs/test_isolation.mdtest_isolation.md所描述的导入纯净性迁移才是开启它的前置条件。这正是用数据而非直觉决定默认值的工程范例。五、单测时长守卫CPU 时间、双阈值与祖父条款5.1 两个阈值环境变量默认值含义BACKEND_FAST_UNIT_WARN_SECONDS0.1每个测试的 CPU 时间目标warn 阈值BACKEND_FAST_UNIT_FAIL_SECONDS本地test.sh/ pre-push0.30CI1.0阻塞预算fail 阈值实现位于 tests/conftest.py 的_enforce_fast_unit_duration_guard。5.2 为什么用 CPU 时间而不是墙钟时间守卫测量的是仅 call 阶段的 CPU 时间time.process_time而非 wall-clock见 conftest.py。原因wall-clock 在并行争抢下不可预测地膨胀硬性限制必然抖动CPU 时间是更好的信号但并非免疫在饱和主机上实测 CPU 读数仍会膨胀约 2 倍竞争 stall 周期被计入进程因此 fail 预算故意在 warn 目标之上保留头部空间而不是紧贴其上。另外文件隔离运行器下每个测试文件是独立进程文件/类的第一个测试会把该进程的模块导入成本FastAPI app / router / database 图摊销进自己的测量时间。这是结构性成本而非单测回归所以历史超限的既有单测被祖父化进 tests/fast_unit_duration_allowlist.txt每行一个 node ID注释说明原因想缩小该列表有两个方向改用单会话BACKEND_PYTEST_FILE_ISOLATION0导入成本每 worker 只付一次或调高 fail 阈值。5.3 守卫在 xdist 下依然生效test.sh的并行分区batched session是一个 pytest 进程跑多个文件控制器会丢弃 worker 的session.exitstatus。如果守卫只依赖 exitstatus一旦套件切到单并行会话预算就会静默失效。因此 worker 通过workeroutput[backend_fast_unit_duration_failures]把违规者交回控制器控制器在pytest_testnodedown中收集并自行失败会话见 conftest.py。由于时长守卫失败不会失败任何单个测试pytest_terminal_summary还会打印BACKEND-UNIT-FAILED-FILE path标记行test.sh读取该前缀以生成按文件的失败/重跑清单test.sh。5.4 什么才该被标记而不是被 allowlist真正的非单测真实的asynciosleep、网络/Redis、压力、全仓 grep、完整 app 装配、每测试全新模块重载必须打pytest.mark.slow/pytest.mark.integration离开 PR 车道而不是进 allowlist。allowlist 只用于真单测但结构性超时的历史条目。六、slow不等于不运行守护型慢测的清单式调度这是 README 中一个重要的历史教训。直到 2026-09-04 之前scripts/run-unit-ci.sh是后端唯一的单测车道且它运行not integration and not slow——于是slow标记会把测试彻底移出 CI而exact-set 守卫失败即报错这类守卫在没人运行时就只是一段注释。仓库用真实事故佐证了这一点PR #12701 为 belief model 添加了两个受管的get_llm调用点而test_managed_call_sites_absent_from_the_inventory_fail_ci本应拒绝它们——因为没人运行该测试改动在一小时内就合入了 main。修复方案是分离两条车道PR 车道run-unit-ci.sh继续跑not integration and not slow守护车道scripts/run-slow-guardrails-ci.shrun-slow-guardrails-ci.sh按 tests/slow_guardrail_manifest.txt 清单运行slow and not integration的守护型测试codebase grep、coverage ratchet、exact-set pin 等。这条车道故意不做文件隔离——守护型测试多为静态扫描、导入少单会话跑完整清单可控制在两分钟内。同时它无条件运行一个职责是发现没人预料到的变更的检查不能被触发它的变更集所门控。压力测试与网络测试仍属于slow但不需要进入清单。注意清单的治理约定只增不减文件只会在修复或删除后带理由被移出未列入的红色测试会被点名记录在注释里让差距保持可数。七、CI 入口run-unit-ci.sh与本地 pre-push 的分工7.1 CI 全量契约scripts/run-unit-ci.shrun-unit-ci.sh是 GitHub Actions 的入口执行完整链条运行选择器--all或--changed-filesbash test-preflight.sh预检需要时运行scripts/typecheck.sh类型检查needs-typecheck.sh决定边界以固定环境变量调用bash test.shBACKEND_FAST_UNIT_WARN_SECONDS0.1、BACKEND_FAST_UNIT_FAIL_SECONDS1.0、BACKEND_PYTEST_FILE_ISOLATION1、BACKEND_PYTEST_MARK_EXPRnot integration and not slow。CI 的 fail 阈值1.0s高于本地0.30sCI 需要为跨机器的 CPU 计量差异留出余量避免无关 PR 被抖动阻塞但保留同样的 100ms warn 目标。最慢的 wall-clock 时间仍会在Backend unit test durations摘要中打印供观察。7.2 分片shardCI 还支持--shard total/index把同一个选择集扇出到 N 个并行 job每个分片执行确定性排序列表的交错切片第 i 行进入分片((i-1) % total)1慢文件被摊到各分片且每个文件始终保持独立进程。分片内同样会运行 preflight 与类型检查避免某个分片悄悄丢失兄弟分片跑过的守卫。7.3 pre-push 刻意保持轻量pre-push故意不调用run-unit-ci.sh全量选择被限制为最多 40 个文件或缩减为变更测试文件普通 push 保持快速。README 要求维持这个分工迭代用bash test.sh或聚焦的pytest调用全量验证交给 CI。八、实战案例Asana 项目选择器回归测试README 给出一组可直接复用的回归测试命令需先在仓库根目录安装好锁定环境backend/.venv/bin/python -m pytest backend/tests/unit/test_asana_project_pagination.py backend/tests/unit/test_asana_project_pagination_http.py -q这组 hermetic 单测文件实际存在于 test_asana_project_pagination.py 与 test_asana_project_pagination_http.py覆盖项目续接偏移量continuation offsets分页过程中查询过滤器被保留跨页时的 OAuth 重试后页错误的向上传播。其中 HTTP 用例会以假 provider/database 边界挂载生产路由器校验响应序列化但不接触真实 Asana、不跑真实 OAuth。两个文件都会被现有后端单测运行器自动发现因此它们既可以直接用pytest单独跑也会被select_backend_unit_tests.py --all纳入全量套件。九、集成测试边界与运行前检查集成测试位于tests/integration/不会被bash test.sh运行。它们可能依赖 Redis、Firebase 凭据、API Key 或真实外部服务必须阅读 tests/integration/README.md 后手动用 pytest 显式运行。以通知集成测试为例需要先设置GOOGLE_APPLICATION_CREDENTIALS、TEST_USER_ID、TEST_FCM_TOKENS并从项目根目录执行pytest backend/tests/integration/test_notifications_integration.py -v这类测试会发送真实通知README 明确警告应使用测试 Firebase 项目与自己的测试用户。十、小结正确使用这套测试体系迭代期间用bash backend/test.sh全量单测或先scripts/select_backend_unit_tests.py --changed-files缩窄范围提交前bash backend/test-preflight.sh确认环境时长类失败必须用test.sh打印的重跑命令而非裸 pytest写新测试真单测追求 0.1s CPU需要真实服务/长时间等待的打slow/integration守护型检查打slow且必须加入tests/slow_guardrail_manifest.txt不要随意把测试塞进 allowlist——allowlist 只属于结构性超时的既有真单测CI 与本地分工全量验证交给run-unit-ci.sh本地 pre-push 保持 40 文件上限的快速反馈。这套体系的独特价值在于把测试怎么选、怎么并行、怎么防抖、怎么不静默失效都用可复现的测量数据沉淀进了脚本与文档而不是停留在约定层面。【免费下载链接】FriendAI that sees your screen, listens to your conversations and tells you what to do项目地址: https://gitcode.com/GitHub_Trending/fr/Friend创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
