Plugin Testing
Plugin Testing
Section titled “Plugin Testing”Reference for test utilities, patterns, and lint enforcement for OpenClaw plugins.
Test utilities
Section titled “Test utilities”Import: openclaw/plugin-sdk/testing
The testing subpath exports a narrow set of helpers for plugin authors:
import { installCommonResolveTargetErrorCases, shouldAckReaction, removeAckReactionAfterReply,} from "openclaw/plugin-sdk/testing";Available exports
Section titled “Available exports”| Export | Purpose |
|---|---|
installCommonResolveTargetErrorCases | Shared test cases for target resolution error handling |
shouldAckReaction | Check whether a channel should add an ack reaction |
removeAckReactionAfterReply | Remove ack reaction after reply delivery |
The testing subpath also re-exports types useful in test files:
import type { ChannelAccountSnapshot, ChannelGatewayContext, OpenClawConfig, PluginRuntime, RuntimeEnv, MockFn,} from "openclaw/plugin-sdk/testing";Testing target resolution
Section titled “Testing target resolution”Use installCommonResolveTargetErrorCases to add standard error cases for
channel target resolution:
import { describe } from "vitest";import { installCommonResolveTargetErrorCases } from "openclaw/plugin-sdk/testing";
describe("my-channel target resolution", () => { installCommonResolveTargetErrorCases({ resolveTarget: ({ to, mode, allowFrom }) => { // Your channel's target resolution logic return myChannelResolveTarget({ to, mode, allowFrom }); }, implicitAllowFrom: ["user1", "user2"], });
// Add channel-specific test cases it("should resolve @username targets", () => { // ... });});Testing patterns
Section titled “Testing patterns”Unit testing a channel plugin
Section titled “Unit testing a channel plugin”import { describe, it, expect, vi } from "vitest";
describe("my-channel plugin", () => { it("should resolve account from config", () => { const cfg = { channels: { "my-channel": { token: "test-token", allowFrom: ["user1"], }, }, };
const account = myPlugin.setup.resolveAccount(cfg, undefined); expect(account.token).toBe("test-token"); });
it("should inspect account without materializing secrets", () => { const cfg = { channels: { "my-channel": { token: "test-token" }, }, };
const inspection = myPlugin.setup.inspectAccount(cfg, undefined); expect(inspection.configured).toBe(true); expect(inspection.tokenStatus).toBe("available"); // No token value exposed expect(inspection).not.toHaveProperty("token"); });});Unit testing a provider plugin
Section titled “Unit testing a provider plugin”import { describe, it, expect } from "vitest";
describe("my-provider plugin", () => { it("should resolve dynamic models", () => { const model = myProvider.resolveDynamicModel({ modelId: "custom-model-v2", // ... context });
expect(model.id).toBe("custom-model-v2"); expect(model.provider).toBe("my-provider"); expect(model.api).toBe("openai-completions"); });
it("should return catalog when API key is available", async () => { const result = await myProvider.catalog.run({ resolveProviderApiKey: () => ({ apiKey: "test-key" }), // ... context });
expect(result?.provider?.models).toHaveLength(2); });});Mocking the plugin runtime
Section titled “Mocking the plugin runtime”For code that uses createPluginRuntimeStore, mock the runtime in tests:
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
const store = createPluginRuntimeStore<PluginRuntime>("test runtime not set");
// In test setupconst mockRuntime = { agent: { resolveAgentDir: vi.fn().mockReturnValue("/tmp/agent"), // ... other mocks }, config: { loadConfig: vi.fn(), writeConfigFile: vi.fn(), }, // ... other namespaces} as unknown as PluginRuntime;
store.setRuntime(mockRuntime);
// After testsstore.clearRuntime();Testing with per-instance stubs
Section titled “Testing with per-instance stubs”Prefer per-instance stubs over prototype mutation:
// Preferred: per-instance stubconst client = new MyChannelClient();client.sendMessage = vi.fn().mockResolvedValue({ id: "msg-1" });
// Avoid: prototype mutation// MyChannelClient.prototype.sendMessage = vi.fn();Contract tests (in-repo plugins)
Section titled “Contract tests (in-repo plugins)”Bundled plugins have contract tests that verify registration ownership:
pnpm test -- src/plugins/contracts/These tests assert:
- Which plugins register which providers
- Which plugins register which speech providers
- Registration shape correctness
- Runtime contract compliance
Running scoped tests
Section titled “Running scoped tests”For a specific plugin:
pnpm test -- <bundled-plugin-root>/my-channel/For contract tests only:
pnpm test -- src/plugins/contracts/shape.contract.test.tspnpm test -- src/plugins/contracts/auth.contract.test.tspnpm test -- src/plugins/contracts/runtime.contract.test.tsLint enforcement (in-repo plugins)
Section titled “Lint enforcement (in-repo plugins)”Three rules are enforced by pnpm check for in-repo plugins:
- No monolithic root imports —
openclaw/plugin-sdkroot barrel is rejected - No direct
src/imports — plugins cannot import../../src/directly - No self-imports — plugins cannot import their own
plugin-sdk/<name>subpath
External plugins are not subject to these lint rules, but following the same patterns is recommended.
Test configuration
Section titled “Test configuration”OpenClaw uses Vitest with V8 coverage thresholds. For plugin tests:
# Run all testspnpm test
# Run specific plugin testspnpm test -- <bundled-plugin-root>/my-channel/src/channel.test.ts
# Run with a specific test name filterpnpm test -- <bundled-plugin-root>/my-channel/ -t "resolves account"
# Run with coveragepnpm test:coverageIf local runs cause memory pressure:
OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm testRelated
Section titled “Related”- SDK Overview — import conventions
- SDK Channel Plugins — channel plugin interface
- SDK Provider Plugins — provider plugin hooks
- Building Plugins — getting started guide