Private beta — the @z0devs packages are available to design partners while we harden for general availability.Try the live demoRequest access

Testing plugins

Unit and integration tests against a mock host — mock shell store, fake auth adapter, manifest builders, render harness.

@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:

OptionDefaultNotes
values{}Your host's hooks and helpers, stubbed
registriesautoMissing keys auto-instantiate on first readshell.registries.nav works even if you didn't declare it
eventsfresh emitterPass your own createEventsEmitter<Events>() to assert on emissions
apiVersionSHELL_API_VERSIONOverride 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 ran

Reacting 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#

LayerToolCatches
UnitcreateMockShellStore + testing-libraryRender logic, registrations, cleanup, event handling
ContractSelectSharedStoreSlice + host @mf-typesStore drift, at build time
ManifestvalidateManifestEntry in CIMalformed entries before they ship
Integrationpnpm dev:standaloneWhole-plugin behaviour against stubs
End-to-endReal shell + plugin, PlaywrightFederation itself: sharing, loading, routing, auth

Next#