Documentation
Testing Guide
Unit-test mutations that call invalidateTags, component tests that react to SSE events, and widget tests that consume server status — all without a real server or SSE connection.
Overview
The @tanstack-tools/query-tags/testing entry exports four utilities that cover the three most common testing scenarios:
createMockInvalidationSystem
Server-side replacement
Drop-in mock for tagInvalidation. Captures every invalidateTags() call with assertion helpers.
createMockInvalidatorClient
Client-side replacement
Mock InvalidatorClient where every method is a vi.fn() spy with sensible defaults.
createTestProvider
React test wrapper
Creates a TagInvalidationProvider wrapper + queryClient + runtime for Testing Library render().
simulateInvalidation
SSE event simulation
Injects an invalidation message directly into the runtime — no SSE connection needed.
# The /testing entry ships with the main package — no separate install needed.
# Peer deps: vitest (or any framework that exposes a compatible vi.fn)
import {
createMockInvalidationSystem,
createMockInvalidatorClient,
createTestProvider,
simulateInvalidation,
} from "@tanstack-tools/query-tags/testing"/testing entry relies on vi.fn from Vitest being available on globalThis.vi, or you can pass { vi } explicitly to createMockInvalidatorClient. It is safe to import in any Vitest test file.Setup
The examples on this page share a common tag contract. Define it once and import it in both your application code and your test files.
// src/lib/tag-contract.ts
import { defineTags } from "@tanstack-tools/query-tags";
export const appTags = defineTags({
todos: {
list: () => ["todos"],
byId: (id: number) => ["todo", id],
},
} as const);Testing Server Mutations
Use createMockInvalidationSystem to assert that your server actions and mutations call invalidateTags with the correct tags. The mock implements the same interface as the real system — swap it in via dependency injection or a module-level variable.
// src/server/todos.server.ts
import { createServerFn } from "@tanstack/react-start";
import { tagInvalidation } from "#/lib/invalidation-system";
import { appTags } from "#/lib/tag-contract";
export const createTodoServerFn = createServerFn({ method: "POST" })
.handler(async ({ data }: { data: { name: string } }) => {
await db.todos.create({ data: { name: data.name } });
await tagInvalidation.invalidateTags({
tags: [appTags.todos()], // invalidates todos.list + todos.byId
});
});// src/server/todos.test.ts
import { describe, it } from "vitest";
import { createMockInvalidationSystem } from "@tanstack-tools/query-tags/testing";
import { appTags } from "#/lib/tag-contract";
describe("createTodo", () => {
it("invalidates the todos tag group", async () => {
const system = createMockInvalidationSystem();
// Call the logic under test, passing the mock system in place of the real one
await system.invalidateTags({ tags: [appTags.todos()] });
// Assert every leaf under appTags.todos() was invalidated
system.expectTagsInvalidated(['"todos"']);
});
});"todos" for a string tag, ["todo",42] for an array tag. Use appTags.todos.list() on the query side — normalizeTag converts it to the same string the mock captures.// Test helpers from createMockInvalidationSystem
const system = createMockInvalidationSystem();
// Run your mutation / server action
await runMyMutation(system);
// Assert specific tags were invalidated
system.expectTagsInvalidated(['"todos"', '["todo",42]']);
// Assert certain tags were NOT touched
system.expectTagsNotInvalidated(['"products"']);
// Count how many times a tag was invalidated
const count = system.getInvalidationCount('"todos"');
// Inspect the raw messages for custom assertions
console.log(system.emittedMessages);
// [{ scopeKey: "global", tags: ['"todos"', '["todo",42]'] }]
// Reset between test cases
system.reset();Component Tests with simulateInvalidation
Use createTestProvider to wrap your component in a full TagInvalidationProvider, then call simulateInvalidation to inject a synthetic SSE event directly into the runtime. No server or WebSocket needed.
// src/components/TodoList.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import {
createTestProvider,
simulateInvalidation,
} from "@tanstack-tools/query-tags/testing";
import { appTags } from "#/lib/tag-contract";
import { TodoList } from "./TodoList";
describe("TodoList", () => {
it("refetches when the todos tag is invalidated", async () => {
const { TestProvider, queryClient, runtime } = createTestProvider();
// Seed the cache
queryClient.setQueryData(["todos"], [{ id: 1, name: "Buy oat milk" }]);
render(<TodoList />, { wrapper: TestProvider });
expect(screen.getByText("Buy oat milk")).toBeInTheDocument();
// Simulate an SSE invalidation event (no real server or SSE needed)
await simulateInvalidation(runtime, { tags: [appTags.todos.list()] });
// Query is now stale — a refetch has been scheduled
await waitFor(() => {
expect(queryClient.isFetching({ queryKey: ["todos"] })).toBe(1);
});
});
});simulateInvalidation calls runtime.processMessage() directly, bypassing SSE. The runtime invalidates the matching queries in the provided queryClient exactly as it would in production.Mocking the InvalidatorClient
Components that call the server control plane (pause, enable/disable tags, fetch status) use the InvalidatorClient interface. Use createMockInvalidatorClient to provide a fully-typed mock where every method is a vi.fn() spy.
// src/components/StatusWidget.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { vi } from "vitest";
import {
createMockInvalidatorClient,
createTestProvider,
} from "@tanstack-tools/query-tags/testing";
import { StatusWidget } from "./StatusWidget";
describe("StatusWidget", () => {
it("renders connected client count from the server", async () => {
const client = createMockInvalidatorClient({ vi });
// Override the default resolved value for getStatus
client.mocks.getStatus.mockResolvedValue({
paused: false,
disabledTags: [],
connectedClients: 3,
connectedClientsByScope: {},
totalInvalidations: 12,
recentInvalidations: [],
});
const { TestProvider } = createTestProvider({ client });
render(<StatusWidget />, { wrapper: TestProvider });
expect(await screen.findByText("3")).toBeInTheDocument();
});
});client.reset() between test cases to clear all spy call history without recreating the client. Access individual spies via client.mocks.getStatus, client.mocks.pause, etc.Testing Scoped Invalidation
When your app uses scopes (per-user or per-tenant isolation), createMockInvalidationSystem records the scope key on each message. Inspect system.emittedMessages directly for assertions beyond the built-in helpers.
// Testing scoped invalidation (e.g. per-user or per-tenant)
import { createMockInvalidationSystem } from "@tanstack-tools/query-tags/testing";
const system = createMockInvalidationSystem();
await system.invalidateTags({
tags: [appTags.todos.list()],
scope: { userId: "user-123" },
});
// Scoped invalidation messages include the scope key
const [msg] = system.emittedMessages;
console.log(msg.scopeKey); // "user:user-123" (or however getScopeKey formats it)
// Scope presence can be asserted via raw emittedMessages
expect(msg.scopeKey).not.toBe("global");Quick Reference
| Scenario | Utility | How to use |
|---|---|---|
| Assert mutation calls invalidateTags | createMockInvalidationSystem | Inject mock; call expectTagsInvalidated() |
| Count invalidation calls | createMockInvalidationSystem | getInvalidationCount(tag) |
| Reset between tests | createMockInvalidationSystem | system.reset() |
| Trigger SSE event in a component test | simulateInvalidation | Pass runtime from createTestProvider |
| Mock getStatus / pause / resume | createMockInvalidatorClient | client.mocks.getStatus.mockResolvedValue(…) |
| Wrap component in TagInvalidationProvider | createTestProvider | render(<Comp />, { wrapper: TestProvider }) |
FAQ
stableSerialize() before storage. String tags become JSON strings: "todos" → "todos". Array tags become JSON arrays: ['["todo",42]']. Pass the serialized form to the assertion helper.