@z0devs/mf-testing exists so plugin tests never need a live host. It gives you a fully-formed fake
of defineSharedStore, a stub auth adapter, a valid manifest-entry builder and a render helper.
pnpm add -D @z0devs/mf-testing @testing-library/react
# Its peers are the shell-side packages, so they land in your dev tree too. Expected for a dev-only dep;
# add them explicitly if you run with auto-install-peers=false or strict-peer-dependencies=true.@testing-library/react is an optional peer — it's async-imported inside renderWithMockShell,
so the other utilities work without it.
A first test#
import { createMockShellStore, renderWithMockShell } from "@z0devs/mf-testing";
import MyPlugin from "../src/MyPlugin";
test("renders the signed-in user's email", async () => {
const shell = createMockShellStore({
values: {
useAuth: () => ({ user: { id: "u-1", email: "alice@example.com" }, isLoading: false }),
},
});
const { getByText } = await renderWithMockShell(<MyPlugin />, { shell });
expect(getByText("alice@example.com")).toBeInTheDocument();
});What's in the box#
createMockShellStore(options?)#
A DefinedSharedStore with everything optional:
| Option | Default | Notes |
|---|---|---|
values | {} | Your host's hooks and helpers, stubbed |
registries | auto | Missing keys auto-instantiate on first read — shell.registries.nav works even if you didn't declare it |
events | fresh emitter | Pass your own createEventsEmitter<Events>() to assert on emissions |
apiVersion | SHELL_API_VERSION | Override to test version gating |
createMockAuthAdapter(options?)#
An AuthAdapter<TUser> with user, isLoading, login, logout stubs. Override to simulate
logged-out (user: null) or pending (isLoading: true).
mockManifestEntry(overrides?)#
A valid MFEManifestEntry with sane defaults — handy for loader tests and for asserting your own
manifest filters:
const entry = mockManifestEntry({ name: "billing", shellApiVersion: "^2.0.0", enabled: false });renderWithMockShell(ui, { shell? })#
Async wrapper around testing-library's render that mounts the UI inside shell.Provider. Returns
the standard handle (getByText, findByRole, unmount, …).
Patterns worth copying#
Registration and cleanup#
The single most common plugin bug is a missing cleanup. Assert it directly:
const shell = createMockShellStore();
const { unmount } = await renderWithMockShell(<MyPluginInit />, { shell });
expect(shell.registries.nav.getItems()).toHaveLength(1);
expect(shell.registries.routes.getItems()[0].path).toBe("/my-plugin");
unmount();
expect(shell.registries.nav.getItems()).toHaveLength(0); // cleanup ranReacting to a host event#
import { createEventsEmitter } from "@z0devs/mf-context";
const events = createEventsEmitter<{ onOrgChange: { orgId: string } }>();
const shell = createMockShellStore({ events });
const { findByText } = await renderWithMockShell(<MyPlugin />, { shell });
events.emit("onOrgChange", { orgId: "org-2" });
expect(await findByText("org-2")).toBeInTheDocument();An onboarding step's predicate#
const shell = createMockShellStore();
await renderWithMockShell(<MyPluginInit />, { shell });
const step = shell.registries.onboardingSteps.getItems()[0];
expect(step.id).toMatch(/:eula-acceptance$/);
expect(step.predicate({ pathname: "/" })).toBe(true);Predicates are required to be pure and synchronous, which is exactly what makes them easy to test — no rendering needed.
Flag-gated contributions#
const shell = createMockShellStore();
await renderWithMockShell(<MyPluginInit />, { shell });
shell.registries.nav.setActiveFlags([]);
expect(shell.registries.nav.getItems()).toHaveLength(0); // beta item filtered out
shell.registries.nav.setActiveFlags(["beta"]);
expect(shell.registries.nav.getItems()).toHaveLength(1);Manifest linting in CI#
import { validateManifestEntry } from "@z0devs/mf-core";
import entry from "../manifest-entry.json";
test("manifest entry is valid", () => {
const result = validateManifestEntry(entry);
expect(result.valid).toBe(true);
});Cheap, and it catches the class of mistake that would otherwise surface as your plugin silently not loading in someone else's shell.
Caveats#
What the mock does not do
It does not boot a real router or run PluginRouteLoader. Manifest fetching, version gating,
remote loading, retries and integrity checks are not exercised — for those, run the real shell and
plugin together and drive them with Playwright.
Registry auto-proxy returns the same instance for repeated reads, so test isolation means calling
createMockShellStore() per test, not sharing one across a file.
A layered strategy#
| Layer | Tool | Catches |
|---|---|---|
| Unit | createMockShellStore + testing-library | Render logic, registrations, cleanup, event handling |
| Contract | SelectSharedStoreSlice + host @mf-types | Store drift, at build time |
| Manifest | validateManifestEntry in CI | Malformed entries before they ship |
| Integration | pnpm dev:standalone | Whole-plugin behaviour against stubs |
| End-to-end | Real shell + plugin, Playwright | Federation itself: sharing, loading, routing, auth |