上面这张图是我自己动手做的 SVG 信息图。SVG 这种格式, 本质上是靠代码来描绘出的矢量图像。把它放大了看, 不会出现模糊的现象。想要改动上面的文字内容也很方便。它支持版本追踪的功能。这种特点尤其适合用在技术文章之中。
用来解释像目录结构这类细节, 或者说明工作流程, 又或是讲清楚能力边界的情况, 效果非常好。面对那些偏向于“工程组织能力”方面的工具主题时, 使用 SVG 进行表达, 其清晰度要比直接使用截图高得多。
究竟是什么东西?
官方网站首页对于它自身的定位表达得相当直白和明确。
它可以帮助你进行文字的书写工作。
在官方首页上, 有这几个点是大家最容易印象深刻的:
当你仅仅是关注前面那两条内容的时候, 你可能会产生一种印象, 觉得它更像是一个使用体验更加顺畅的检查断言工具库;可是, 要是你把后面提到的那些关于目录层面的规范要求, 还有所涉及的插件机制, 全部放在一起进行综合分析的话, 那么你就会清晰地发现一个不同的事实了, 原来在整体的形态和定位上, 它其实更像是一个用来系统化组织测试工作的管理框架体系。
在这之中, 最为值得让人铭记于心的那一句便是:
真正的核心竞争能力在哪里, 并不是去替代那个被称为self的东西, 而是去终结那种完全依靠人工约定、处于混乱不堪状态的测试工程模式。
很多团队在开始进行测试之后, 为什么会变得越来越混乱呢。
我们可以观察到一个很常见的, 叫做非化这样一种状态。
project/├─ test_login.py
├─ test_api_v1.py
├─ test_api_v2.py
├─ test_final.py
├─ helpers.py
├─ helpers_new.py
├─ db_mock.py
├─ fake_db2.py
└─ run_tests.py
在初期阶段, 由于文件的数量并不多, 所以人们还可以通过依靠记忆力的方式来对它们进行维护与管理。可是, 当项目的规模变得非常大时, 相关的问题就会随之出现:
目录的结构缺乏明确的分类层次, 因为单元测试、集成测试以及端到端测试被杂乱无章地揉合在了一起。初始化操作存在着严重的重复性问题, 每一个单独的文件都会自己去创建客户端连接、准备测试所需的数据, 并且还要建立临时的文件目录来存放内容。
在进行运行和选择的时候非常困难, 如果有人只想单独执行接口部分的测试工作, 那就只能通过去猜测文件的名称来找到对应的目标。相关的配置信息呈现分散状态, 本地环境有一套执行的命令流程,持续集成系统里有另一套独立的参数设置, 随后还需要再去手写一套不同的规则来进行应对。
面对测试组合的急剧膨胀状况, 由于输入条件多种多样, 涉及的角色身份各不相同, 后端的服务实现也互有差异, 最终大家只能依靠手动复制和粘贴函数的方式来勉强维持整个体系的正常运转。
在这一个时间点上面, 它所具备的重要意义便真正地显现出来了, 它所给予大家的, 并不是那种仅仅为了书写方便而在语法层面存在的所谓“语法糖”, 并且更不是一种简单的修饰手段, 而是提供了一种完整的、系统性的组织方式作为支撑。
1. 先将目录结构和相关配置内容固定下来, 使得测试工作在最初阶段就确立好规范, 避免因标准不清而导致后续执行过程偏离预期轨道。
官方的 Good 明确建议, 一定要把项目结构还有测试结构都规范化起来, 这里面的典型思路是这样子的。
通常来说, 更加稳健的工程骨架往往呈现出这样一种形态。
awesome_service/├─ pyproject.toml
├─ src/
│ └─ awesome_service/
│ ├─ api/
│ ├─ llm/
│ ├─ storage/
│ └─ utils/
└─ tests/
├─ conftest.py
├─ unit/
│ ├─ test_prompt_builder.py
│ └─ test_token_budget.py
├─ integration/
│ ├─ test_openai_gateway.py
│ └─ test_vector_store.py
└─ e2e/
└─ test_chat_flow.py
那些对应的配置项, 不要散乱地分布在像.ini文件、命令行参数以及CI脚本这样的地方里到处都是, 干脆一点, 直接全部收纳到.toml文件中去吧:
[tool.pytest.ini_options]minversion = "8.0"
addopts = ["-ra", "--strict-markers", "--import-mode=importlib"]
testpaths = ["tests"]
pythonpath = ["src"]
markers = [
"smoke: 核心冒烟测试",
"slow: 运行较慢的测试",
"api: 接口层测试",
"integration: 集成测试",
"llm: 大模型相关测试",
]
这一步的价值是非常巨大的, 原因是该操作直接实现了对三项重要事项的统一确定和锁定。
有很多项目在进行测试工作的时候, 情况变得越来越混乱了, 这种状况不是因为他们不知道应该怎么去使用某些工具或者方法, 而是因为在最开始的时候, 项目的目录结构以及相关的约定规范都没有建立起来。
2. 真正具有王牌的, 其实就在于它能够对测试依赖进行有效的管理。
在官方文档里面, 所强调的重点内容, 不仅仅是表示“能够进行复用操作”, 而是突出说明了它特别擅长于对测试工作所必需的依赖关系以及资源的生命周期实施管理与控制。
该文档甚至直接着重强调了其具备的灵活特性, 并且指出测试函数可以直接通过声明特定的参数名的方式, 来获取并引用自身所需要的相关对象与配置信息。
大家可能会问, 为什么这件事是重要的呢? 原因其实很明显。在真实的项目开发环境中, 测试环节几乎从来不会仅仅是去断言一个纯函数而已。相反, 它经常会依赖以下几个方面的内容。
如果没有进行专门的集中处理, 那么这些必要的筹备事项往往会被分散放置在不同的地点或者平台之上。
而 这种思路的实际情况, 主要是这样的。
测试只是发出了一个声明, 说清楚我这边需要什么东西, 至于具体要怎么去准备、这些资源要复用多长时间、到了什么时间点该进行释放操作, 这些具体的决策工作并不在测试的职责范围内。
我们来观察一个更加贴近于人工智能服务项目的具体实例。
tests/.py
from pathlib import Pathimport pytest
class FakeLLMClient:
def __init__(self, model: str):
self.model = model
def generate(self, prompt: str) -> str:
return f"[{self.model}] {prompt}".upper()
@pytest.fixture(scope="session")
def model_name() -> str:
return "qwen-mock"
@pytest.fixture(scope="session")
def llm_client(model_name: str) -> FakeLLMClient:
return FakeLLMClient(model=model_name)
@pytest.fixture
def workspace(tmp_path: Path) -> Path:
project_dir = tmp_path / "project"
project_dir.mkdir()
return project_dir
@pytest.fixture
def sample_prompt() -> str:
return "summarize the meeting in 3 bullet points"
tests/unit/.py
def test_prompt_generation(llm_client, sample_prompt):result = llm_client.generate(sample_prompt)
assert "SUMMARIZE THE MEETING" in result
assert result.startswith("[QWEN-MOCK]")
def test_workspace_created(workspace):
assert workspace.exists()
assert workspace.is_dir()
上面所说的这一种写法, 相比于那种把初始化的逻辑强行塞入到每一个测试文件的做法来说, 是比那个要强大得多的, 因为这种办法自然而然地就会带来好几个在工程方面的好处与收益, 这些收获是显而易见的。
我们把依赖关系变成显式的状态, 也就是直接在测试函数的签名里面写明自己需要哪些东西, 这样带来的好处是可以让代码复用变得更加自然, 因为同一个对象可以被很多个不同的测试文件进行使用, 同时我们还可以对资源的生命周期进行控制, 利用那些特定的作用域可以非常清楚地规定出资源复用的范围大小, 并且在以后需要进行扩展的时候完全不会让人感到痛苦, 哪怕将来要把某些部分替换成真实的模拟数据, 也绝对不需要去批量修改所有的测试代码, 那么关于如何使用这些工作空间的作用域呢?
在官方所提供的文档内容之中,针对那个被称作 scope 的关键层级部分, 有着具体的说明呈现出来, 对于那些属于一般情况范畴之中的决策环节如何加以理解, 可以参照以下的方式来进行考量与解析。
根据我平时的经验, 我是采用这样的办法来进行选择的。
这是因为如果数量变得太多, 那么测试工作乍一看似乎变得十分轻松和简单, 可是实际情况是那些底层的依赖关系链条会再次转变为不易察觉的隐式状态。
3. 参数化这个做法呢, 它可不是为了图省事或者偷懒, 它的真正的核心意思, 其实是在致力于去构建所谓的测试矩阵。
根据官方文档的说法, 关于 @.mark. 这个内容的介绍是比较平淡的: 目的是让一个测试函数通过使用多组的参数来运行。很多人第一次使用它的时候, 只是觉得“可以少写几个复制粘贴的测试函数”;但是在做工程的时候,它所真正解决的问题是组合爆炸。
比方说, 假设你目前正在着手开发一个用于文本摘要的接口功能, 那么一般情况下, 你需要确保这个功能至少能够涵盖以下几个重要的考察维度:
要是你不去运用参数这样的方法, 你就非常有可能写出数量大概在八个到十六个这么样子的测试函数出来, 而且这些函数里面会存在着高度重复的内容。
而用, 可以把它收成一套矩阵:
import pytest@pytest.mark.parametrize(
"text,language,max_tokens",
[
("你好,帮我总结这段会议记录", "zh", 64),
("Summarize this release note", "en", 64),
("请把这段超长文档压缩成 5 个要点", "zh", 128),
],
ids=["zh-short", "en-short", "zh-long"],
)
def test_summary_request_validation(text, language, max_tokens):
payload = {
"text": text,
"language": language,
"max_tokens": max_tokens,
}
assert payload["text"]
assert payload["language"] in {"zh", "en"}
assert payload["max_tokens"] > 0
这样一种写法的价值, 远远不止是节省了代码这么简单了, 它还有另外一些更重要的意义体现在这里:
如果你再次将参数化与结合起来, 那么其自身的组织架构和统筹能力将会被进一步地扩大和提升。
4. 这解决的是测试怎么分层跑。
许多团队目前所面临的核心困难, 其实并不是说他们完全没有安排测试工作, 而是他们在实际运作中, 很难能够根据具体的需求情况来灵活地启动和运行测试环节。
比如:
在情况不存在的前提下, 此类需求最终全部转变为:
这套系统实际上就是专门为处理这件事而存在的, 而且在官方提供的文档示例之中, 是明确地展示了这一点。
来看一个例子:
import pytest@pytest.mark.smoke
@pytest.mark.api
def test_healthcheck_ok():
response = {"status": "ok"}
assert response["status"] == "ok"
@pytest.mark.slow
@pytest.mark.llm
def test_long_context_reasoning():
token_usage = 32768
assert token_usage < 65536
你可以配合着前面的那个.toml文件, 从而就得到一套用起来手感特别顺手、操作起来非常顺畅的运行策略。
pytest -m smokepytest -m "api and not slow"
pytest -m llm
pytest -m "not slow"
这比过去那种想要运行某部分测试, 只能被迫依靠目录切换命令的方式, 效率方面要高出非常多的程度。
5..py这个文件, 它是专门用来测试基础设施的一个分发中心。
很多人群体会将相关学习行为拆解为若干个孤立的技能点, 表现为能够操作某些具体功能, 但是无法构建出完整的知识框架。真正促使这些能力在实际项目中落地生根的载体, 往往是脚本文件。
它的这个价值, 主要体现出来的地方在于。
你可以这么理解:
这么做的利处在于, 测试的基础设施能够伴随着目录的分界而呈现出自然而然层叠的态势, 它并不会让诸多要素统统混杂在一起从而形成一个庞大得令人咋舌的巨型文件。
6. 一个更贴近真实项目的 组织方案
假设你在做一个 AI API 服务,目录可以这样落:
ai_gateway/├─ pyproject.toml
├─ src/
│ └─ ai_gateway/
│ ├─ api/
│ ├─ providers/
│ ├─ prompts/
│ └─ billing/
└─ tests/
├─ conftest.py
├─ unit/
│ ├─ test_prompt_template.py
│ ├─ test_rate_limit.py
│ └─ test_billing_rules.py
├─ integration/
│ ├─ test_openai_provider.py
│ ├─ test_qwen_provider.py
│ └─ test_vector_cache.py
└─ e2e/
└─ test_chat_completion_flow.py
顶层.py
import pytest@pytest.fixture(scope="session")
def settings():
return {
"default_model": "qwen-plus",
"timeout": 30,
"max_retries": 2,
}
@pytest.fixture(scope="session")
def api_base_url():
return "http://127.0.0.1:8000"
tests//.py
import pytest@pytest.fixture(scope="module")
def fake_provider_server():
server = {"started": True, "provider": "mock-qwen"}
yield server
server["started"] = False
tests//.py
import pytest@pytest.mark.api
@pytest.mark.integration
@pytest.mark.parametrize(
"prompt,expected_provider",
[
("你好", "mock-qwen"),
("请总结日志", "mock-qwen"),
],
ids=["simple-chat", "summarize-log"],
)
def test_qwen_provider_dispatch(fake_provider_server, prompt, expected_provider):
assert fake_provider_server["started"] is True
assert fake_provider_server["provider"] == expected_provider
assert isinstance(prompt, str)
为什么说, 这一套结构体系, 要优于那所谓的随意指写一些测试用例的做法呢? 关键的原因在于, 它能够同时把以下几个方面都给兼顾到。
换句话说, 在这里所扮演的角色情况, 已经不是被视为工具类型的断言了, 而是作为测试编排器的角色了。
我们再看一张图: 组织路径具体是怎么串联到一起的?
这张图片可以将这个核心思想, 用一句话来进行概括。
先确定目录的边界, 然后再去管理资源, 接着使用参数化来扩展测试矩阵, 最后依靠具体的措施和统一的命令把本地环境与持续集成进行连接。
许多团队在学习的时候, 只不过掌握了一些分散的语法点, 并没有把这些本事连贯成一个工作步骤, 因此总让人觉得虽然有了一点能力, 不过在具体项目操作中依然显得杂乱无序。真正管用的办法, 是把这些本事整合成一条稳固的运行路线, 而不是仅仅保留几个单独的技能技巧。
7. 让团队成员不要零散去记忆那些常用的命令, 而是给团队规定出一套统一使用的运行语义体系。
许多团队的测试体验比较差, 这并非因为技术实力不够强大, 而是因为命令的语义没有达到固化的程度, 下面呈现出的这张表格, 基本上已经足够覆盖大多数项目日常使用的需求。
假设说, 你的团队现在确实已经在用了持续集成这个技术的东西了, 那么我建议, 干脆就直接吧这些关于语义方面的同步操作, 统统给它整合到那个流水线里面去搞一搞!
这样一来, 本地环境和持续集成环境之间才不会形成两个相互割裂的世界。
8. 如果想要从所谓的“手写脚本测试”那个环境, 迁移到新的环境之中, 那么究竟采用什么样的方法才算是最为稳妥的呢?
通常不提议将全部仓库的内容一次性进行重写操作, 最为稳妥的办法是按照以下所示的顺序, 分步骤实现渐进式的迁移工作。
第一步的事情, 就是将运行的入口给统一起来。
只要能够先做到这一点。
只要做到了这一步, 你在很大程度上就已经取得了胜利。
第二步:把重复初始化抽成
优先从这几个类型中进行抽取。
将第三个步骤进行如下处理, 也就是把从上面复制过来的那个测试函数给修改一下, 改成一个包含参数化内容的形式。
尤其是那种非常恰当的。
到了第四步, 需要把补充的工作做足, 好让运行层级分层的情况得以建立起来。
首先, 需要先进行划分。
到达第五个阶段的时候, 我们需要再去认真考虑一下那些更加高级的插件所具备的功能。
像覆盖率, 并行, 快照, 还有 mock 以及异步测试这些项目, 都是非常有必要添加进来的, 但是呢, 这种做法最好是能够建立在之前的那种组织结构已经处于稳定状态的那个基础之上的。
9. 我对我所要论述的核心对象, 作出了一个非常清晰的, 并且毫不含糊的判断。
如果只是去写几个纯粹的函数进行测试的话, 那么它所展现出的优势确实会显得像是“让断言的操作变得更加方便”;可是, 只要这个项目是进入了下面存在的某一种状态里面的任何一种的话。
它的价值就会很快地从“写法舒服”这样的状态, 升级成为“组织效率差异”这样的状况。
所以我本人是更加倾向于使用这一种方式来来进行定义的, 就是:
它并不是一种让测试工作变得更加类似于编写脚本的工具, 而是一种使得测试工作终于能够像真正的工程实践一样的工具。
这句话, 才是比起其他方面来说, 它更加重要的关键所在。
10. 在最后, 我给你提供一套可以直接拿来落地使用的, 最小的工程模板。
project/├─ pyproject.toml
├─ src/
│ └─ your_pkg/
└─ tests/
├─ conftest.py
├─ unit/
└─ integration/
[tool.pytest.ini_options]addopts = ["-ra", "--strict-markers", "--import-mode=importlib"]
testpaths = ["tests"]
markers = [
"smoke: 核心冒烟测试",
"slow: 慢测试",
"api: 接口测试",
"integration: 集成测试",
]
# tests/conftest.pyimport pytest
@pytest.fixture(scope="session")
def config():
return {"env": "test"}
# tests/unit/test_config.pyimport pytest
@pytest.mark.smoke
def test_config_env(config):
assert config["env"] == "test"
pytest -m smoke你要是先把这一套骨架搭建完毕, 随后再逐渐往里填充内容、进行参数化设计以及其他相关操作的话, 其潜在的价值将会非常迅速且显著地展现出来。
结语
学 ,别只盯着 。
我们应该学会去做的, 实际上是:
当你这样去理解的时候, 它就不再仅仅只是意味着测试变得更加顺手了, 而是意味着测试终于可以开始大规模地进行了。