Openclaw · EN · Part 4
Ch 13 Testing And Quality Assurance
13-ch-13-Testing-and-Quality-Assurance.md
第13章 Testing and Quality Assurance
"Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it."
— Brian Kernighan
OpenClaw's test suite numbers 3,500 test files spanning fifty-eight separate Vitest configurations. This isn't an accident—it's the foundation that allows twenty messaging channels, hundreds of plugins, and a distributed Gateway architecture to move forward without breaking. When you add a feature that affects Telegram, Discord, the CLI, and three provider plugins simultaneously, you need confidence that nothing else quietly collapsed.
This chapter guides you through OpenClaw's testing philosophy, from writing your first unit test to navigating the performance guardrails that keep the test suite runnable. You'll learn why tests are colocated with source, how to structure tests that survive refactoring, and when to reach for Docker-based E2E validation.
Testing Architecture Overview
OpenClaw uses Vitest as its test framework with V8 coverage collection. The 70% coverage threshold is enforced through CI gates but the architecture goes deeper: tests are organized into layers (unit, integration, E2E), colocated with their source, and split across domain-specific configurations.
The architecture follows three principles:
-
Colocation: Tests live next to the code they test (
src/gateway/protocol/index.test.tstestsindex.ts). This keeps tests in sync during refactors and makes the test surface discoverable. -
Domain partitioning: Instead of one monolithic test configuration, OpenClaw uses fifty-eight configs (
vitest.gateway.config.ts,vitest.channels.config.ts, etc.) each scoped to a domain. This allows targeted execution and faster CI parallelization. -
Isolation control: Most configs use
isolate: falsefor performance (workers share module state) while hot paths like gateway and channels use explicitforkspools for true isolation.
Rendering diagram...
Coverage Thresholds
The V8 provider enforces 70% thresholds for lines, functions, and statements (55% for branches). These are documented in vitest.shared.config.ts:
// [ch-13-code1] from vitest.shared.config.ts
coverage: {
provider: "v8" as const,
reporter: ["text", "lcov"],
all: false,
thresholds: {
lines: 70,
functions: 70,
branches: 55,
statements: 70,
},
include: ["./src/**/*.ts"],
exclude: [
`${BUNDLED_PLUGIN_ROOT_DIR}/**`,
"apps/**",
"ui/**",
"test/**",
"src/**/*.test.ts",
// ... more exclusions
],
}
🤿 深入底层:The
all: falsesetting means coverage only counts files that are actually exercised by tests, not every file insrc/. This is intentional—OpenClaw's coverage surface is the "critical path" code, not the entire codebase. New exclusions should be justified through the same lens.
Test Layers
OpenClaw's tests organize into three layers:
-
Unit tests (
*.test.ts): 3,500+ files testing individual functions, classes, and modules. Run viapnpm testorpnpm test:fast(unit-only). -
Integration tests: Domain-specific configs test component interactions (
vitest.gateway.config.tscovers gateway-protocol, server-methods, and client integration). -
E2E tests (
*.e2e.test.ts): Docker-based tests running live Gateway instances, real channels, and actual provider calls. Run viapnpm test:docker:allor individual e2e scripts.
Writing Effective Tests
The structure of an OpenClaw test follows Vitest conventions with project-specific patterns for fixtures, mocking, and cleanup. Let's walk through a well-structured test:
// [ch-13-code1] Well-structured test example
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { createExpiringMapCache, resolveCacheTtlMs } from "./cache-utils.js";
describe("resolveCacheTtlMs", () => {
it("accepts exact non-negative integers", () => {
expect(resolveCacheTtlMs({ envValue: "0", defaultTtlMs: 60_000 })).toBe(0);
expect(resolveCacheTtlMs({ envValue: "120000", defaultTtlMs: 60_000 })).toBe(120_000);
});
it("rejects malformed env values and falls back to the default", () => {
expect(resolveCacheTtlMs({ envValue: "0abc", defaultTtlMs: 60_000 })).toBe(60_000);
expect(resolveCacheTtlMs({ envValue: "15ms", defaultTtlMs: 60_000 })).toBe(60_000);
});
});
describe("createExpiringMapCache", () => {
let now = 1_000;
const clock = () => now;
beforeEach(() => {
now = 1_000; // [1] Reset shared state
});
it("expires entries on read after the TTL", () => {
const cache = createExpiringMapCache<string, string>({ // [2]
ttlMs: 5_000,
clock, // [3] Inject test clock
});
cache.set("alpha", "a");
expect(cache.get("alpha")).toBe("a");
now = 6_001; // [4] Advance time
expect(cache.get("alpha")).toBeUndefined();
expect(cache.size()).toBe(0);
});
});
Key patterns:
- [1] Reset shared state in
beforeEachto avoid test pollution - [2] Type the generic parameters explicitly for better documentation
- [3] Inject test doubles (clock, random, etc.) as constructor options
- [4] Advance test time deterministically rather than using real delays
Test Helpers and Fixtures
The test/helpers/ directory provides reusable test infrastructure:
| Helper | Purpose |
|---|---|
withTempHome() | Isolated $HOME directory with auto-cleanup |
createCapturedIo() | Capture stdout/stderr without affecting global streams |
spawnGatewayInstance() | Launch a real Gateway for E2E tests |
connectGatewayClient() | Connect a WebSocket client with auth |
Example using withTempHome:
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { readConfig } from "./io.js";
describe("config I/O", () => {
it("writes and reads config in isolated home", async () => {
await withTempHome(async (home) => {
const configPath = `${home}/.openclaw/openclaw.json`;
await writeConfig(configPath, { gateway: { port: 18789 } });
const config = await readConfig(configPath);
expect(config.gateway?.port).toBe(18789);
});
});
});
⚠️ 避坑指南:Never mix
await import(...)and staticimportfor the same module. If you need lazy loading, create a dedicated*.runtime.tsboundary file and import that instead. Mixing import styles breaks Vitest's module caching and causes intermittent test failures.
Mocking Strategies
OpenClaw uses Vitest's vi.mock() hoists for stable module mocking. The pattern in test/setup.shared.ts shows how to mock external dependencies:
// test/setup.shared.ts
vi.mock("@mariozechner/pi-ai", async () => {
const original = await vi.importActual<typeof import("@mariozechner/pi-ai")>("@mariozechner/pi-ai");
return {
...original,
getOAuthApiKey: () => undefined,
getOAuthProviders: () => [],
loginOpenAICodex: vi.fn(),
};
});
Key rules:
- Mock heavy dependencies once in setup files
- Use
vi.importActual()to preserve real exports you don't want to mock - Never mock
openclaw/plugin-sdk/*barrels in hot tests—create local runtime seams instead
Running Tests Locally and in CI
Basic Execution
The primary test command runs all suites:
pnpm test # Run all tests
pnpm test:fast # Unit tests only
pnpm test:coverage # With coverage report
Targeted execution uses the native root-project entrypoint:
pnpm test src/gateway/protocol/index.test.ts # Single file
pnpm test src/gateway/protocol/ -t "additionalProperties" # Filter by name
Live Tests
Live tests require real API keys and validate against actual providers:
OPENCLAW_LIVE_TEST=1 pnpm test:live
# Or specific domains:
OPENCLAW_LIVE_TEST=1 pnpm test:live -- src/agents/auth-profiles/anthropic.setup-token.live.test.ts
Live tests default to quiet mode—keep [live] progress markers but suppress profile chatter. Full logs:
OPENCLAW_LIVE_TEST_QUIET=0 OPENCLAW_LIVE_TEST=1 pnpm test:live
☕ 茶水间时间:Live tests are deliberately opt-in (
OPENCLAW_LIVE_TEST=1) because they burn actual API quota and may expose rate limits in CI logs. When contributing to OpenClaw, run live tests before pushing if you touched auth, provider selection, or model payload construction.
Docker-based E2E Tests
Docker E2E tests validate the full stack: build → install → run real commands → assert results:
# All E2E tests (builds Docker image, runs live gateway, onboarding, etc.)
pnpm test:docker:all
# Individual E2E suites:
pnpm test:docker:live-models # Real provider calls
pnpm test:docker:live-gateway # Gateway server with real nodes
pnpm test:docker:onboard # Installation + onboarding flow
pnpm test:docker:plugins # Plugin installation contracts
These tests live in scripts/e2e/*.sh and use Docker to ensure clean environments:
# scripts/e2e/onboard-docker.sh (excerpt)
docker run --rm \
-v "$PWD:/repo" \
-e OPENCLAW_E2E_MODELS="$OPENCLAW_E2E_MODELS" \
openclaw-e2e \
bash -c "cd /repo && pnpm install && pnpm build && openclaw onboard"
CI Configuration
GitHub Actions workflows run tests in parallel across multiple jobs:
# .github/workflows/test.yml (simplified)
jobs:
test:
strategy:
matrix:
shard: [1, 2, 3, 4] # Split tests across 4 runners
steps:
- uses: actions/checkout@v4
- run: pnpm install
- run: pnpm test --shard=${{ matrix.shard }}/4
CI uses fixed worker counts (ciWorkers = isWindows ? 2 : 3) and runs coverage on every push to main.
Test Performance and Guardrails
OpenClaw's 3,500 test files can take hours if run naively. The test suite uses aggressive performance guardrails to keep runtimes reasonable:
Module Loading Patterns
The most common performance killer is heavy module loading in test loops. Avoid this pattern:
// ❌ BAD: Reloads heavy module every test
beforeEach(async () => {
vi.resetModules();
await import("../../src/plugins/manifest.js");
});
Instead, import once and reset mocks:
// ✅ GOOD: Import once, reset mocks per test
let loadPluginManifest: typeof import("../../src/plugins/manifest.js").loadPluginManifest;
beforeAll(async () => {
const module = await import("../../src/plugins/manifest.js");
loadPluginManifest = module.loadPluginManifest;
});
beforeEach(() => {
vi.clearAllMocks();
});
vi.mock() Usage
Static vi.mock() hoists are preferred over vi.doMock() for heavy modules. Hoisted mocks are evaluated once at module load time:
// ✅ GOOD: Hoisted mock (evaluated once)
vi.mock("../../src/infra/device-identity.js", () => ({
loadOrCreateDeviceIdentity: vi.fn(() => mockDeviceIdentity),
}));
// ❌ BAD: Dynamic mock (evaluated every call)
beforeEach(() => {
vi.doMock("../../src/infra/device-identity.js");
});
⚠️ 避坑指南:Never pair
vi.mock()hoists withvi.resetModules()+ freshawait import()inbeforeEach. This forces Vitest to pay the module graph cost per test. Import heavy modules once inbeforeAll, then reset only the mocks/runtime state inbeforeEach.
Hot Test Identification
OpenClaw tracks test import durations to identify hot tests:
pnpm test:perf:imports # Report import time per test
pnpm test:perf:hotspots # List the slowest test files
Output example:
[src/gateway/server-methods/agent.test.ts] 450ms import
[src/agents/pi-embedded-runner.test.ts] 380ms import
[src/plugins/registry.test.ts] 320ms import
Tests above ~200ms import time are candidates for refactoring:
- Extract heavy runtime code behind
*.runtime.tsboundaries - Mock the boundary instead of importing real implementation
- Split large test files into focused suites
Worker and Memory Management
The default worker count derives from host capabilities:
// From vitest.shared.config.ts
function resolveLocalVitestMaxWorkers(env, system, pool, processStats) {
const cpuCount = system.cpuCount ?? 1;
const totalMemoryGb = system.totalMemoryBytes / 1024 ** 3;
let inferred = cpuCount <= 4 ? 1
: cpuCount <= 8 ? 2
: cpuCount <= 12 ? 3
: cpuCount <= 16 ? 4
: 6;
// Reduce for memory-constrained hosts
if (totalMemoryGb <= 16) inferred = Math.min(inferred, 2);
// Throttle if system is already busy
if (processStats.otherVitestWorkerCount >= 2) {
return { maxWorkers: 1, fileParallelism: false, throttledBySystem: true };
}
return { maxWorkers: inferred, fileParallelism: true, throttledBySystem: false };
}
Manual override:
OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test # Force single worker
OPENCLAW_VITEST_MAX_WORKERS=8 pnpm test # Force 8 workers (max)
Performance Refactoring Example
The gateway tests were a notorious hotspot. The fix involved extracting a gateway-runtime.ts seam:
// src/gateway/test-helpers.runtime.ts (new boundary)
export async function spawnGatewayForTest(config: GatewayConfig) {
// Heavy runtime: spawn process, wait for port, establish WebSocket
const gateway = spawn("node", ["dist/index.js", "gateway", ...]);
await waitForPort(config.port);
return gateway;
}
// src/gateway/server.test.ts (refactored)
import { spawnGatewayForTest } from "./test-helpers.runtime.js";
vi.mock("./test-helpers.runtime.js"); // [1] Mock the heavy runtime
it("handles node connect", async () => {
vi.mocked(spawnGatewayForTest).mockResolvedValue(mockGateway); // [2]
// Test logic...
});
Before: 8 seconds for 10 tests. After: 400ms. The heavy runtime code still gets exercised in *.e2e.test.ts files—unit tests just no longer pay the cost.
Chapter Exercises
-
Write a unit test for
src/config/cache-utils.tsadding a newclear()method that expires all entries. Follow the existing test structure with a fake clock. -
Run live tests with real API keys for your configured provider. Set
OPENCLAW_LIVE_TEST=1and runpnpm test:live -- src/agents/auth-profiles/. Note any failures and investigate whether they're credential issues or test bugs. -
Identify a test performance issue by running
pnpm test:perf:imports. Pick a test over 200ms import time and create a*.runtime.tsseam that allows unit tests to mock the heavy code while preserving E2E coverage.
With testing understood—the philosophy, the patterns, the performance guardrails—you have the foundation to contribute confidently. Tests aren't a gate; they're a safety net that lets the project move fast without breaking. Next, we'll explore the full contribution workflow: how to find issues, write PRs, and get your changes merged into main.