Testing Plugins
Unit and integration testing strategies for plugins
Testing plugins ensures they work correctly in isolation and when combined with other plugins.
Why test plugins?
- Plugins run on every request — bugs affect all users
- Complex logic (retry, cache, deduplication) needs verification
- Interactions between plugins can be subtle
- Tests document expected behavior
What to test:
- Middleware calls
next()correctly - Early returns work (cache, deduplication)
- Context modifications (headers, options)
- Lifecycle hooks clean up resources
- Integration with other plugins
Testing Strategy
Unit Tests - Test plugin logic in isolation with mocked context
Integration Tests - Test plugins with real Spoosh instance and hooks
Unit Testing Middleware
Basic Test Setup
import { createMockContext } from "@spoosh/test-utils";
import { describe, it, expect, vi } from "vitest";
import { myPlugin } from "./my-plugin";
describe("myPlugin", () => {
it("should have correct name", () => {
const plugin = myPlugin();
expect(plugin.name).toBe("my-app:my-plugin");
});
it("should operate on read operations", () => {
const plugin = myPlugin();
expect(plugin.operations).toEqual(["read"]);
});
});Testing Middleware Calls next()
it("should call next() and return response", async () => {
const plugin = myPlugin();
const context = createMockContext();
const expectedResponse = { data: { id: 1 }, status: 200 };
const next = vi.fn().mockResolvedValue(expectedResponse);
const result = await plugin.middleware!(context, next);
expect(next).toHaveBeenCalled();
expect(result).toEqual(expectedResponse);
});Testing Early Return (Cache Pattern)
it("should return cached data when available", async () => {
const plugin = cachePlugin({ staleTime: 5000 });
const stateManager = createStateManager();
stateManager.setCache('{"method":"GET","path":["users","1"]}', {
state: {
data: { id: 1, name: "Cached User" },
error: undefined,
timestamp: Date.now(),
},
tags: ["users", "users/1"],
stale: false,
});
const context = createMockContext({
stateManager,
queryKey: '{"method":"GET","path":["users","1"]}',
});
const next = vi.fn();
const result = await plugin.middleware!(context, next);
expect(next).not.toHaveBeenCalled();
expect(result.data).toEqual({ id: 1, name: "Cached User" });
});Key points:
- Use
createStateManager()from test utils - Set up cache state before testing
- Verify
next()is not called for early returns
Testing Context Modification
it("should modify request context", async () => {
const plugin = retryPlugin({ retries: 5 });
const context = createMockContext();
const next = vi.fn().mockResolvedValue({ data: { id: 1 }, status: 200 });
await plugin.middleware!(context, next);
expect(context.request.retries).toBe(5);
});
it("should override with request option", async () => {
const plugin = retryPlugin({ retries: 3 });
const context = createMockContext({
pluginOptions: { retries: 10 },
});
const next = vi.fn().mockResolvedValue({ data: { id: 1 }, status: 200 });
await plugin.middleware!(context, next);
expect(context.request.retries).toBe(10);
});Testing Error Handling
it("should handle error responses", async () => {
const plugin = myPlugin();
const context = createMockContext();
const errorResponse = { error: { message: "Not found" }, status: 404 };
const next = vi.fn().mockResolvedValue(errorResponse);
const result = await plugin.middleware!(context, next);
expect(result).toEqual(errorResponse);
});
it("should handle thrown errors", async () => {
const plugin = myPlugin();
const context = createMockContext();
const error = new Error("Network error");
const next = vi.fn().mockRejectedValue(error);
await expect(plugin.middleware!(context, next)).rejects.toThrow(error);
});Unit Testing Lifecycle
Testing onMount
it("should set up resource on mount", () => {
const plugin = myPlugin();
const context = createMockContext({
instanceId: "test-instance-1",
});
plugin.lifecycle!.onMount!(context);
// Verify resource was set up
});Testing onUnmount Cleanup
it("should clean up resource on unmount", () => {
const plugin = pollingPlugin();
const context = createMockContext({
instanceId: "test-instance-1",
pluginOptions: { pollingInterval: 5000 },
});
vi.useFakeTimers();
plugin.lifecycle!.onMount!(context);
plugin.lifecycle!.onUnmount!(context);
vi.advanceTimersByTime(10000);
expect(context.eventEmitter.emit).not.toHaveBeenCalled();
vi.useRealTimers();
});Key points:
- Use
vi.useFakeTimers()to control time - Mount → Unmount → Verify cleanup
- Test that resources are actually released
Testing Memory Leaks
it("should not leak memory on repeated mount/unmount", () => {
const plugin = myPlugin();
for (let i = 0; i < 100; i++) {
const context = createMockContext({
instanceId: `test-instance-${i}`,
});
plugin.lifecycle!.onMount!(context);
plugin.lifecycle!.onUnmount!(context);
}
// Verify internal maps/sets are empty
});Unit Testing AfterResponse
it("should process response in afterResponse", () => {
const plugin = transformPlugin();
const stateManager = createStateManager();
const context = createMockContext({
stateManager,
pluginOptions: {
transform: (data) => ({ ...data, processed: true }),
},
});
const response = { data: { id: 1 }, status: 200 };
plugin.afterResponse!(context, response);
const meta = stateManager.getMeta(context.queryKey);
expect(meta).toHaveProperty("transformedData");
});Integration Testing
Angular plugin testing uses the same patterns as core plugin testing - you test the plugin logic directly with mocked context.
For integration tests with injectRead/injectWrite, you'll need to mock Angular core:
import { vi } from "vitest";
vi.mock("@angular/core", () => ({
signal: <T>(initial: T) => {
let value = initial;
const sig = () => value;
sig.set = (newValue: T) => {
value = newValue;
};
return sig;
},
computed: <T>(fn: () => T) => {
const value = fn();
return () => value;
},
effect: (fn: () => void) => {
fn();
return { destroy: () => {} };
},
inject: () => ({ onDestroy: () => {} }),
DestroyRef: class DestroyRef {},
untracked: <T>(fn: () => T) => fn(),
}));Testing with Real Spoosh Instance
import { Spoosh } from "@spoosh/core";
import { create } from "@spoosh/angular";
import { createStateManager, createEventEmitter } from "@spoosh/test-utils";
import { createPluginExecutor } from "@spoosh/core";
import { myPlugin } from "./my-plugin";
it("should work with real Spoosh instance", () => {
const spoosh = new Spoosh<ApiSchema, Error>("/api").use([myPlugin()]);
const { injectRead } = create(spoosh);
const read = injectRead((api) => api("users").GET());
// Plugin integration tests focus on plugin behavior,
// not Angular component integration
expect(read.data()).toBeDefined();
});Note: For full Angular component integration tests, use your application's testing setup with real Angular TestBed. Plugin tests focus on plugin logic, not framework integration.
Test Utilities
createMockContext
Creates a mock PluginContext for unit testing:
import { createMockContext } from "@spoosh/test-utils";
const context = createMockContext({
operationType: "read",
path: "users/1",
method: "GET",
queryKey: "test-query-key",
tags: ["users", "users/1"],
instanceId: "test-instance-1",
pluginOptions: { customOption: "value" },
stateManager: createStateManager(),
});createStateManager
Creates a real StateManager instance for testing:
import { createStateManager } from "@spoosh/test-utils";
const stateManager = createStateManager();
stateManager.setCache("key", {
state: { data: { id: 1 }, error: undefined, timestamp: Date.now() },
tags: ["users"],
});
const cached = stateManager.getCache("key");Testing Best Practices
1. Test Both Config and Per-Request Options
describe("configuration", () => {
it("should use default config", async () => {
const plugin = myPlugin({ timeout: 5000 });
// Test default behavior
});
it("should override with per-request options", async () => {
const plugin = myPlugin({ timeout: 5000 });
const context = createMockContext({
pluginOptions: { timeout: 10000 },
});
// Test override behavior
});
});2. Test Edge Cases
describe("edge cases", () => {
it("should handle undefined pluginOptions", async () => {
const plugin = myPlugin();
const context = createMockContext({ pluginOptions: undefined });
// ...
});
it("should handle missing instanceId", async () => {
const plugin = myPlugin();
const context = createMockContext({ instanceId: undefined });
// ...
});
});3. Use Fake Timers for Time-Based Logic
describe("polling", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("should poll at specified interval", () => {
const plugin = pollingPlugin();
const context = createMockContext({
pluginOptions: { pollingInterval: 5000 },
});
plugin.lifecycle!.onMount!(context);
vi.advanceTimersByTime(5000);
expect(context.eventEmitter.emit).toHaveBeenCalledWith("refetch", {
queryKey: context.queryKey,
reason: "polling",
});
});
});4. Test Cleanup Thoroughly
describe("cleanup", () => {
it("should remove all listeners on unmount", () => {
const plugin = refetchPlugin();
const context = createMockContext({
instanceId: "test-1",
});
plugin.lifecycle!.onMount!(context);
plugin.lifecycle!.onUnmount!(context);
// Verify all listeners removed
});
});