diff --git a/.changeset/extension-snapshot-testing.md b/.changeset/extension-snapshot-testing.md
new file mode 100644
index 0000000000..7ac281ec43
--- /dev/null
+++ b/.changeset/extension-snapshot-testing.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-test-utils': patch
+---
+
+Added `snapshot()` method to `ExtensionTester`, which returns a tree-shaped representation of the resolved extension hierarchy. Convenient to use with `toMatchInlineSnapshot()`.
diff --git a/docs/frontend-system/building-plugins/02-testing.md b/docs/frontend-system/building-plugins/02-testing.md
index 941744c13e..8fa8f12005 100644
--- a/docs/frontend-system/building-plugins/02-testing.md
+++ b/docs/frontend-system/building-plugins/02-testing.md
@@ -200,8 +200,6 @@ describe('Index page', () => {
});
```
-That's all for testing features!
-
## Mounting routes
If your component or extension uses `useRouteRef` to generate links to other routes, you need to mount those routes in the test environment. Both `renderInTestApp` and `renderTestApp` support the `mountedRoutes` option for this purpose.
@@ -243,6 +241,10 @@ describe('MyComponent', () => {
});
```
+## Extension tree snapshots
+
+The `snapshot()` method on `ExtensionTester` returns a tree-shaped representation of the resolved extension hierarchy, which is convenient to use with Jest's `toMatchInlineSnapshot()` for verifying extension structure in tests.
+
## Missing something?
If there's anything else you think needs to be covered in the docs or that you think isn't covered by the test utilities, please create an issue in the Backstage repository. You are always welcome to contribute as well!
diff --git a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx
index 680d93cd1c..c5c34fc02f 100644
--- a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx
+++ b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx
@@ -153,4 +153,61 @@ describe('PageBlueprint', () => {
expect(getByText("I'm a lovely card")).toBeInTheDocument(),
);
});
+
+ it('should produce a correct extension tree snapshot with child extensions', () => {
+ const myPage = PageBlueprint.makeWithOverrides({
+ name: 'test-page',
+ inputs: {
+ cards: createExtensionInput([coreExtensionData.reactElement], {
+ optional: false,
+ singleton: false,
+ }),
+ },
+ factory(originalFactory, { inputs }) {
+ return originalFactory({
+ loader: async () => (
+
+ {inputs.cards.map(c => c.get(coreExtensionData.reactElement))}
+
+ ),
+ path: '/test',
+ routeRef: mockRouteRef,
+ });
+ },
+ });
+
+ const CardBlueprint = createExtensionBlueprint({
+ kind: 'card',
+ attachTo: { id: 'page:test-page', input: 'cards' },
+ output: [coreExtensionData.reactElement],
+ factory() {
+ return [coreExtensionData.reactElement(I'm a lovely card
)];
+ },
+ });
+
+ const tester = createExtensionTester(myPage).add(
+ CardBlueprint.make({ name: 'card', params: {} }),
+ );
+
+ expect(tester.snapshot()).toMatchInlineSnapshot(`
+ {
+ "children": {
+ "cards": [
+ {
+ "id": "card:card",
+ "outputs": [
+ "core.reactElement",
+ ],
+ },
+ ],
+ },
+ "id": "page:test-page",
+ "outputs": [
+ "core.reactElement",
+ "core.routing.path",
+ "core.routing.ref",
+ ],
+ }
+ `);
+ });
});
diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md
index fce8c61d68..4d1a0764ed 100644
--- a/packages/frontend-test-utils/report.api.md
+++ b/packages/frontend-test-utils/report.api.md
@@ -66,6 +66,14 @@ export class ExtensionQuery {
get node(): AppNode;
}
+// @public
+export interface ExtensionSnapshotNode {
+ children?: Record;
+ disabled?: true;
+ id: string;
+ outputs?: string[];
+}
+
// @public (undocumented)
export class ExtensionTester {
// (undocumented)
@@ -89,6 +97,7 @@ export class ExtensionTester {
): ExtensionQuery>;
// (undocumented)
reactElement(): JSX.Element;
+ snapshot(): ExtensionSnapshotNode;
}
// @public
diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx
index e57a7b5058..8293872e29 100644
--- a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx
+++ b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx
@@ -188,4 +188,161 @@ describe('createExtensionTester', () => {
}),
);
});
+
+ describe('snapshot', () => {
+ it('should return a snapshot of the extension tree', () => {
+ const extension = createExtension({
+ name: 'root',
+ attachTo: { id: 'ignored', input: 'ignored' },
+ output: [stringDataRef],
+ factory: () => [stringDataRef('test-text')],
+ });
+
+ const tester = createExtensionTester(extension);
+
+ expect(tester.snapshot()).toMatchInlineSnapshot(`
+ {
+ "id": "root",
+ "outputs": [
+ "test.string",
+ ],
+ }
+ `);
+ });
+
+ it('should include child extensions in the tree', () => {
+ const childInput = createExtensionInput([stringDataRef]);
+
+ const rootExtension = createExtension({
+ name: 'root',
+ attachTo: { id: 'ignored', input: 'ignored' },
+ inputs: {
+ children: childInput,
+ },
+ output: [coreExtensionData.reactElement],
+ factory: () => [coreExtensionData.reactElement(root
)],
+ });
+
+ const childExtension = createExtension({
+ name: 'child',
+ attachTo: { id: 'root', input: 'children' },
+ output: [stringDataRef],
+ factory: () => [stringDataRef('child-data')],
+ });
+
+ const tester = createExtensionTester(rootExtension).add(childExtension);
+
+ expect(tester.snapshot()).toMatchInlineSnapshot(`
+ {
+ "children": {
+ "children": [
+ {
+ "id": "child",
+ "outputs": [
+ "test.string",
+ ],
+ },
+ ],
+ },
+ "id": "root",
+ "outputs": [
+ "core.reactElement",
+ ],
+ }
+ `);
+ });
+
+ it('should include multiple children in sorted order', () => {
+ const childInput = createExtensionInput([stringDataRef]);
+
+ const rootExtension = createExtension({
+ name: 'root',
+ attachTo: { id: 'ignored', input: 'ignored' },
+ inputs: {
+ children: childInput,
+ },
+ output: [coreExtensionData.reactElement],
+ factory: () => [coreExtensionData.reactElement(root
)],
+ });
+
+ const child1 = createExtension({
+ name: 'child1',
+ attachTo: { id: 'root', input: 'children' },
+ output: [stringDataRef],
+ factory: () => [stringDataRef('child1-data')],
+ });
+
+ const child2 = createExtension({
+ name: 'child2',
+ attachTo: { id: 'root', input: 'children' },
+ output: [stringDataRef],
+ factory: () => [stringDataRef('child2-data')],
+ });
+
+ const tester = createExtensionTester(rootExtension)
+ .add(child1)
+ .add(child2);
+
+ expect(tester.snapshot()).toMatchInlineSnapshot(`
+ {
+ "children": {
+ "children": [
+ {
+ "id": "child1",
+ "outputs": [
+ "test.string",
+ ],
+ },
+ {
+ "id": "child2",
+ "outputs": [
+ "test.string",
+ ],
+ },
+ ],
+ },
+ "id": "root",
+ "outputs": [
+ "core.reactElement",
+ ],
+ }
+ `);
+ });
+
+ it('should omit empty children and outputs', () => {
+ const extension = createExtension({
+ name: 'root',
+ attachTo: { id: 'ignored', input: 'ignored' },
+ output: [],
+ factory: () => [],
+ });
+
+ const tester = createExtensionTester(extension);
+
+ expect(tester.snapshot()).toMatchInlineSnapshot(`
+ {
+ "id": "root",
+ }
+ `);
+ });
+
+ it('should produce serializable snapshot data', () => {
+ const extension = createExtension({
+ name: 'root',
+ attachTo: { id: 'ignored', input: 'ignored' },
+ output: [stringDataRef],
+ factory: () => [stringDataRef('test-text')],
+ });
+
+ const tester = createExtensionTester(extension);
+ const snapshot = tester.snapshot();
+
+ expect(snapshot).toEqual({
+ id: 'root',
+ outputs: ['test.string'],
+ });
+
+ expect(JSON.parse(JSON.stringify(snapshot))).toEqual(snapshot);
+ });
+ });
});
diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.tsx
index 1076b82d2d..91a7bf9022 100644
--- a/packages/frontend-test-utils/src/app/createExtensionTester.tsx
+++ b/packages/frontend-test-utils/src/app/createExtensionTester.tsx
@@ -40,6 +40,22 @@ import { createErrorCollector } from '../../../frontend-app-api/src/wiring/creat
import { OpaqueExtensionDefinition } from '@internal/frontend';
import { TestApiRegistry, type TestApiPairs } from '../utils';
+/**
+ * Represents a snapshot of an extension in the app tree.
+ *
+ * @public
+ */
+export interface ExtensionSnapshotNode {
+ /** The ID of the extension */
+ id: string;
+ /** The IDs of output data refs produced by this extension */
+ outputs?: string[];
+ /** Child extensions organized by input name */
+ children?: Record;
+ /** Whether this extension is disabled */
+ disabled?: true;
+}
+
/** @public */
export class ExtensionQuery {
#node: AppNode;
@@ -192,6 +208,54 @@ export class ExtensionTester {
return element;
}
+ /**
+ * Returns a snapshot of the extension tree structure for testing and debugging.
+ * Convenient to use with Jest's inline snapshot testing.
+ *
+ * @example
+ * ```tsx
+ * const tester = createExtensionTester(myExtension);
+ * expect(tester.snapshot()).toMatchInlineSnapshot();
+ * ```
+ */
+ snapshot(): ExtensionSnapshotNode {
+ const tree = this.#resolveTree();
+
+ const buildNode = (node: AppNode): ExtensionSnapshotNode => {
+ const outputs = node.instance
+ ? Array.from(node.instance.getDataRefs())
+ .map(ref => ref.id)
+ .sort()
+ : [];
+
+ const children: Record = {};
+ for (const [inputName, attachedNodes] of node.edges.attachments) {
+ children[inputName] = attachedNodes
+ .map(n => buildNode(n))
+ .sort((a, b) => a.id.localeCompare(b.id));
+ }
+
+ const result: ExtensionSnapshotNode = {
+ id: node.spec.id,
+ };
+
+ // Only include non-empty/non-default fields
+ if (outputs.length > 0) {
+ result.outputs = outputs;
+ }
+ if (Object.keys(children).length > 0) {
+ result.children = children;
+ }
+ if (node.spec.disabled) {
+ result.disabled = true;
+ }
+
+ return result;
+ };
+
+ return buildNode(tree.root);
+ }
+
#resolveTree() {
if (this.#tree) {
return this.#tree;
diff --git a/packages/frontend-test-utils/src/app/index.ts b/packages/frontend-test-utils/src/app/index.ts
index 9db67dbb49..71de66fce6 100644
--- a/packages/frontend-test-utils/src/app/index.ts
+++ b/packages/frontend-test-utils/src/app/index.ts
@@ -18,6 +18,7 @@ export {
createExtensionTester,
type ExtensionTester,
type ExtensionQuery,
+ type ExtensionSnapshotNode,
} from './createExtensionTester';
export { renderInTestApp, type TestAppOptions } from './renderInTestApp';