OpenMed 测试套件深度指南:基于 pytest 的离线优先单元测试与集成测试实践
【免费下载链接】openmedLocal-first healthcare AI: clinical NER & HIPAA PII de-identification that runs 100% on-device. 2,200+ medical models, 21 languages, Apple MLX + Python, no cloud, no patient data leaving your network. Apache-2.0项目地址: https://gitcode.com/GitHub_Trending/ope/openmed
本指南以 OpenMed 仓库的 tests/README.md 为骨架,系统讲解其测试目录布局、pytest 标记体系、共享 fixture 设计、从源码安装依赖的方法以及全部常用运行命令。OpenMed 是一个本地优先(local-first)的医疗健康 AI SDK,其测试套件的核心设计目标是:在核心依赖安装完成后,完全离线即可执行全部测试——所有下游 Hugging Face 模型 API 均被 mock 替换。读完本文,你将掌握 OpenMed 测试套件的完整结构、如何复现测试环境、如何分层运行单元/集成/慢速/模糊测试,以及如何利用仓库中的共享 fixture 编写自己的新测试。
测试套件总览:为离线执行而设计的目录结构
OpenMed 的tests/目录承载了单元测试与集成测试的完整覆盖。整个套件依赖pytest,并通过大量 mock 屏蔽下游 Hugging Face API,因此只要核心依赖安装完毕,就可以在网络隔离的环境下运行。这一点与 OpenMed"数据不出网、全程本地推理"的产品定位一脉相承——测试环境同样不依赖外部网络。
官方文档定义的核心布局如下:
unit/—— 快速、隔离的单元测试,覆盖配置管理、模型加载辅助函数、分词(tokenisation)、格式化与各类工具模块;integration/—— 更高级别的场景测试,通过 mock 的 transformers pipeline 演练公开 API 表面(例如analyze_text、list_models);fixtures/—— 共享的样例文本与可复用的 pytest fixture;conftest.py—— 全局 fixture,负责 mock transformers 组件、配置重置与样例数据。
从仓库实际内容看,套件规模远超这四个目录:tests/unit下有core、cli、clinical、interop、eval、multimodal、risk、service、traces、training等数十个分类目录,累计超过千个测试文件;此外还有fuzz/(基于 Hypothesis 的属性测试与语料回放)、property/(流水线阶段契约测试)、browser/(Playwright 端到端测试)、web/、mobile/(Flutter FFI 与 React Native 桥接对比)以及desktop/(Tauri 客户端)。tests/run-tests.sh与根目录 pyproject.toml 中的 pytest 配置把这些测试统一编排进 CI。
从源码安装测试依赖
官方文档给出的安装流程是:创建全新的虚拟环境,以可编辑(editable)模式安装包并附带测试依赖。测试期间 transformer 层会被 patch,但必须仍然可被 import,因此需要安装transformers(torch可选)。
# 从仓库根目录执行 git clone https://gitcode.com/GitHub_Trending/ope/openmed.git cd openmed python -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate pip install -e . pip install pytest pytest-cov transformers # 可选:为 transformers 安装 CPU 后端 pip install torch --index-url https://download.pytorch.org/whl/cpu如果要在自定义测试中访问 Hugging Face 的受限(gated)模型,运行 pytest 前需要导出HF_TOKEN;而仓库自带的测试不需要任何网络访问。
一个更省事的替代方案是直接使用dev可选依赖组。根目录 pyproject.toml 中的[project.optional-dependencies].dev已经聚合了pytest>=7.0、pytest-cov>=4.0、pytest-timeout>=2.3、hypothesis>=6.100、ruff==0.15.22、huggingface-hub>=0.30等全套开发工具,tests/run-tests.sh正是这样做的:
pip install -e '.[dev]'从源码结构看,OpenMed 还提供了丰富的运行时可选依赖(mlx、onnx、torch、hf、gliner等,见 pyproject.toml),测试时应按被测模块按需安装,例如涉及 ONNX 推理的测试需要pip install -e '.[onnx]'。
运行测试:从全量到分层
在项目根目录执行pytest即可运行完整的单元 + 集成套件:
pytest只运行轻量的单元测试:
pytest tests/unit # 或者通过 marker 过滤 pytest -m "not integration"只运行集成场景:
pytest -m integration生成覆盖率报告:
pytest --cov=openmed --cov-report=term-missing这些命令能够成立,依赖 pyproject.toml 中的[tool.pytest.ini_options]配置:
markers = [ "integration: marks end-to-end or external integration tests", "slow: marks tests that are expected to run slowly", "contract: marks property-based stage-boundary contract tests", "fuzz: marks property-based (Hypothesis) fuzz tests", "doctest_examples: runs doctest examples for targeted public modules" ] python_files = ["test_*.py", "*_test.py"] testpaths = ["tests"]也就是说,pytest 会从tests目录收集所有test_*.py/*_test.py文件,并注册了五类 marker:integration(端到端或外部集成)、slow(预期耗时较长的测试)、contract(基于属性的流水线阶段边界契约测试)、fuzz(基于 Hypothesis 的模糊测试)与doctest_examples(针对指定公共模块的 doctest)。例如tests/unit/core/test_result_cache.py与tests/unit/core/test_pipeline_latency.py中就同时使用了slow/integration等标记组合。
仓库还提供了编排好的脚本 tests/run-tests.sh,它模拟 CI 的完整流程:
#!/usr/bin/env bash set -euo pipefail python3 -m venv .venv source .venv/bin/activate python -m pip install --upgrade pip >/tmp/pip-up.log pip install -e '.[dev]' >/tmp/pip-install.log ruff check . ruff format --check . # 不包含慢速标记的核心测试套件(为 zero-shot 模块收集覆盖率) pytest -m "not slow" --cov=openmed/ner --cov-report=term-missing # zero-shot 慢速检查(依赖缺失时优雅跳过) pytest -m slow脚本展示了三个关键实践:先做代码规范检查(ruff)再跑测试;核心套件用-m "not slow"排除慢速测试,同时单独为openmed/ner模块统计覆盖率;慢速测试单独收尾,并在依赖缺失时优雅跳过。
所有测试之所以被设计为可离线运行,正是因为广泛使用了 mock——官方文档明确指出:失败要么指向 OpenMed 代码本身的回归,要么指向本地缺失的依赖,而不是网络问题。
conftest.py 拆解:全局 mock 与状态隔离
测试套件的离线能力集中体现在 tests/conftest.py。它以unittest.mock构造了一整套模拟的 transformers 组件,并用三个autousefixture 保证每个测试之间的全局状态相互隔离。
数据类 fixture
| fixture | 类型 | 说明 |
|---|---|---|
sample_config | OpenMedConfig | 构造OpenMedConfig(default_org="TestOrg", cache_dir="/tmp/test_cache", device="cpu", log_level="DEBUG", timeout=60),用于配置相关测试 |
sample_text | str | 短医疗文本:"Patient John Doe has diabetes and hypertension. Prescribed metformin 500mg daily." |
sample_long_text | str | 较长的多段临床文书(主诉、现病史、体格检查、实验室结果、评估与计划),覆盖长文本与多句子场景 |
sample_predictions | list[dict] | 三条模拟模型预测:B-CONDITION(diabetes, 0.95)、B-MEDICATION(metformin, 0.89)、B-DOSAGE(500mg, 0.87) |
模拟组件 fixture
mock_tokenizer:一个Mock分词器,tokenize返回["patient", "has", "diabetes"],并提供convert_ids_to_tokens、convert_tokens_to_string;其调用返回值是一个自定义MockEncoding,实现了word_ids()(返回[None, 0, 1, 2, None])并携带input_ids、attention_mask、offset_mapping、special_tokens_mask等标准编码字段——这正是 transformers fast tokenizer 的典型返回结构。mock_model:模拟 token-classification 模型,配置num_labels=3、problem_type="token_classification"、architectures=["BertForTokenClassification"]。mock_pipeline:模拟 HuggingFace pipeline 调用,返回两条实体预测(B-CONDITION/diabetes、B-MEDICATION/metformin,含 score、word、start、end 字段)。mock_model_info:模拟 Hub 模型元信息(modelId、author、downloads、likes、library_name、tags、pipeline_tag),用于模型注册表 / 搜索相关测试。
自动执行的隔离 fixture
@pytest.fixture(autouse=True) def reset_config(): yield openmed.core.config.set_config(OpenMedConfig()) @pytest.fixture(autouse=True) def reset_tokenizer_cache(): clear_tokenizer_cache() yield clear_tokenizer_cache() @pytest.fixture(autouse=True) def clear_paged_kv_service_env(monkeypatch): for env_var in _PAGED_KV_SERVICE_ENV_VARS: monkeypatch.delenv(env_var, raising=False)三个autousefixture 分别解决三类全局状态污染问题:
- 全局配置重置:
openmed.core.config维护一个进程级单例_config(见 openmed/core/config.py 的get_config/set_config),若测试之间不重置,前一个测试写入的配置会泄漏到后续测试。reset_config在每次测试结束后恢复为默认OpenMedConfig()。 - 进程级分词器缓存清空:OpenMed 在 openmed/processing/tokenizer_cache.py 中实现了一个容量 32、线程安全(
RLock)的 LRU 分词器缓存。reset_tokenizer_cache在测试前后各调用一次clear_tokenizer_cache(),确保 mock 的分词器与真实缓存互不干扰。 - MLX paged KV-cache 环境变量清理:
_PAGED_KV_SERVICE_ENV_VARS列出的五个OPENMED_SERVICE_MLX_PAGED_KV_CACHE_*环境变量会在测试间被monkeypatch.delenv移除,避免可选的 MLX paged KV-cache 服务配置泄漏进无关测试——这也印证了 OpenMed 的 MLX 推理路径是可选的、环境驱动的。
TestHelpers:面向新测试的构造工具
conftest.py末尾的TestHelpers类(同时以test_helpersfixture 暴露)提供两个静态方法:
create_entity_prediction(text, label, confidence, start=None, end=None):基于openmed.processing.outputs.EntityPrediction构造单条实体预测;create_prediction_result(text, entities, model_name="test-model"):把[{"word", "entity", "score", ...}]形式的预测列表转换为完整的PredictionResult(含timestamp与processing_time)。
这使新测试可以直接复用统一的预测数据模型,不必重复手工构造。
集成测试如何驱动真实公共 API
test_end_to_end.py 展示了集成层"mock 底层、驱动真实 API"的写法。以test_analyze_text_full_pipeline为例,它依次 patch 了openmed.core.backends._module_available、openmed.core.models.HF_AVAILABLE、pipeline、AutoConfig、AutoTokenizer、AutoModelForTokenClassification,然后调用公开入口analyze_text(sample_text, model_name="medical-ner", config=...),断言返回结果具备text、entities、model_name属性且实体数量正确。
analyze_text的真实实现位于 openmed/init.py,其完整调用链为:validate_input→validate_model_name→ModelLoader.create_pipeline(内部使用AutoConfig/AutoTokenizer/AutoModelForTokenClassification组装token-classificationpipeline)→ 可选句子切分与分块(chunk)→ner_pipeline(inference_input)→ 边界修正与可选 medical-tokenizer 重映射 →format_predictions输出。集成测试通过 mockAutoConfig/AutoTokenizer/AutoModelForTokenClassification拦截了这条链的模型加载环节,从而在完全无网环境下验证了analyze_text的参数解析、pipeline 组装与结果格式化逻辑。
同文件中的其他测试还覆盖了list_models(mockget_all_models后断言返回模型 id 列表)、包结构(__all__导出完整性)、配置流(get_config/set_config往返)、文本处理管线(TextProcessor的clean_text/segment_sentences/extract_medical_entities)、输出格式化(dict/json/html 三种格式)与错误处理(空输入与非法模型名校验抛ValueError)。标记为@pytest.mark.slow的性能类测试则验证长文本处理耗时小于 1 秒。
共享样例数据:fixtures 目录
fixtures/sample_medical_texts.py 提供了更丰富的真实感医疗语料:
CLINICAL_NOTE_1/CLINICAL_NOTE_2:完整的结构化临床文书(主诉、既往史、用药、体格检查、评估与计划);MEDICATION_LIST_1/MEDICATION_LIST_2:用药列表(含剂量、频次与给药时间);SHORT_TEXTS:8 条短句(诊断、用药、血压、过敏史、手术史等);PROCEDURE_NOTE/RADIOLOGY_REPORT:操作记录与放射报告;TEST_CASES:带期望实体(CONDITION/MEDICATION/DOSAGE/VITAL_SIGN)的标注用例;EDGE_CASES:空串、纯空白、超长文本、emoji、医学缩写(H/O DM, HTN, CAD)、处方格式(Metformin 500 mg BID x 30 days #30 disp)等边界输入;BATCH_TEST_DATA:批量处理模拟数据。
这些数据覆盖了实体抽取、剂量解析、生命体征识别、缩写消歧、批处理与边界行为等多个测试面,是单元测试复用的主要语料来源。除此之外,tests/fixtures下还有按领域组织的海量 JSON/JSONL 黄金数据(如clinical/、pii/、i18n/、fhir/、interop/omop/、risk/等),支撑临床关系抽取、多语言 PII、FHIR/OMOP 互操作、差分隐私预算等专项测试。
编写新测试的推荐实践
综合仓库的测试组织方式,为 OpenMed 贡献新测试时可以参考以下模式:
- 放到正确的层级:纯逻辑、无模型依赖的测试放
tests/unit/对应模块子目录;演练analyze_text、deidentify、list_models等公共 API 的场景放tests/integration/;耗时较长或需要真实资源(如真实句子切分器、容器、模型下载)的测试追加@pytest.mark.integration或@pytest.mark.slow;属性化输入用@pytest.mark.fuzz+ Hypothesis。 - 优先复用 conftest fixture:医疗文本直接用
sample_text/sample_long_text/sample_predictions;需要预测对象用test_helpers.create_prediction_result;配置相关的测试用sample_config,并通过set_config设置、依赖reset_config自动还原。 - mock 掉模型与 Hub:参照
test_end_to_end.py,用@patch拦截AutoConfig/AutoTokenizer/AutoModelForTokenClassification/pipeline,或直接使用mock_pipeline/mock_tokenizerfixture;涉及模型元信息用mock_model_info。 - 注意进程级状态:测试不应假设全局配置与分词器缓存是干净的——autouse fixture 已保证隔离,但自建的全局状态也应遵循"用完即清"的对称模式。
排障速查
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
测试因ImportError: HuggingFace transformers is required...失败 | 未安装transformers(即便测试会 mock 它,也必须可 import) | pip install transformers,或pip install -e '.[hf]' |
| 集成测试尝试联网 | 缺少HF_TOKEN或测试误触真实 Hub | 仓库自带测试全部离线;自定义测试如需 gated 模型,先export HF_TOKEN=... |
| 慢速测试拖慢全量运行 | 误把slow测试纳入常规 CI | 用pytest -m "not slow"过滤 |
| 覆盖率统计缺失某模块 | --cov未覆盖目标包 | 参考run-tests.sh:pytest --cov=openmed/ner --cov-report=term-missing |
| 测试间相互污染 | 进程级配置 / tokenizer 缓存 / 环境变量泄漏 | 依赖conftest.py的 autouse fixture;新全局状态遵循对称清理 |
结语
OpenMed 的测试套件是一个围绕"离线可复现"精心设计的体系:tests/目录分层清晰、conftest.py 用一套 mock 组件与三个自动清理 fixture 保证了测试的确定性与隔离性,pyproject.toml 中的 marker 体系让单元、集成、慢速、契约与模糊测试可以按需组合,run-tests.sh 则把 lint、覆盖率与分层测试编排成了可一键复现的 CI 流程。无论是复现回归、评估覆盖率,还是为 OpenMed 贡献新测试,本文的命令与 fixture 速查都能让你快速上手。
【免费下载链接】openmedLocal-first healthcare AI: clinical NER & HIPAA PII de-identification that runs 100% on-device. 2,200+ medical models, 21 languages, Apple MLX + Python, no cloud, no patient data leaving your network. Apache-2.0项目地址: https://gitcode.com/GitHub_Trending/ope/openmed
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考