Test OpenClaw Plugins: Utilities and Patterns Guide
Writing tests for plugins can feel like a chore, especially when you are dealing with complex integrations or external APIs. We have all been there—staring at a failing test suite because a mock did not behave or a target resolution failed. To make your life easier, OpenClaw provides a set of utilities and patterns designed to keep your plugin code reliable without the headache.
If you are looking for specific examples, the how-to guides include worked test examples for Channel plugin tests and Provider plugin tests.
Test utilities
Section titled “Test utilities”You can find the main testing helpers in the openclaw/plugin-sdk/testing subpath. This exports a narrow set of tools to help you handle common plugin tasks.
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 you will need in your test files:
import type { ChannelAccountSnapshot, ChannelGatewayContext, OpenClawConfig, PluginRuntime, RuntimeEnv, MockFn,} from "openclaw/plugin-sdk/testing";Testing target resolution
Section titled “Testing target resolution”You should use installCommonResolveTargetErrorCases to add standard error cases for channel target resolution. This ensures your channel handles edge cases consistently.
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”When testing a channel plugin, you want to verify that accounts resolve correctly from the configuration and that secrets remain protected.
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”For provider plugins, focus on verifying dynamic model resolution and ensuring the catalog behaves correctly when API keys are present.
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”If your code uses createPluginRuntimeStore, you need to mock the runtime in your tests to simulate the environment.
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”It is better to use per-instance stubs rather than mutating prototypes. This keeps your tests isolated and prevents side effects.
// 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 include contract tests. These verify that registration ownership and runtime compliance are correct.
pnpm test -- src/plugins/contracts/These tests check:
- 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”You can run tests for a specific plugin or focus only on contract tests.
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)”If you are working on in-repo plugins, pnpm check enforces three specific rules to keep the codebase clean:
- No monolithic root imports — You cannot use the
openclaw/plugin-sdkroot barrel. - No direct
src/imports — Plugins are not allowed to import../../src/directly. - No self-imports — Plugins cannot import their own
plugin-sdk/<name>subpath.
While external plugins do not have to follow these rules, sticking to these patterns is a good idea.
Test configuration
Section titled “Test configuration”OpenClaw uses Vitest. You can run tests for the whole project or target specific files and filters.
# 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 you run into memory issues during local runs, use these environment variables:
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
Next Steps
Section titled “Next Steps”- Check out the SDK Overview to understand how imports work.
- Learn about Building Plugins if you are just starting.
Need more help? Try the AI Setup Assistant.
OpenClaw Expert
Still stuck?
If this page didn't answer your case, ask OpenClaw Expert for step-by-step guidance.