跳到主要内容

MCP 集成测试机制:用真 MCP client 打真 server

30 秒导读: 这套测试不测「函数」,测「一整个 MCP server」。它用官方 MCP SDK 起一个 Client,通过 stdio 连上一个真跑起来的 node cli.js 子进程,然后像真实 AI agent 那样 callTool,再把 server 返回的那坨文本拆开,断言里面的「生成的 Playwright 代码」和「页面无障碍快照」对不对。测的是这个工具对外承诺的契约,而不是内部实现。

本章属于 Playwright MCP 系列。若你还不知道这个项目整体是什么、cli.js / createConnection 怎么组装,先看 index入口与包壳;工具目录和能力开关见 配置面与能力开关;工具清单文本从哪来见 文档即真相。这里只讲测试机制本身


1. 这是什么(零基础也能懂)

一句话定义

这是一套黑盒集成测试:它把 Playwright MCP server 当成一个真实进程启动,自己扮演「AI agent 客户端」去调用它的工具,再检查回话对不对。

它为什么不是普通单元测试

普通单元测试是「给函数喂参数,断言返回值」。这里不一样——被测对象是「一个 MCP server 进程」,不是某个函数:

  • 测试进程里 new 一个 MCP Client(和 Claude、Cursor 里那个 client 是同一个 SDK)。
  • 通过标准输入输出(stdio)把它接到一个真的 node cli.js 子进程上。
  • 然后走完整的 MCP 握手(connectping),再发真的 callTool 请求。

换句话说,测试复刻了「真实 agent 用这个 server」的完整链路,只是把 agent 换成了断言代码。

它到底在验证什么

Playwright MCP 这套工具的核心设计契约有三条,测试正是围着这三条转:

契约含义谁来测
ref-based 定位agent 不给像素坐标,而是给快照里的元素引用 ref=e2 来点击click.spec.ts
生成 Playwright 代码每次工具调用都回吐一段真实可读的 page.xxx() 代码core / click.spec.ts 里的 code: 断言
快照 diff每次操作后回一份页面无障碍快照,状态变化(如 [active])可被观察snapshot: 断言

一句话直觉: 把这套测试想成「一个假 agent 坐下来用真 server,每点一下就核对:你生成的代码对吗?你给我看的页面快照对吗?」。


2. 顶层全景(它大概怎么转)

一张图:一条测试从起进程到断言

下面这张图从上到下是一次测试的完整生命周期。看点:测试进程和被测 server 是两个进程,靠 stdio 相连

测试进程 (@playwright/test worker)
┌──────────────────────────────────────────────┐
│ fixtures.ts: startClient() │
│ 1. 拼参数 --headless/--browser/--config │
│ 2. 写临时 config.json 注入 --config │
│ 3. new Client() (可带 roots 能力) │
│ 4. createTransport(): 起子进程 │
└───────────────┬──────────────────────────────┘
│ StdioClientTransport
│ (stdin/stdout = MCP JSON-RPC)

被测 server 子进程
┌──────────────────────────────────────────────┐
│ node cli.js --headless --browser=chrome ... │
│ 真启动一个浏览器,真开页面,真点击 │
└───────────────┬──────────────────────────────┘
│ callTool 的返回:一坨分节文本
│ "### Ran Playwright code ... ### Snapshot ..."

┌──────────────────────────────────────────────┐
│ expect(...).toHaveResponse({ code, snapshot })│
│ parseResponse() 把文本拆成结构化对象 │
│ 再逐字段断言 │
└──────────────────────────────────────────────┘

部件一句话职责

