强曰为道

与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

第 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-electronVS Code 扩展测试VS Code
MockLSPClient协议层集成测试Node.js
lsp-test (Haskell)通用 LSP Server 测试Haskell
pytest-lspPython LSP Server 测试Python

⚠️ 测试注意事项

问题建议
测试不稳定使用固定的超时和重试机制
Server 进程泄漏确保在 afterAll 中 kill Server
stdout 输出干扰调试信息应输出到 stderr
并发请求混乱使用请求 ID 正确匹配响应

🔗 扩展阅读


下一章第 13 章:高级主题 — 自定义扩展、实验性功能、进度报告。