diff --git a/.changeset/cool-fans-wash.md b/.changeset/cool-fans-wash.md
new file mode 100644
index 0000000000..07d38e7ea2
--- /dev/null
+++ b/.changeset/cool-fans-wash.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-test-utils': patch
+---
+
+The `createExtensionTester` helper is now able to render more than one route in the test app.
diff --git a/docs/frontend-system/building-plugins/testing.md b/docs/frontend-system/building-plugins/testing.md
new file mode 100644
index 0000000000..dbe883c55b
--- /dev/null
+++ b/docs/frontend-system/building-plugins/testing.md
@@ -0,0 +1,235 @@
+---
+id: testing
+title: Frontend System Testing Plugins
+sidebar_label: Testing
+# prettier-ignore
+description: Testing plugins in the frontend system
+---
+
+> **NOTE: The new frontend system is in a highly experimental phase**
+
+# Testing Frontend Plugins
+
+> NOTE: The new frontend system is in alpha, and some plugins do not yet fully implement it.
+
+Utilities for testing frontend features and components are available in `@backstage/frontend-test-utils`.
+
+## Testing React components
+
+A component can be used for more than one extension, and it should be tested independently of an extension environment.
+
+Use the `renderInTestApp` helper to render a given component inside a Backstage test app:
+
+```tsx
+import React from 'react';
+import { screen } from '@testing-library/react';
+import { renderInTestApp } from '@backstage/frontend-test-utils';
+import { EntityDetails } from './plugin';
+
+describe('Entity details component', () => {
+ it('should render the entity name and owner', async () => {
+ await renderInTestApp();
+
+ await expect(
+ screen.findByText('The entity "test" is owned by "tools"'),
+ ).resolves.toBeInTheDocument();
+ });
+});
+```
+
+To mock [Utility APIs](../architecture/06-utility-apis.md) that are used by your component you can use the `TestApiProvider` to override individual API implementations. In the snippet below, we wrap the component within a `TestApiProvider` in order to mock the catalog client API:
+
+```tsx
+import React from 'react';
+import { screen } from '@testing-library/react';
+import {
+ renderInTestApp,
+ TestApiProvider,
+} from '@backstage/frontend-test-utils';
+import { stringifyEntityRef } from '@backstage/catalog-model';
+import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
+import { EntityDetails } from './plugin';
+
+describe('Entity details component', () => {
+ it('should render the entity name and owner', async () => {
+ const catalogApiMock = {
+ async getEntityFacets() {
+ return {
+ facets: {
+ 'relations.ownedBy': [{ count: 1, value: 'group:default/tools' }],
+ },
+ },
+ }
+ } satisfies Partial;
+
+ const entityRef = stringifyEntityRef({
+ kind: 'Component',
+ namespace: 'default',
+ name: 'test',
+ });
+
+ await renderInTestApp(
+
+
+ ,
+ );
+
+ await expect(
+ screen.findByText('The entity "test" is owned by "tools"'),
+ ).resolves.toBeInTheDocument();
+ });
+});
+```
+
+## Testing extensions
+
+To facilitate testing of frontend extensions, the `@backstage/frontend-test-utils` package provides a tester class which starts up an entire frontend harness, complete with a number of default features. You can then provide overrides for extensions whose behavior you need to adjust for the test run.
+
+A number of features (frontend extensions and overrides) are also accepted by the tester. Here are some examples of how these facilities can be useful:
+
+### Single extension
+
+In order to test an extension in isolation, you simply need to pass it into the tester factory, then call the render method on the returned instance:
+
+```tsx
+import { screen } from '@testing-library/react';
+import { createExtensionTester } from '@backstage/frontend-test-utils';
+import { indexPageExtension } from './plugin';
+
+describe('Index page', () => {
+ it('should render a the index page', () => {
+ createExtensionTester(indexPageExtension).render();
+
+ expect(screen.getByText('Index Page')).toBeInTheDocument();
+ });
+});
+```
+
+### Extension preset
+
+There are some extensions that rely on other extensions existence, such as a page that links to another page. In that case, you can add more than one extension to the preset of features you want to render in the test, as shown below:
+
+```tsx
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { createExtensionTester } from '@backstage/frontend-test-utils';
+import { indexPageExtension, detailsPageExtension } from './plugin';
+
+describe('Index page', async () => {
+ it('should link to the details page', () => {
+ createExtensionTester(indexPageExtension)
+ // Adding more extensions to the preset being tested
+ .add(detailsPageExtension)
+ .render();
+
+ await expect(screen.findByText('Index Page')).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('link', { name: 'See details' }));
+
+ await expect(
+ screen.findByText('Details Page'),
+ ).resolves.toBeInTheDocument();
+ });
+});
+```
+
+### Mocking apis
+
+If your extensions requires implementation of APIs that aren't wired up by default, you'll have to add overrides to the preset of features being tested:
+
+```tsx
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { createApiFactory } from '@backestage/core-plugin-api';
+import {
+ createExtensionOverrides,
+ configApiRef,
+ analyticsApiRef,
+} from '@backstage/frontend-plugin-api';
+import {
+ createExtensionTester,
+ MockConfigApi,
+ MockAnalyticsApi,
+} from '@backstage/frontend-test-utils';
+import { indexPageExtension } from './plugin';
+
+describe('Index page', () => {
+ it('should capture click events in analytics', async () => {
+ // Mocking the analytics api implementation
+ const analyticsApiMock = new MockAnalyticsApi();
+
+ const analyticsApiOverride = createApiExtension({
+ factory: createApiFactory({
+ api: analyticsApiRef,
+ factory: () => analyticsApiMock,
+ }),
+ });
+
+ createExtensionTester(indexPageExtension)
+ // Overriding the analytics api extension
+ .add(analyticsApiOverride)
+ .render();
+
+ await userEvent.click(
+ await screen.findByRole('link', { name: 'See details' }),
+ );
+
+ expect(analyticsApiMock.getEvents()[0]).toMatchObject({
+ action: 'click',
+ subject: 'See details',
+ });
+ });
+});
+```
+
+### Setting configuration
+
+In the case that your extension can be configured, you can test this capability by passing configuration values as follows:
+
+```tsx
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { createExtensionTester } from '@backstage/frontend-test-utils';
+import { indexPageExtension, detailsPageExtension } from './plugin';
+
+describe('Index page', () => {
+ it('should accepts a custom title via config', async () => {
+ createExtensionTester(indexPageExtension, {
+ // Configuration specific of index page
+ config: { title: 'Custom index' },
+ })
+ .add(detailsExtensionPage, {
+ // Configuration specific of details page
+ config: { title: 'Custom details' },
+ })
+ .render({
+ // Configuration specific of the instance
+ config: {
+ app: {
+ title: 'Custom app',
+ },
+ },
+ });
+
+ await expect(
+ screen.findByRole('heading', { name: 'Custom app' }),
+ ).resolves.toBeInTheDocument();
+
+ await expect(
+ screen.findByRole('heading', { name: 'Custom index' }),
+ ).resolves.toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('link', { name: 'See details' }));
+
+ await expect(
+ screen.findByText('Custom details'),
+ ).resolves.toBeInTheDocument();
+ });
+});
+```
+
+That's all for testing features!
+
+## 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-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx
index dc39ab3ae5..1cbe3a65ba 100644
--- a/packages/frontend-app-api/src/wiring/createApp.test.tsx
+++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx
@@ -270,6 +270,25 @@ describe('createApp', () => {
]
+ apis [
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ]
"
`);
});
diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx
index 572e88e647..337da91708 100644
--- a/packages/frontend-app-api/src/wiring/createApp.tsx
+++ b/packages/frontend-app-api/src/wiring/createApp.tsx
@@ -103,6 +103,8 @@ import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/w
import { DefaultComponentsApi } from '../apis/implementations/ComponentsApi';
import { stringifyError } from '@backstage/errors';
+const DefaultApis = defaultApis.map(factory => createApiExtension({ factory }));
+
export const builtinExtensions = [
Core,
CoreRouter,
@@ -114,6 +116,7 @@ export const builtinExtensions = [
DefaultNotFoundErrorPageComponent,
LightTheme,
DarkTheme,
+ ...DefaultApis,
].map(def => resolveExtensionDefinition(def));
/** @public */
@@ -418,7 +421,7 @@ function createApiHolder(
(x): x is typeof createTranslationExtension.translationDataRef.T => !!x,
) ?? [];
- for (const factory of [...defaultApis, ...pluginApis]) {
+ for (const factory of pluginApis) {
factoryRegistry.register('default', factory);
}
@@ -496,15 +499,6 @@ function createApiHolder(
}),
});
- // TODO: ship these as default extensions instead
- for (const factory of defaultApis as AnyApiFactory[]) {
- if (!factoryRegistry.register('app', factory)) {
- throw new Error(
- `Duplicate or forbidden API factory for ${factory.api} in app`,
- );
- }
- }
-
ApiResolver.validateFactories(factoryRegistry, factoryRegistry.getAllApis());
return new ApiResolver(factoryRegistry);
diff --git a/packages/frontend-test-utils/api-report.md b/packages/frontend-test-utils/api-report.md
index 36fc5e30b6..d67518050d 100644
--- a/packages/frontend-test-utils/api-report.md
+++ b/packages/frontend-test-utils/api-report.md
@@ -5,10 +5,11 @@
```ts
///
+import { AnalyticsApi } from '@backstage/frontend-plugin-api';
+import { AnalyticsEvent } from '@backstage/frontend-plugin-api';
import { ErrorWithContext } from '@backstage/test-utils';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { JsonObject } from '@backstage/types';
-import { MockAnalyticsApi } from '@backstage/test-utils';
import { MockConfigApi } from '@backstage/test-utils';
import { MockErrorApi } from '@backstage/test-utils';
import { MockErrorApiOptions } from '@backstage/test-utils';
@@ -47,7 +48,13 @@ export class ExtensionTester {
render(options?: { config?: JsonObject }): RenderResult;
}
-export { MockAnalyticsApi };
+// @public
+export class MockAnalyticsApi implements AnalyticsApi {
+ // (undocumented)
+ captureEvent(event: AnalyticsEvent): void;
+ // (undocumented)
+ getEvents(): AnalyticsEvent[];
+}
export { MockConfigApi };
diff --git a/packages/frontend-test-utils/package.json b/packages/frontend-test-utils/package.json
index cdf2bc14a0..7f157cdc4f 100644
--- a/packages/frontend-test-utils/package.json
+++ b/packages/frontend-test-utils/package.json
@@ -24,7 +24,8 @@
},
"devDependencies": {
"@backstage/cli": "workspace:^",
- "@testing-library/jest-dom": "^6.0.0"
+ "@testing-library/jest-dom": "^6.0.0",
+ "@types/react": "*"
},
"files": [
"dist"
@@ -37,6 +38,7 @@
},
"peerDependencies": {
"@testing-library/react": "^12.1.3 || ^13.0.0 || ^14.0.0",
- "react": "^16.13.1 || ^17.0.0 || ^18.0.0"
+ "react": "^16.13.1 || ^17.0.0 || ^18.0.0",
+ "react-router-dom": "6.0.0-beta.0 || ^6.3.0"
}
}
diff --git a/packages/frontend-test-utils/src/apis/AnalyticsApi/MockAnalyticsApi.test.ts b/packages/frontend-test-utils/src/apis/AnalyticsApi/MockAnalyticsApi.test.ts
new file mode 100644
index 0000000000..a84ffd91aa
--- /dev/null
+++ b/packages/frontend-test-utils/src/apis/AnalyticsApi/MockAnalyticsApi.test.ts
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { MockAnalyticsApi } from './MockAnalyticsApi';
+
+describe('MockAnalyticsApi', () => {
+ const context = {
+ pluginId: 'some-plugin',
+ extensionId: 'some-extension',
+ };
+
+ it('should collect events', () => {
+ const api = new MockAnalyticsApi();
+
+ api.captureEvent({ action: 'action-1', subject: 'subject-1', context });
+ api.captureEvent({
+ action: 'action-2',
+ subject: 'subject-2',
+ value: 42,
+ context,
+ });
+ api.captureEvent({
+ action: 'action-3',
+ subject: 'subject-3',
+ value: 1337,
+ attributes: { some: 'context' },
+ context,
+ });
+
+ expect(api.getEvents()[0]).toMatchObject({
+ subject: 'subject-1',
+ action: 'action-1',
+ context,
+ });
+ expect(api.getEvents()[1]).toMatchObject({
+ subject: 'subject-2',
+ action: 'action-2',
+ value: 42,
+ context,
+ });
+ expect(api.getEvents()[2]).toMatchObject({
+ subject: 'subject-3',
+ action: 'action-3',
+ value: 1337,
+ context,
+ attributes: {
+ some: 'context',
+ },
+ });
+ });
+});
diff --git a/packages/frontend-test-utils/src/apis/AnalyticsApi/MockAnalyticsApi.ts b/packages/frontend-test-utils/src/apis/AnalyticsApi/MockAnalyticsApi.ts
new file mode 100644
index 0000000000..f6ccf4e9d1
--- /dev/null
+++ b/packages/frontend-test-utils/src/apis/AnalyticsApi/MockAnalyticsApi.ts
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { AnalyticsApi, AnalyticsEvent } from '@backstage/frontend-plugin-api';
+
+/**
+ * Mock implementation of {@link frontend-plugin-api#AnalyticsApi} with helpers to ensure that events are sent correctly.
+ * Use getEvents in tests to verify captured events.
+ *
+ * @public
+ */
+export class MockAnalyticsApi implements AnalyticsApi {
+ private events: AnalyticsEvent[] = [];
+
+ captureEvent(event: AnalyticsEvent) {
+ const { action, subject, value, attributes, context } = event;
+
+ this.events.push({
+ action,
+ subject,
+ context,
+ ...(value !== undefined ? { value } : {}),
+ ...(attributes !== undefined ? { attributes } : {}),
+ });
+ }
+
+ getEvents(): AnalyticsEvent[] {
+ return this.events;
+ }
+}
diff --git a/packages/frontend-test-utils/src/apis/AnalyticsApi/index.ts b/packages/frontend-test-utils/src/apis/AnalyticsApi/index.ts
new file mode 100644
index 0000000000..dc5fd062aa
--- /dev/null
+++ b/packages/frontend-test-utils/src/apis/AnalyticsApi/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { MockAnalyticsApi } from './MockAnalyticsApi';
diff --git a/packages/frontend-test-utils/src/apis/index.ts b/packages/frontend-test-utils/src/apis/index.ts
index 81bd8a7abd..1230be9be0 100644
--- a/packages/frontend-test-utils/src/apis/index.ts
+++ b/packages/frontend-test-utils/src/apis/index.ts
@@ -15,7 +15,6 @@
*/
export {
- MockAnalyticsApi,
MockConfigApi,
type ErrorWithContext,
MockErrorApi,
@@ -26,3 +25,5 @@ export {
MockStorageApi,
type MockStorageBucket,
} from '@backstage/test-utils';
+
+export { MockAnalyticsApi } from './AnalyticsApi/MockAnalyticsApi';
diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx
index e738bee7d1..04da2b7e89 100644
--- a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx
+++ b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx
@@ -14,55 +14,215 @@
* limitations under the License.
*/
+import React, { useCallback } from 'react';
+import { Link } from 'react-router-dom';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
import {
+ analyticsApiRef,
+ configApiRef,
coreExtensionData,
+ createApiExtension,
+ createApiFactory,
createExtension,
+ createSchemaFromZod,
+ useAnalytics,
+ useApi,
} from '@backstage/frontend-plugin-api';
-import { screen } from '@testing-library/react';
-import React from 'react';
+import { MockAnalyticsApi } from '../apis';
import { createExtensionTester } from './createExtensionTester';
describe('createExtensionTester', () => {
- it('should render a simple extension', async () => {
- createExtensionTester(
- createExtension({
- namespace: 'test',
- attachTo: { id: 'ignored', input: 'ignored' },
- output: { element: coreExtensionData.reactElement },
- factory: () => ({ element: