Add extension snapshot testing support to frontend-test-utils

Adds the snapshot() method to ExtensionTester, enabling snapshot
testing of extension tree structures. The snapshots use a tree-shaped
format that mirrors the extension hierarchy, with empty fields and
default values omitted for clarity.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-02-02 01:30:22 +01:00
parent a5e536d899
commit 15ed3f9ccb
7 changed files with 297 additions and 2 deletions
+5
View File
@@ -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()`.
@@ -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!
@@ -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 () => (
<div>
{inputs.cards.map(c => c.get(coreExtensionData.reactElement))}
</div>
),
path: '/test',
routeRef: mockRouteRef,
});
},
});
const CardBlueprint = createExtensionBlueprint({
kind: 'card',
attachTo: { id: 'page:test-page', input: 'cards' },
output: [coreExtensionData.reactElement],
factory() {
return [coreExtensionData.reactElement(<div>I'm a lovely card</div>)];
},
});
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",
],
}
`);
});
});
@@ -66,6 +66,14 @@ export class ExtensionQuery<UOutput extends ExtensionDataRef> {
get node(): AppNode;
}
// @public
export interface ExtensionSnapshotNode {
children?: Record<string, ExtensionSnapshotNode[]>;
disabled?: true;
id: string;
outputs?: string[];
}
// @public (undocumented)
export class ExtensionTester<UOutput extends ExtensionDataRef> {
// (undocumented)
@@ -89,6 +97,7 @@ export class ExtensionTester<UOutput extends ExtensionDataRef> {
): ExtensionQuery<NonNullable<T['output']>>;
// (undocumented)
reactElement(): JSX.Element;
snapshot(): ExtensionSnapshotNode;
}
// @public
@@ -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(<div>root</div>)],
});
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(<div>root</div>)],
});
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);
});
});
});
@@ -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<string, ExtensionSnapshotNode[]>;
/** Whether this extension is disabled */
disabled?: true;
}
/** @public */
export class ExtensionQuery<UOutput extends ExtensionDataRef> {
#node: AppNode;
@@ -192,6 +208,54 @@ export class ExtensionTester<UOutput extends ExtensionDataRef> {
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<string, ExtensionSnapshotNode[]> = {};
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;
@@ -18,6 +18,7 @@ export {
createExtensionTester,
type ExtensionTester,
type ExtensionQuery,
type ExtensionSnapshotNode,
} from './createExtensionTester';
export { renderInTestApp, type TestAppOptions } from './renderInTestApp';