部件干什么在哪
test fixture扩展 @playwright/test,注入 client / startClient / servertests/fixtures.ts:68
startClient拼参数、注入 config、建 client、起进程的总装配tests/fixtures.ts:77-121
createTransport决定用 node cli.js 还是 docker 起 servertests/fixtures.ts:186-222
toHaveResponse自定义匹配器:把返回文本解析后再断言tests/fixtures.ts:226-246
parseResponse / parseSections### 小节 文本拆成 {code, snapshot, result...}tests/fixtures.ts:252-312
server fixture每个测试一个可控的 HTTP 测试站点(喂 HTML)tests/fixtures.ts:175-183
*.spec.ts具体测试:navigate / click / library / clitests/*.spec.ts

主线走一遍(不进代码)

一次典型测试:startClient() 拼好参数并起 node cli.js 子进程 → 测试用 server fixture 造一个页面 → client.callTool({name:'browser_navigate', ...}) → server 真去打开页面、返回一坨分节文本 → toHaveResponse 把文本拆开,断言 codepage.goto(...)snapshot 里含某个无障碍节点。


3. 核心原理(逐个机制,由浅入深)

3.1 startClient:把一个 server 进程装配起来

要解决的小问题: 每个测试都可能想要不同的 server 配置(带 pdf 能力、指定浏览器、注入自定义 config)。得有一个统一入口,按选项拼出正确的启动方式。

思路: 做成一个 fixture 函数,测试调 startClient({args, config, roots}),它负责把这些选项翻译成命令行参数和进程环境。

参数拼装(真实实现): 命令行参数是层层叠加的——先是 project 级的 mcpArgs,再按 CI/headless/browser 追加,最后叠上单测传入的 options.args。见 tests/fixtures.ts:80-90:

// tests/fixtures.ts:82-90(节选)
const args: string[] = mcpArgs ?? [];
if (process.env.CI && process.platform === 'linux')
args.push('--no-sandbox');
if (mcpHeadless)
args.push('--headless');
if (mcpBrowser)
args.push(`--browser=${mcpBrowser}`);
if (options?.args)
args.push(...options.args);

这解释了为什么 tests/capabilities.spec.tsstartClient({ args: ['--caps=pdf'] }) 就能让 server 多出 browser_pdf_save 工具——--caps=pdf 只是被追加进了这个 args 数组。

config.json 注入(真实实现): 如果测试给了 config 对象,fixture 把它序列化写进临时文件,再用 --config=<相对路径> 指给 server。见 tests/fixtures.ts:91-95:

// tests/fixtures.ts:91-95
if (options?.config) {
const configFile = testInfo.outputPath('config.json');
await fs.promises.writeFile(configFile, JSON.stringify(options.config, null, 2));
args.push(`--config=${path.relative(cwd, configFile)}`);
}

关键细节: 每个 client 被 push 进 clients 数组(tests/fixtures.ts:114),fixture 结束时统一 client.close()(tests/fixtures.ts:120)——保证子进程不泄漏。连接后立刻 client.connect(transport) + client.ping()(tests/fixtures.ts:115-116),ping 是「握手真的通了」的冒烟检查。

3.2 用官方 MCP SDK 当客户端

要解决的小问题: 怎么才算「像真实 agent 一样」调这个 server?自己糊一个 JSON-RPC 客户端不可信。

思路: 直接用 @modelcontextprotocol/sdk 里 agent 端会用的同一个 Client,通过 StdioClientTransport 起子进程。这样测试链路和生产链路用的是同一套 SDK 代码。

真实实现: 非 docker 分支用 nodecli.js。见 tests/fixtures.ts:204-217:

// tests/fixtures.ts:204-217(节选)
const transport = new StdioClientTransport({
command: 'node',
args: [path.join(__dirname, '../cli.js'), ...args],
cwd,
stderr: 'pipe',
env: {
...process.env,
DEBUG: process.env.PWMCP_DEBUG ? 'pw:mcp*' : 'pw:mcp:test',
...
PWMCP_PROFILES_DIR_FOR_TEST: profilesDir,
...env,
},
});

几个要点:

  • command: 'node' + cli.js —— 真跑那个入口(01-entrypoints-and-wrapper.md 讲的 cli.js),不是 import 内部函数。
  • stderr: 'pipe' —— server 的错误输出被管道接住,startClient 返回值里的 stderr: () => stderrBuffer(tests/fixtures.ts:108-117)让测试能读到 server 的日志用于断言/调试。
  • PWMCP_PROFILES_DIR_FOR_TEST —— 把浏览器 profile 目录重定向到测试临时目录,避免污染真实用户目录、也让并行 worker 互不干扰。

3.3 支持 MCP roots 协议

要解决的小问题: MCP 有个 roots 概念——client 可以声明「我允许你操作这些目录/URI」。要测 server 对 roots 的处理,client 就得能应答 server 发来的「列出 roots」请求。

思路: 只有当测试传了 roots 时,才在 client 上声明 roots 能力,并注册一个处理器来应答 ListRootsRequestSchema

真实实现:tests/fixtures.ts:97-106:

// tests/fixtures.ts:97-106(节选)
const client = new Client(
{ name: options?.clientName ?? 'test', version: '1.0.0' },
options?.roots ? { capabilities: { roots: {} } } : undefined);
if (options?.roots) {
client.setRequestHandler(ListRootsRequestSchema, async request => {
if (options.rootsResponseDelay)
await new Promise(resolve => setTimeout(resolve, options.rootsResponseDelay));
return { roots: options.roots };
});
}

关键细节: rootsResponseDelay 是故意留的口子——可以让 client 慢慢回 roots,用来测 server 在等待 roots 应答时的行为(超时 / 竞态)。这是「测试要能模拟慢客户端」这一现实需求的体现。

3.4 docker 传输模式

要解决的小问题: server 也支持在容器里跑。得能用同一套测试去打「容器里的 server」。

思路: createTransportmcpMode 分叉:docker 时把 command 换成 docker run ...,其余不变。

真实实现:tests/fixtures.ts:190-201:

// tests/fixtures.ts:190-201(节选)
if (mcpMode === 'docker') {
const relCwd = path.relative(test.info().project.outputDir, cwd);
const dockerCwd = path.posix.join('/app/test-results', relCwd.split(path.sep).join('/'));
const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v',
`${test.info().project.outputDir}:/app/test-results`, '-w', dockerCwd];
const transport = new StdioClientTransport({
command: 'docker',
args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args],
});
...
}

要点:-i 保持 stdin 打开(MCP 靠 stdio 通信必须),--network=host 让容器能连到测试进程起的本地测试站点,-v 把测试输出目录挂进容器,dockerCwd 把宿主机路径重映射到容器内 /app/test-results 下的对应子路径。


4. 深入实现:工具返回的 wire 格式与解析

这一节是理解整套测试(乃至整个工具设计)最值钱的部分:Playwright MCP 的每个工具返回的不是结构化 JSON,而是一坨 Markdown 分节文本。 测试之所以要写 parseResponse,正是为了把这坨文本还原成可断言的对象。

4.1 返回文本长什么样

server 返回内容的第一个 content 块是一段文本,用 ### 小节名 分节。测试要处理的小节名有(见 tests/fixtures.ts:256-264):ErrorResultRan Playwright codeOpen tabsPage stateSnapshotNew console messagesModal stateDownloads

概念上一次 browser_navigate 的返回大致是这样(展示用,含代码围栏,故此处外层用 6 反引号):

### Ran Playwright code
```js
await page.goto('http://localhost:.../hello');
```

### Snapshot
```yaml
- generic [active] [ref=e1]: Hello, world!
```

4.2 parseSections:按 ### 切块

parseSections 把整段文本按行首 ### 切开,每块第一行是节名、其余是内容。见 tests/fixtures.ts:297-312:

// tests/fixtures.ts:298-309(节选)
const sectionHeaders = text.split(/^### /m).slice(1); // 丢掉开头空块
for (const section of sectionHeaders) {
const firstNewlineIndex = section.indexOf('\n');
if (firstNewlineIndex === -1) continue;
const sectionName = section.substring(0, firstNewlineIndex);
const sectionContent = section.substring(firstNewlineIndex + 1).trim();
sections.set(sectionName, sectionContent);
}

4.3 parseResponse:抠出 code 和 snapshot

拿到分节 Map 后,parseResponse 做两件精细活(见 tests/fixtures.ts:252-295):

去掉代码围栏。 Ran Playwright code 小节内容是 ```js … ```,断言时不想带围栏,所以剥掉:

// tests/fixtures.ts:265
const codeNoFrame = code?.replace(/^```js\n/, '').replace(/\n```$/, '');

快照可能是内联,也可能是外链文件。 快照太大时 server 把它写成文件、正文只放一个 [Snapshot](相对路径) 链接;否则直接内联在 ```yaml … ``` 里。parseResponse 两种都处理——先试着匹配链接、读文件,匹配不到就当内联剥围栏。见 tests/fixtures.ts:269-280:

// tests/fixtures.ts:269-280(节选)
let snapshot: string | undefined;
if (snapshotSection) {
const match = snapshotSection.match(/\[Snapshot\]\(([^)]+)\)/);
if (match) {
try { snapshot = fs.readFileSync(path.resolve(cwd, match[1]), 'utf-8'); } catch {}
} else {
snapshot = snapshotSection.replace(/^```yaml\n?/, '').replace(/\n?```$/, '');
}
}

最后返回一个扁平对象 { error, result, code, tabs, pageState, snapshot, consoleMessages, modalState, downloads, isError, attachments }(tests/fixtures.ts:282-295)。attachments 是 content 数组里除第一个文本块之外的其余块(如截图),见 tests/fixtures.ts:267

4.4 toHaveResponse:自定义匹配器

有了 parseResponse,匹配器本身很薄——解析后用 objectContaining 做部分匹配,支持 .not。见 tests/fixtures.ts:226-246:

// tests/fixtures.ts:227-234(节选)
toHaveResponse(response: Response, object: any) {
const parsed = parseResponse(response, test.info().outputPath());
const isNot = this.isNot;
try {
if (isNot) expect(parsed).not.toEqual(expect.objectContaining(object));
else expect(parsed).toEqual(expect.objectContaining(object));
} catch (e: any) { return { pass: isNot, message: () => e.message }; }
...
}

objectContaining 是关键:测试只需写关心的字段({code, snapshot}),不必列全所有小节。


5. 具体测试谱:各 spec 在验证哪条契约

这一节把前面机制落到真实测试上。四个 spec 各盯一条不同的东西。

5.1 core.spec.ts —— 生成代码 + 快照都对

最小的正向用例:browser_navigate 打开测试站点的 hello 页,断言两件事——生成的代码是 page.goto(...),快照里含无障碍节点 ref=e1。见 tests/core.spec.ts:19-27:

// tests/core.spec.ts:20-26
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toHaveResponse({
code: `await page.goto('${server.HELLO_WORLD}');`,
snapshot: expect.stringContaining(`generic [active] [ref=e1]: Hello, world!`),
});

这一条同时验证了「生成 Playwright 代码」和「快照含带 ref 的无障碍节点」两条契约。

5.2 click.spec.ts —— ref 定位的完整闭环

这是最能说明核心可测契约的用例,走了一个三步闭环:

步骤动作断言
① 造页面server.setContent 塞一个 <button>Submit</button>——
② navigate打开该页,拿到快照快照含 button "Submit" [ref=e2](tests/click.spec.ts:36)
③ clickelement:'Submit button' + target:'e2' 点击生成 getByRole('button', { name: 'Submit' }).click(),且快照变成 [active](tests/click.spec.ts:46-47)

第 ③ 步的调用最关键——agent 不给坐标,给的是上一步快照里的 ref=e2。见 tests/click.spec.ts:39-48:

// tests/click.spec.ts:39-48(节选)
expect(await client.callTool({
name: 'browser_click',
arguments: { element: 'Submit button', target: 'e2' },
})).toHaveResponse({
code: `await page.getByRole('button', { name: 'Submit' }).click();`,
snapshot: expect.stringContaining(`button "Submit" [active] [ref=e2]`),
});

这条测试一口气验证了全部三条契约:ref-based 定位(用 target:'e2')、生成代码(得到语义化的 getByRole().click() 而非坐标点击)、快照 diff(点击后 [active] 出现,证明状态变化可观察)。

5.3 library.spec.ts —— CommonJS 冒烟(issue #456)

这条不走 MCP client,而是验证「把 @playwright/mcp 当库、从 CommonJS 里 import() 并调 createConnection()」能跑通。它对应真实 issue #456,防止打包/导出方式回退。见 tests/library.spec.ts:20-28:

// tests/library.spec.ts:22-27(节选)
await fs.writeFile(file, `
import('@playwright/mcp')
.then(playwrightMCP => playwrightMCP.createConnection())
.then(() => console.log('OK'));
`);
expect(child_process.execSync(`node ${file}`, { encoding: 'utf-8' })).toContain('OK');

createConnection 是库入口(见 01-entrypoints-and-wrapper.md),这里只做「能 import、能调、打印 OK」的冒烟。

5.4 cli.spec.ts —— 子命令 --help

验证 CLI 的 install-browser --help 子命令能输出、且含 install。见 tests/cli.spec.ts:22-25:

// tests/cli.spec.ts:22-25
test('install-browser --help', async () => {
const output = child_process.execSync(`node ${cliPath} install-browser --help`, { encoding: 'utf-8' });
expect(output).toContain('install');
});

5.5 capabilities.spec.ts —— 能力开关改变工具清单

前面 3.1 提过:startClient({ args:['--caps=pdf'] }) → 工具清单里出现 browser_pdf_save(tests/capabilities.spec.ts:48-55);--caps=vision → 出现 browser_mouse_*_xy 系列(tests/capabilities.spec.ts:57-66)。这验证了 配置面与能力开关 讲的 --caps 真的改变了 server 暴露的工具集。默认工具清单则由 test snapshot tool list 锁定(tests/capabilities.spec.ts:19-46)。


6. playwright.config.ts:双 project 与并行

测试运行器配置很小,但有两个决定要点。见 tests/../playwright.config.ts:21-38:

配置作用
fullyParalleltrue所有测试并行跑;配合 worker 级独立测试站点端口(fixtures.ts:161)与独立 profile 目录,互不干扰
workersCI 下 2,本地不限CI 收敛并发防抖动
forbidOnlyCI 下 true防止误提交 test.only
project chrome默认 projectnode cli.js,mcpBrowser 默认 chrome(fixtures.ts:156)
project chromium-dockerMCP_IN_DOCKER 时启用用 docker 传输,且 grep 只跑 browser_navigate|browser_click 两个用例做冒烟(playwright.config.ts:29-36)

docker project 之所以只 grep 那两个用例,是因为它只想验证「容器里的 server 基本能通」,而不是在容器里重跑全部套件——这正是 core / click 两条最能代表核心契约的原因。


7. 巧妙之处(可借鉴的技术)

  • 测「进程 + wire 契约」而非「函数」。 用官方 SDK 的 Client + StdioClientTransport 起真 cli.js(fixtures.ts:204-217),测试链路与生产链路共用同一套 MCP 代码,杜绝「mock 和真实实现漂移」。
  • 把返回文本还原成结构化对象再断言。 parseResponse/parseSections(fixtures.ts:252-312)让断言只写关心的字段(objectContaining),既暴露了工具的真实 wire 格式,又让测试读起来清爽。
  • ref-based 定位是可测契约的核心。 click.spec.ts 用「上一步快照给的 ref=e2 → 下一步 click 的 target 」把整套设计钉死成可回归的断言(click.spec.ts:36:42)。
  • 一份 fixture 覆盖多种传输。 createTransport 的 node/docker 分叉(fixtures.ts:186-201)让同一批 spec 既能测本地进程也能测容器,不必写两套。
  • 可注入的慢客户端。 rootsResponseDelay(fixtures.ts:100-101)专为测 server 在等待 client 应答时的时序行为而留。
  • 回归钉子。 library.spec.ts 直接把 issue #456 编号写进 test 注解(library.spec.ts:20),防止 CommonJS 导入方式再次回退。

8. 边界与局限

  • 不是纯单元测试。 每条测试都真起子进程、真开浏览器,慢且依赖环境(node、浏览器、CI 下 --no-sandbox)。快速逻辑单测不在这套里。
  • 断言是「部分匹配」。 toHaveResponseobjectContaining(fixtures.ts:232),只校验列出的字段;返回里的其它小节不写就不校验——好处是抗漂移,代价是覆盖靠测试作者自觉。
  • 快照断言多用 stringContaining core/click 里断言的是快照「包含某节点」,而非整份快照逐字相等——对无关变化更鲁棒,但也不会锁死整份快照。
  • docker project 是冒烟级。 只 grep 两个用例(playwright.config.ts:31),不覆盖全部工具在容器下的行为。

9. 代码地图(导航索引)

主题文件路径符号名
测试 fixture 扩展入口tests/fixtures.ts:68test = baseTest.extend
装配并启动 server 进程tests/fixtures.ts:77-121startClient
参数拼装 / config.json 注入tests/fixtures.ts:82-95args.push / --config
MCP roots 能力与处理器tests/fixtures.ts:97-106ListRootsRequestSchema
选传输:node vs dockertests/fixtures.ts:186-222createTransport
docker 传输分支tests/fixtures.ts:190-201mcpMode === 'docker'
node 起 cli.jstests/fixtures.ts:204-217StdioClientTransport
自定义匹配器tests/fixtures.ts:226-246toHaveResponse
返回文本 → 对象tests/fixtures.ts:252-295parseResponse
### 分节tests/fixtures.ts:297-312parseSections
每测独立测试站点tests/fixtures.ts:160-183_workerServers / server
navigate 正向用例tests/core.spec.ts:19-27browser_navigate
ref 定位点击闭环tests/click.spec.ts:19-49browser_click / target: 'e2'
CommonJS 库冒烟 (issue #456)tests/library.spec.ts:20-28createConnection
CLI 子命令tests/cli.spec.ts:22-25install-browser --help
能力开关改工具清单tests/capabilities.spec.ts:48-77--caps=pdf / --caps=vision
默认工具清单锁定tests/capabilities.spec.ts:19-46test snapshot tool list
双 project / 并行 / CI workersplaywright.config.ts:21-38projects / fullyParallel