LSP 开发指南 / 第 12 章:测试策略
12.1 测试概述
LSP Server 的测试面临独特挑战:它是一个通过 stdin/stdout 通信的独立进程。测试策略分为三个层次:
| 层次 | 目标 | 工具 | 速度 |
|---|---|---|---|
| 单元测试 | 单个函数/模块的逻辑 | 标准测试框架 | 极快 |
| 协议测试 | 消息解析/序列化 | Mock 通信层 | 快 |
| 集成测试 | 完整 Server 进程 | 真实进程+Client | 中等 |
12.2 单元测试
12.2.1 核心逻辑测试
将业务逻辑与协议层解耦,独立测试:
// parser.ts
export function extractSymbols(text: string): SymbolInfo[] {
const symbols: SymbolInfo[] = [];
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/^(?:def|function)\s+(\w+)/);
if (match) {
symbols.push({
name: match[1],
kind: "function",
line: i,
character: lines[i].indexOf(match[1]),
});
}
}
return symbols;
}
// parser.test.ts
import { extractSymbols } from "./parser";
describe("extractSymbols", () => {
it("extracts Python functions", () => {
const text = `def hello():\n pass\n\ndef world():\n pass`;
const symbols = extractSymbols(text);
expect(symbols).toHaveLength(2);
expect(symbols[0].name).toBe("hello");
expect(symbols[1].name).toBe("world");
});
it("extracts JavaScript functions", () => {
const text = `function hello() {\n}\n\nconst world = () => {}`;
const symbols = extractSymbols(text);
expect(symbols).toHaveLength(1);
expect(symbols[0].name).toBe("hello");
});
it("returns empty array for no functions", () => {
const text = `const x = 1;\nconst y = 2;`;
expect(extractSymbols(text)).toHaveLength(0);
});
});
12.2.2 增量编辑应用测试
import { applyIncrementalChange } from "./sync";
describe("applyIncrementalChange", () => {
it("replaces a single character", () => {
const result = applyIncrementalChange(
"hello world",
{ start: { line: 0, character: 5 }, end: { line: 0, character: 6 } },
","
);
expect(result).toBe("hello,world");
});
it("inserts text at position", () => {
const result = applyIncrementalChange(
"hello world",
{ start: { line: 0, character: 5 }, end: { line: 0, character: 5 } },
" beautiful"
);
expect(result).toBe("hello beautiful world");
});
it("deletes text", () => {
const result = applyIncrementalChange(
"hello world",
{ start: { line: 0, character: 0 }, end: { line: 0, character: 6 } },
""
);
expect(result).toBe("world");
});
});
12.3 模拟 LSP 客户端
12.3.1 Mock Client 实现
import { EventEmitter } from "events";
import { Duplex } from "stream";
class MockLSPClient extends EventEmitter {
private requestId = 0;
private pendingRequests = new Map<number, {
resolve: (value: any) => void;
reject: (error: any) => void;
}>();
constructor(private inputStream: Duplex, private outputStream: Duplex) {
super();
this.setupReader();
}
private setupReader(): void {
let buffer = "";
this.outputStream.on("data", (chunk: Buffer) => {
buffer += chunk.toString();
// 解析消息
let headerEnd: number;
while ((headerEnd = buffer.indexOf("\r\n\r\n")) !== -1) {
const header = buffer.substring(0, headerEnd);
const match = header.match(/Content-Length:\s*(\d+)/);
if (!match) break;
const contentLength = parseInt(match[1], 10);
const bodyStart = headerEnd + 4;
const body = buffer.substring(bodyStart, bodyStart + contentLength);
if (Buffer.byteLength(body, "utf-8") < contentLength) break;
buffer = buffer.substring(bodyStart + contentLength);
const message = JSON.parse(body);
this.handleMessage(message);
}
});
}
private handleMessage(message: any): void {
if (message.id !== undefined && this.pendingRequests.has(message.id)) {
const pending = this.pendingRequests.get(message.id)!;
this.pendingRequests.delete(message.id);
if (message.error) {
pending.reject(message.error);
} else {
pending.resolve(message.result);
}
} else if (message.method) {
this.emit("notification", message.method, message.params);
}
}
// 发送请求
async sendRequest(method: string, params: any): Promise<any> {
return new Promise((resolve, reject) => {
const id = ++this.requestId;
this.pendingRequests.set(id, { resolve, reject });
const message = JSON.stringify({
jsonrpc: "2.0",
id,
method,
params,
});
const header = `Content-Length: ${Buffer.byteLength(message, "utf-8")}\r\n\r\n`;
this.inputStream.write(header + message);
});
}
// 发送通知
sendNotification(method: string, params: any): void {
const message = JSON.stringify({
jsonrpc: "2.0",
method,
params,
});
const header = `Content-Length: ${Buffer.byteLength(message, "utf-8")}\r\n\r\n`;
this.inputStream.write(header + message);
}
}
12.3.2 使用 Mock Client 测试
import { spawn } from "child_process";
import { MockLSPClient } from "./mock-client";
describe("LSP Server Integration", () => {
let server: any;
let client: MockLSPClient;
beforeAll(async () => {
server = spawn("node", ["dist/server.js"], {
stdio: ["pipe", "pipe", "pipe"],
});
client = new MockLSPClient(server.stdin, server.stdout);
});
afterAll(() => {
server.kill();
});
it("initializes successfully", async () => {
const result = await client.sendRequest("initialize", {
processId: process.pid,
rootUri: "file:///tmp/test",
capabilities: {},
});
expect(result.capabilities).toBeDefined();
expect(result.capabilities.textDocumentSync).toBeDefined();
expect(result.capabilities.completionProvider).toBeDefined();
});
it("provides completions", async () => {
client.sendNotification("initialized", {});
client.sendNotification("textDocument/didOpen", {
textDocument: {
uri: "file:///tmp/test.py",
languageId: "python",
version: 1,
text: "def hello():\n pri",
},
});
const result = await client.sendRequest("textDocument/completion", {
textDocument: { uri: "file:///tmp/test.py" },
position: { line: 1, character: 7 },
});
expect(result.items).toBeDefined();
expect(result.items.length).toBeGreaterThan(0);
const printItem = result.items.find((i: any) => i.label === "print");
expect(printItem).toBeDefined();
});
it("publishes diagnostics", async () => {
const diagnostics = new Promise<any>((resolve) => {
client.on("notification", (method: string, params: any) => {
if (method === "textDocument/publishDiagnostics") {
resolve(params);
}
});
});
client.sendNotification("textDocument/didOpen", {
textDocument: {
uri: "file:///tmp/test.py",
languageId: "python",
version: 1,
text: "# TODO fix this\nx = 1",
},
});
const params = await diagnostics;
expect(params.uri).toBe("file:///tmp/test.py");
expect(params.diagnostics.length).toBeGreaterThan(0);
expect(params.diagnostics[0].code).toBe("todo-comment");
});
});
12.4 使用 @vscode/test-electron 测试 VS Code 扩展
import * as path from "path";
import { runTests } from "@vscode/test-electron";
async function main() {
try {
const extensionDevelopmentPath = path.resolve(__dirname, "..");
const extensionTestsPath = path.resolve(__dirname, "./suite/index");
await runTests({
extensionDevelopmentPath,
extensionTestsPath,
launchArgs: ["--disable-extensions"],
});
} catch (err) {
console.error("Failed to run tests:", err);
process.exit(1);
}
}
main();
测试用例:
import * as vscode from "vscode";
import * as assert from "assert";
suite("LSP Extension Tests", () => {
test("Server starts and provides diagnostics", async () => {
const doc = await vscode.workspace.openTextDocument({
language: "python",
content: "# TODO: fix this\nprint(x)\n",
});
await vscode.window.showTextDocument(doc);
// 等待 Server 处理
await new Promise((resolve) => setTimeout(resolve, 2000));
const diagnostics = vscode.languages.getDiagnostics(doc.uri);
assert.ok(diagnostics.length > 0, "Should have diagnostics");
});
test("Completion works", async () => {
const doc = await vscode.workspace.openTextDocument({
language: "python",
content: "pri",
});
const editor = await vscode.window.showTextDocument(doc);
const position = new vscode.Position(0, 3);
const completions = await vscode.commands.executeCommand<vscode.CompletionList>(
"vscode.executeCompletionItemProvider",
doc.uri,
position
);
assert.ok(completions && completions.items.length > 0);
const printItem = completions.items.find((i) => i.label === "print");
assert.ok(printItem, "Should have 'print' completion");
});
test("Hover provides information", async () => {
const doc = await vscode.workspace.openTextDocument({
language: "python",
content: "print",
});
await vscode.window.showTextDocument(doc);
const hovers = await vscode.commands.executeCommand<vscode.Hover[]>(
"vscode.executeHoverProvider",
doc.uri,
new vscode.Position(0, 2)
);
assert.ok(hovers && hovers.length > 0);
});
});
12.5 协议消息快照测试
import { MockLSPClient } from "./mock-client";
describe("Protocol Message Snapshot", () => {
it("sends correct initialize message format", async () => {
const messages: any[] = [];
const server = spawn("node", ["dist/server.js"], {
stdio: ["pipe", "pipe", "pipe"],
});
// 捕获 Server 发送的所有消息
let buffer = "";
server.stdout.on("data", (chunk: Buffer) => {
buffer += chunk.toString();
// 解析消息...
const message = JSON.parse(/* ... */);
messages.push(message);
});
// 发送初始化
const client = new MockLSPClient(server.stdin, server.stdout);
await client.sendRequest("initialize", {
processId: process.pid,
rootUri: "file:///tmp",
capabilities: {},
});
// 快照比对
expect(messages).toMatchSnapshot();
server.kill();
});
});
12.6 性能测试
describe("Performance Tests", () => {
it("completes analysis within 100ms for 1000-line file", async () => {
const lines = Array.from({ length: 1000 }, (_, i) => `line_${i} = ${i}`);
const text = lines.join("\n");
const start = performance.now();
const diagnostics = analyzeText(text);
const elapsed = performance.now() - start;
expect(elapsed).toBeLessThan(100);
console.log(`Analysis took ${elapsed.toFixed(2)}ms`);
});
it("handles 100 concurrent completion requests", async () => {
const server = spawn("node", ["dist/server.js"], { stdio: ["pipe", "pipe", "pipe"] });
const client = new MockLSPClient(server.stdin, server.stdout);
await client.sendRequest("initialize", { processId: process.pid, rootUri: "file:///tmp", capabilities: {} });
client.sendNotification("initialized", {});
client.sendNotification("textDocument/didOpen", {
textDocument: { uri: "file:///tmp/test.py", languageId: "python", version: 1, text: "x = 1" },
});
const start = performance.now();
const promises = Array.from({ length: 100 }, (_, i) =>
client.sendRequest("textDocument/completion", {
textDocument: { uri: "file:///tmp/test.py" },
position: { line: 0, character: i % 5 },
})
);
await Promise.all(promises);
const elapsed = performance.now() - start;
console.log(`100 completions took ${elapsed.toFixed(2)}ms`);
expect(elapsed).toBeLessThan(5000);
server.kill();
});
});
12.7 测试工具总结
| 工具 | 用途 | 平台 |
|---|---|---|
| Jest / Vitest | 单元测试框架 | Node.js |
| @vscode/test-electron | VS Code 扩展测试 | VS Code |
| MockLSPClient | 协议层集成测试 | Node.js |
| lsp-test (Haskell) | 通用 LSP Server 测试 | Haskell |
| pytest-lsp | Python LSP Server 测试 | Python |
⚠️ 测试注意事项
| 问题 | 建议 |
|---|---|
| 测试不稳定 | 使用固定的超时和重试机制 |
| Server 进程泄漏 | 确保在 afterAll 中 kill Server |
| stdout 输出干扰 | 调试信息应输出到 stderr |
| 并发请求混乱 | 使用请求 ID 正确匹配响应 |
🔗 扩展阅读
下一章:第 13 章:高级主题 — 自定义扩展、实验性功能、进度报告。