1. Hermes 自动化测试骨架到底解决什么问题
Hermes 自动化测试技能这个系列,我想从最容易被忽略、但后面最省时间的一步讲起:搭骨架。很多人第一次给 Hermes 相关项目写测试,都是直接新建一个test.js或者test_xxx.py,把用例堆进去,跑通一次就完事。等到用例涨到几十个、需要区分单元测试和集成测试、需要 mock 外部依赖、需要在 CI 里分阶段跑的时候,才发现目录乱、配置散、命令记不住,改一个公共断言要动十几个文件。
所谓可复用的测试骨架,说白了就是三件事:目录结构固定下来、配置文件集中管理、运行命令标准化。它不解决“测试写得对不对”,但解决“测试放在哪、怎么跑、怎么复用”。对已经有 Jest、Pytest 或 Mocha 基础的开发者来说,骨架搭好之后,写用例就是往固定位置填内容,心智负担会小很多。
这篇面向的是已经会写基础断言、但还没系统组织过测试工程的开发者。我会给出三套可直接复制的目录结构和配置,分别对应 Jest、Pytest、Mocha,然后挑其中一套完整演示从写用例到断言通过的过程。Hermes 项目里常见的模块划分、外部服务调用、异步逻辑,都会在骨架层面预留好位置。
先明确一个判断标准:什么样的骨架算合格。我的标准是四条。第一,新增一个测试文件不需要改任何配置,放进目录就能被识别。第二,单元测试和集成测试能用不同命令分开跑。第三,公共的 mock、fixture、断言封装有统一入口。第四,本地跑和 CI 跑用的是同一套命令,不靠人肉记忆参数。下面所有结构都围绕这四条设计。
如果你现在项目里只有一个tests文件夹、里面平铺着所有用例,那这篇正好适合你。不需要推倒重来,按后面的结构迁移即可。Hermes 自动化测试技能后续几篇会讲用例生成、覆盖率、CI 集成,这一篇是地基。
2. TaoToken 前置准备:把模型能力接进测试工作流
在讲具体骨架之前,先说清楚为什么测试工程里会用到 TaoToken。Hermes 自动化测试技能里有一部分场景是用模型辅助生成用例、补断言、分析失败原因,这些能力需要一个稳定的模型调用入口。TaoToken 提供的就是这个入口,兼容主流模型协议,配置方式和常见 SDK 一致。
你需要准备三样东西:Base URL、API Key、Model ID。Base URL 用https://taotoken.net/api,注意这个地址不带任何查询参数。API Key 在控制台创建,Model ID 按你实际要用的模型填。这三样在后面的配置片段里会反复出现,建议先记下来。
创建 Key 的入口在控制台的 API Keys 页面,地址是https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。进去之后新建一个 Key,复制保存。注意 Key 只在创建时完整显示一次,丢了就重新建。
如果你只是想先验证模型能不能通,可以用模型对话页面直接试,地址是https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。在页面上选模型、填 Key、发一句话,能返回内容就说明链路没问题。这一步建议在写测试代码之前做,避免后面把网络问题和代码问题混在一起排查。
对于长期做编码和 Agent 场景的,可以看 Coding Plan,地址是https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。接入文档在https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=,里面有各语言的调用示例。
这里要强调一点:TaoToken 是模型调用入口,不是测试框架,也不替代 Jest、Pytest、Mocha。测试骨架本身不依赖它,只有当你需要模型辅助生成或分析时才用到。所以下面三套骨架,你可以先不接模型,纯手工写用例跑通,再按需接入。
配置方式上,我建议把 Base URL、Key、Model ID 放在环境变量里,不要硬编码进测试文件。原因很简单:测试代码会进版本库,Key 不能进。后面每套骨架我都会给出对应的环境变量读取方式。
3. 三套可复制配置:Jest、Pytest、Mocha 骨架
这一节是全文的核心,给出三套完整可复制的配置。每套都包含目录结构、配置文件、运行命令。你可以只挑自己项目用的那套,也可以三套对照看设计思路。
3.1 Jest 骨架(Node / TypeScript 项目)
目录结构如下:
hermes-project/ ├── src/ │ └── hermes/ │ ├── client.ts │ └── parser.ts ├── tests/ │ ├── unit/ │ │ └── parser.test.ts │ ├── integration/ │ │ └── client.test.ts │ ├── fixtures/ │ │ └── sample-response.json │ └── setup/ │ └── jest.setup.ts ├── jest.config.ts ├── jest.unit.config.ts ├── jest.integration.config.ts └── package.json基础配置jest.config.ts:
import type { Config } from 'jest'; const baseConfig: Config = { preset: 'ts-jest', testEnvironment: 'node', roots: ['<rootDir>/tests'], setupFilesAfterEnv: ['<rootDir>/tests/setup/jest.setup.ts'], moduleNameMapper: { '^@hermes/(.*)$': '<rootDir>/src/hermes/$1', }, collectCoverageFrom: ['src/**/*.{ts,js}'], coverageDirectory: 'coverage', }; export default baseConfig;单元测试专用配置jest.unit.config.ts:
import type { Config } from 'jest'; import baseConfig from './jest.config'; const config: Config = { ...baseConfig, testMatch: ['<rootDir>/tests/unit/**/*.test.ts'], }; export default config;集成测试专用配置jest.integration.config.ts:
import type { Config } from 'jest'; import baseConfig from './jest.config'; const config: Config = { ...baseConfig, testMatch: ['<rootDir>/tests/integration/**/*.test.ts'], testTimeout: 30000, }; export default config;package.json里的脚本:
{ "scripts": { "test": "jest", "test:unit": "jest --config jest.unit.config.ts", "test:integration": "jest --config jest.integration.config.ts", "test:coverage": "jest --coverage" } }tests/setup/jest.setup.ts里放全局钩子和环境变量读取:
process.env.TAOTOKEN_BASE_URL = process.env.TAOTOKEN_BASE_URL || 'https://taotoken.net/api'; beforeAll(() => { if (!process.env.TAOTOKEN_API_KEY) { console.warn('TAOTOKEN_API_KEY 未设置,涉及模型调用的用例将跳过'); } });这套结构的关键点:roots限定在tests下,testMatch在子配置里收窄,所以新增文件只要放进unit或integration就会被自动识别,不用改配置。moduleNameMapper让测试里可以用@hermes/parser这种别名导入源码,路径清晰。
3.2 Pytest 骨架(Python 项目)
目录结构:
hermes-project/ ├── src/ │ └── hermes/ │ ├── client.py │ └── parser.py ├── tests/ │ ├── unit/ │ │ └── test_parser.py │ ├── integration/ │ │ └── test_client.py │ ├── fixtures/ │ │ └── sample_response.json │ └── conftest.py ├── pytest.ini └── pyproject.tomlpytest.ini配置:
[pytest] testpaths = tests python_files = test_*.py python_classes = Test* python_functions = test_* markers = unit: 单元测试 integration: 集成测试 addopts = -ra --strict-markerstests/conftest.py放公共 fixture:
import os import json import pytest from pathlib import Path FIXTURE_DIR = Path(__file__).parent / "fixtures" @pytest.fixture(scope="session") def taotoken_config(): return { "base_url": os.getenv("TAOTOKEN_BASE_URL", "https://taotoken.net/api"), "api_key": os.getenv("TAOTOKEN_API_KEY", ""), "model_id": os.getenv("TAOTOKEN_MODEL_ID", ""), } @pytest.fixture def sample_response(): with open(FIXTURE_DIR / "sample_response.json", encoding="utf-8") as f: return json.load(f)运行命令:
pytest -m unit pytest -m integration pytest --cov=src/hermes --cov-report=term-missingPytest 的骨架优势在于conftest.py自动发现,fixture 按目录层级生效。tests/unit/conftest.py里定义的 fixture 只对单元测试可见,集成测试不会误用。markers配合-m参数实现分组运行,比按目录更灵活。
3.3 Mocha 骨架(Node 项目,偏轻量)
目录结构:
hermes-project/ ├── src/ │ └── hermes/ │ ├── client.js │ └── parser.js ├── test/ │ ├── unit/ │ │ └── parser.spec.js │ ├── integration/ │ │ └── client.spec.js │ ├── fixtures/ │ │ └── sample-response.json │ └── setup.js ├── .mocharc.json └── package.json.mocharc.json:
{ "require": ["test/setup.js"], "spec": ["test/**/*.spec.js"], "timeout": 10000, "recursive": true }test/setup.js:
process.env.TAOTOKEN_BASE_URL = process.env.TAOTOKEN_BASE_URL || 'https://taotoken.net/api'; global.expect = require('chai').expect;package.json脚本:
{ "scripts": { "test": "mocha", "test:unit": "mocha test/unit/**/*.spec.js", "test:integration": "mocha test/integration/**/*.spec.js --timeout 30000" } }Mocha 本身不带断言库,所以setup.js里挂了 chai 的expect到全局。recursive让子目录自动递归。集成测试单独加长 timeout,因为涉及外部调用。
三套骨架的共同设计:单元和集成分离、fixture 集中、setup 统一、命令标准化。你可以按项目语言选一套,也可以混用(比如前端 Jest、后端 Pytest)。
4. 验证请求:从写用例到断言通过
这一节用 Jest 骨架完整走一遍,从写第一个用例到看到绿色通过。其他两套逻辑一致,只是语法不同。
先写一个被测函数。src/hermes/parser.ts:
export interface HermesMessage { role: string; content: string; } export function parseHermesResponse(raw: string): HermesMessage[] { if (!raw || raw.trim() === '') { throw new Error('响应内容为空'); } const data = JSON.parse(raw); if (!Array.isArray(data.messages)) { throw new Error('响应格式不正确:缺少 messages 数组'); } return data.messages.map((m: any) => ({ role: String(m.role || 'unknown'), content: String(m.content || ''), })); }写单元测试tests/unit/parser.test.ts:
import { parseHermesResponse } from '@hermes/parser'; describe('parseHermesResponse', () => { it('正常解析 messages 数组', () => { const raw = JSON.stringify({ messages: [ { role: 'user', content: '你好' }, { role: 'assistant', content: '你好,有什么可以帮你' }, ], }); const result = parseHermesResponse(raw); expect(result).toHaveLength(2); expect(result[0].role).toBe('user'); expect(result[1].content).toContain('有什么可以帮你'); }); it('空内容抛出错误', () => { expect(() => parseHermesResponse('')).toThrow('响应内容为空'); }); it('缺少 messages 字段抛出错误', () => { const raw = JSON.stringify({ data: [] }); expect(() => parseHermesResponse(raw)).toThrow('缺少 messages 数组'); }); });运行:
npm run test:unit预期输出:
PASS tests/unit/parser.test.ts parseHermesResponse ✓ 正常解析 messages 数组 ✓ 空内容抛出错误 ✓ 缺少 messages 字段抛出错误 Test Suites: 1 passed, 1 total Tests: 3 passed, 3 total到这里第一个用例就跑通了。接下来演示集成测试怎么用 fixture 和模型配置。tests/fixtures/sample-response.json:
{ "messages": [ { "role": "user", "content": "生成一个测试用例" }, { "role": "assistant", "content": "好的,这是一个示例用例" } ] }tests/integration/client.test.ts:
import fs from 'fs'; import path from 'path'; import { parseHermesResponse } from '@hermes/parser'; describe('Hermes 响应解析集成', () => { it('从 fixture 文件读取并解析', () => { const fixturePath = path.join(__dirname, '../fixtures/sample-response.json'); const raw = fs.readFileSync(fixturePath, 'utf-8'); const result = parseHermesResponse(raw); expect(result).toHaveLength(2); expect(result[0].role).toBe('user'); }); it('模型配置从环境变量读取', () => { const baseUrl = process.env.TAOTOKEN_BASE_URL; expect(baseUrl).toBe('https://taotoken.net/api'); }); });运行:
npm run test:integration如果TAOTOKEN_BASE_URL没设置,setup.ts里已经兜底成默认值,所以第二个用例能过。这就是骨架的价值:环境变量读取集中在 setup,用例里不用重复写。
如果你要真正调用模型验证链路,可以在集成测试里加一个真实请求,但建议用环境变量控制开关,避免 CI 里每次都打真实接口:
const runLive = process.env.RUN_LIVE_TEST === '1'; (runLive ? it : it.skip)('真实调用模型返回内容', async () => { const res = await fetch(`${process.env.TAOTOKEN_BASE_URL}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.TAOTOKEN_API_KEY}`, }, body: JSON.stringify({ model: process.env.TAOTOKEN_MODEL_ID, messages: [{ role: 'user', content: '回复 ok' }], }), }); expect(res.status).toBe(200); });本地想跑真实请求时:
RUN_LIVE_TEST=1 npm run test:integration这样默认跳过,需要时手动开,既验证了链路又不拖慢日常测试。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth
这一节对照真实会遇到的报错,给出定位思路。这些报错在 Hermes 自动化测试接入模型能力时出现频率最高。
401 Unauthorized。最常见原因是 Key 没读到或读错。先确认环境变量名和代码里读的一致,比如代码读TAOTOKEN_API_KEY,你设的是TAOTOKEN_KEY,那就读不到。其次确认 Key 没有多余空格,复制时容易带上换行。再确认请求头格式是Authorization: Bearer <key>,少了Bearer前缀也会 401。排查命令:
echo $TAOTOKEN_API_KEY | head -c 8看前几位是否正常,不要打印完整 Key。
local proxy failed。这个报错通常出现在请求根本没发出去,被本地网络层拦了。检查你的运行环境有没有设置HTTP_PROXY/HTTPS_PROXY环境变量,如果有但代理不可用,请求会失败。测试环境建议清掉这些变量:
unset HTTP_PROXY HTTPS_PROXY另外确认 Base URL 拼写正确,是https://taotoken.net/api,不要多加路径或斜杠。
reading 'choices'或Cannot read properties of undefined (reading 'choices')。这是解析响应时choices不存在。原因一般是响应体不是预期的模型返回结构,可能是错误响应被当成正常响应解析了。排查方法:先把原始响应打印出来。
const data = await res.json(); console.log(JSON.stringify(data, null, 2));如果看到的是{ "error": ... },说明请求本身失败了,先解决失败原因,再谈解析。测试里建议加一层判断:
if (!data.choices) { throw new Error(`响应缺少 choices: ${JSON.stringify(data)}`); }OAuth 相关报错。如果你用的是需要 OAuth 的客户端(比如某些 CLI 工具),报错可能提示 token 过期或未授权。这类场景下确认三件套是否齐全:Base URL、Key、Model ID。以 Codex 的auth.json为例,配置结构大致如下:
{ "base_url": "https://taotoken.net/api", "api_key": "你的 Key", "model": "你的 Model ID" }三个字段缺一不可。Base URL 不带查询参数,Key 用控制台创建的,Model ID 按实际模型填。如果用的是 Claude Code 这类工具,配置项名称可能不同,但核心还是这三样。接入文档里有各客户端的完整示例,地址是https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。
再补一个测试工程本身的常见坑:Jest 里testMatch写错导致用例不被识别,表现为No tests found。检查testMatch的 glob 是否匹配你的文件路径。Pytest 里 fixture 找不到,多半是conftest.py放错层级。Mocha 里describe is not defined,是没在 setup 里引入或没配require。
排查顺序建议固定:先确认请求有没有发出去(看网络层报错),再确认响应状态码(401 还是 200),再确认响应结构(有没有 choices),最后才是业务断言。按这个顺序,大部分问题能在前三步定位。
6. 把骨架用起来:下一步怎么接
骨架搭好之后,日常写测试就是往unit和integration目录填文件,公共逻辑往fixtures和setup放。新增用例不需要动配置,这是判断骨架是否合格的最直接标准。
如果你想让模型辅助生成用例,可以在测试工程里加一个脚本,读取源码文件,调用模型生成测试草稿,人工审核后放进对应目录。这一步用到的模型调用配置就是前面说的三件套。模型对话页面可以先手动试效果,地址是https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。长期做这类自动化,Coding Plan 会更合适,地址是https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。
Key 管理和接入文档分别在https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=和https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。建议先把 Key 建好、用模型对话验证一次,再回到测试工程里接。
最后给一个实用建议:骨架里的运行命令写进package.json或Makefile,团队统一用npm run test:unit这种命令,不要各自记参数。CI 配置里直接复用同一套命令,本地和线上行为一致,能省掉大量“在我机器上是好的”这类问题。下一篇会讲用例生成和覆盖率,骨架是那一步的前提。