Spoosh
Plugin DevelopmentAdvanced

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
  });
});

On this page