From 3bb938d74204c26b403af9d30d7c31b7985b77b6 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 7 Dec 2023 17:11:48 +0100 Subject: [PATCH 01/12] docs: draft plugins testing page Signed-off-by: Camila Belo --- .../building-plugins/testing.md | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 docs/frontend-system/building-plugins/testing.md diff --git a/docs/frontend-system/building-plugins/testing.md b/docs/frontend-system/building-plugins/testing.md new file mode 100644 index 0000000000..c3315d2821 --- /dev/null +++ b/docs/frontend-system/building-plugins/testing.md @@ -0,0 +1,237 @@ +--- +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 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 () => { + renderInTestApp(); + + await expect( + screen.getByText('The entity "test" is owned by "tools"'), + ).resolves.toBeInTheDocument(); + }); +}); +``` + +It's important to highlight that mocking APIs for components is different from mocking them for extensions. In the snippet below, we wrapped the component within a `TestApiProvider` for mocking the Catalog 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: Partial = { + getEntityFacets: () => + Promise.resolve({ + facets: { + 'relations.ownedBy': [{ count: 1, value: 'group:default/tools' }], + }, + }), + }; + + const entityRef = stringifyEntityRef({ + kind: 'Component', + namespace: 'default', + name: 'test', + }); + + renderInTestApp( + + + , + ); + + await expect( + screen.getByText('The entity "test" is owned by "tools"'), + ).resolves.toBeInTheDocument(); + }); +}); +``` + +## Testing features + +To facilitate testing of frontend features, 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', () => { + const tester = createExtensionTester(indexPageExtension); + + tester.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', () => { + const tester = createExtensionTester(indexPageExtension); + + // Adding more extensions to the preset being tested + tester.add(detailsPageExtension); + tester.render(); + + expect(screen.getByText('Index Page')).toBeInTheDocument(); + + userEvent.click(screen.getByRole('link', { name: 'See details' })); + + await expect(screen.getByText('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 anylitics', async () => { + // Mocking the analytics api implementation + const analyticsApiMock = new MockAnalyticsApi(); + + const analyticsApiOverride = createApiExtension({ + factory: createApiFactory({ + api: analyticsApiRef, + factory: () => analyticsApiMock, + }), + }); + + const tester = createExtensionTester(indexPageExtension); + + // Overriding the analytics api extension + tester.add(analyticsApiOverride); + + tester.render(); + + 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 () => { + const tester = createExtensionTester(indexPageExtension, { + // Configuration specific of index page + config: { title: 'Custom index' }, + }); + + tester.add(detailsExtensionPage, { + // Configuration specific of details page + config: { title: 'Custom details' }, + }); + + tester.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(); + + 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! From d653c139e6d73fca81658e142137aedf56a0d7af Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Sat, 9 Dec 2023 12:23:24 +0100 Subject: [PATCH 02/12] fix(frontend-plugin-api): mock analytics context type Signed-off-by: Camila Belo --- packages/frontend-test-utils/api-report.md | 11 +++- .../AnalyticsApi/MockAnalyticsApi.test.ts | 64 +++++++++++++++++++ .../src/apis/AnalyticsApi/MockAnalyticsApi.ts | 43 +++++++++++++ .../src/apis/AnalyticsApi/index.ts | 17 +++++ .../frontend-test-utils/src/apis/index.ts | 3 +- 5 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 packages/frontend-test-utils/src/apis/AnalyticsApi/MockAnalyticsApi.test.ts create mode 100644 packages/frontend-test-utils/src/apis/AnalyticsApi/MockAnalyticsApi.ts create mode 100644 packages/frontend-test-utils/src/apis/AnalyticsApi/index.ts 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/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'; From 4158ef3301f88b40ca10f75759d67200ab8c216d Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Sat, 9 Dec 2023 12:25:19 +0100 Subject: [PATCH 03/12] fix(frontend-test-utils): enable testing more than one route Signed-off-by: Camila Belo --- packages/frontend-test-utils/package.json | 4 +- .../src/app/createExtensionTester.test.tsx | 226 +++++++++++++++--- .../src/app/createExtensionTester.ts | 52 ++-- .../src/app/renderInTestApp.test.tsx | 49 +++- yarn.lock | 2 + 5 files changed, 274 insertions(+), 59 deletions(-) diff --git a/packages/frontend-test-utils/package.json b/packages/frontend-test-utils/package.json index cdf2bc14a0..9ca546721f 100644 --- a/packages/frontend-test-utils/package.json +++ b/packages/frontend-test-utils/package.json @@ -24,12 +24,14 @@ }, "devDependencies": { "@backstage/cli": "workspace:^", - "@testing-library/jest-dom": "^6.0.0" + "@testing-library/jest-dom": "^6.0.0", + "@types/react": "*" }, "files": [ "dist" ], "dependencies": { + "@backstage/core-components": "workspace:^", "@backstage/frontend-app-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/test-utils": "workspace:^", diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx index e738bee7d1..bdcefb7b8b 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 { 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 { Link } from '@backstage/core-components'; +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:
test
}), - }), - ).render(); + const defaultDefinition = { + namespace: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + output: { element: coreExtensionData.reactElement }, + factory: () => ({ element:
test
}), + }; + it('should render a simple extension', async () => { + const extension = createExtension(defaultDefinition); + const tester = createExtensionTester(extension); + tester.render(); await expect(screen.findByText('test')).resolves.toBeInTheDocument(); }); it('should render an extension even if disabled by default', async () => { - createExtensionTester( - createExtension({ - namespace: 'test', - attachTo: { id: 'ignored', input: 'ignored' }, - disabled: true, - output: { element: coreExtensionData.reactElement }, - factory: () => ({ element:
test
}), - }), - ).render(); - + const extension = createExtension({ + ...defaultDefinition, + disabled: true, + }); + const tester = createExtensionTester(extension); + tester.render(); await expect(screen.findByText('test')).resolves.toBeInTheDocument(); }); it("should fail to render an extension that doesn't output a react element", async () => { - expect(() => - createExtensionTester( - createExtension({ - namespace: 'test', - attachTo: { id: 'ignored', input: 'ignored' }, - disabled: true, - output: { path: coreExtensionData.routePath }, - factory: () => ({ path: '/foo' }), - }), - ).render(), - ).toThrow( - "Failed to instantiate extension 'core/router', input 'children' did not receive required extension data 'core.reactElement' from extension 'test'", + const extension = createExtension({ + ...defaultDefinition, + output: { path: coreExtensionData.routePath }, + factory: () => ({ path: '/foo' }), + }); + const tester = createExtensionTester(extension); + expect(() => tester.render()).toThrow( + "Failed to instantiate extension 'core/routes', input 'routes' did not receive required extension data 'core.reactElement' from extension 'test'", + ); + }); + + it('should render multiple extensions', async () => { + const indexPageExtension = createExtension({ + ...defaultDefinition, + factory: () => ({ + element: ( +
+ Index page See details +
+ ), + }), + }); + const detailsPageExtension = createExtension({ + ...defaultDefinition, + name: 'details', + attachTo: { id: 'core/routes', input: 'routes' }, + output: { + path: coreExtensionData.routePath, + element: coreExtensionData.reactElement, + }, + factory: () => ({ path: '/details', element:
Details page
}), + }); + + const tester = createExtensionTester(indexPageExtension); + tester.add(detailsPageExtension); + tester.render(); + + await expect(screen.findByText('Index page')).resolves.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('link', { name: 'See details' })); + + await expect( + screen.findByText('Details page'), + ).resolves.toBeInTheDocument(); + }); + + it.skip('should accepts a custom config', async () => { + const indexPageExtension = createExtension({ + ...defaultDefinition, + configSchema: createSchemaFromZod(z => + z.object({ title: z.string().optional() }), + ), + factory: ({ config }) => { + const Component = () => { + const configApi = useApi(configApiRef); + const appTitle = configApi.getOptionalString('app.title'); + return ( +
+

{appTitle ?? 'Backstafe app'}

+

{config.title ?? 'Index page'}

+ See details +
+ ); + }; + return { + element: , + }; + }, + }); + + const detailsPageExtension = createExtension({ + ...defaultDefinition, + name: 'details', + attachTo: { id: 'core/routes', input: 'routes' }, + configSchema: createSchemaFromZod(z => + z.object({ title: z.string().optional() }), + ), + output: { + path: coreExtensionData.routePath, + element: coreExtensionData.reactElement, + }, + factory: ({ config }) => ({ + path: '/details', + element:
{config.title ?? 'Details page'}
, + }), + }); + + const tester = createExtensionTester(indexPageExtension, { + config: { title: 'Custom index' }, + }); + + tester.add(detailsPageExtension, { + config: { title: 'Custom details' }, + }); + + tester.render({ + 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(); + + fireEvent.click(screen.getByRole('link', { name: 'See details' })); + + await expect( + screen.findByText('Custom details'), + ).resolves.toBeInTheDocument(); + }); + + it.skip('should capture click events in anylitics', async () => { + // Mocking the analytics api implementation + const analyticsApiMock = new MockAnalyticsApi(); + + const analyticsApiOverride = createApiExtension({ + factory: createApiFactory({ + api: analyticsApiRef, + deps: {}, + factory: () => analyticsApiMock, + }), + }); + + const indexPageExtension = createExtension({ + ...defaultDefinition, + factory: () => { + const Component = () => { + const analyticsApi = useAnalytics(); + const handleClick = useCallback(() => { + analyticsApi.captureEvent('click', 'See details'); + }, [analyticsApi]); + return ( +
+ Index Page + +
+ ); + }; + + return { + element: , + }; + }, + }); + + const tester = createExtensionTester(indexPageExtension); + + // Overriding the analytics api extension + tester.add(analyticsApiOverride); + + tester.render(); + + fireEvent.click(await screen.findByRole('button', { name: 'See details' })); + + await waitFor(() => + expect(analyticsApiMock.getEvents()[1]).toMatchObject({ + action: 'click', + subject: 'See details', + }), ); }); }); diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.ts b/packages/frontend-test-utils/src/app/createExtensionTester.ts index 257a47439c..18c3c84ec9 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.ts +++ b/packages/frontend-test-utils/src/app/createExtensionTester.ts @@ -17,6 +17,8 @@ import { createSpecializedApp } from '@backstage/frontend-app-api'; import { ExtensionDefinition, + coreExtensionData, + createExtension, createExtensionOverrides, } from '@backstage/frontend-plugin-api'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports @@ -33,13 +35,27 @@ export class ExtensionTester { options?: { config?: TConfig }, ): ExtensionTester { const tester = new ExtensionTester(); - tester.add(subject, options); + const { output, factory, ...rest } = subject; + // attaching to core/routes to render as index route + const extension = createExtension({ + ...rest, + attachTo: { id: 'core/routes', input: 'routes' }, + output: { + ...output, + path: coreExtensionData.routePath, + }, + factory: params => ({ + ...factory(params), + path: '/', + }), + }); + tester.add(extension, options); return tester; } readonly #extensions = new Array<{ id: string; - extension: ExtensionDefinition; + definition: ExtensionDefinition; config?: JsonValue; }>(); @@ -47,13 +63,19 @@ export class ExtensionTester { extension: ExtensionDefinition, options?: { config?: TConfig }, ): ExtensionTester { - const withNamespace = { + const { name, namespace } = extension; + + const definition = { ...extension, - name: !extension.namespace && !extension.name ? 'test' : extension.name, + // setting name "test" as fallback + name: !namespace && !name ? 'test' : name, }; + + const { id } = resolveExtensionDefinition(definition); + this.#extensions.push({ - id: resolveExtensionDefinition(withNamespace).id, - extension: withNamespace, + id, + definition, config: options?.config as JsonValue, }); @@ -71,27 +93,17 @@ export class ExtensionTester { } const extensionsConfig: JsonArray = [ - ...rest.map(entry => ({ - [entry.id]: { - config: entry.config, + ...rest.map(extension => ({ + [extension.id]: { + config: extension.config, }, })), { [subject.id]: { - attachTo: { id: 'core/router', input: 'children' }, config: subject.config, disabled: false, }, }, - { - 'core/layout': false, - }, - { - 'core/nav': false, - }, - { - 'core/routes': false, - }, ]; const finalConfig = { @@ -105,7 +117,7 @@ export class ExtensionTester { const app = createSpecializedApp({ features: [ createExtensionOverrides({ - extensions: this.#extensions.map(entry => entry.extension), + extensions: this.#extensions.map(extension => extension.definition), }), ], config: new MockConfigApi(finalConfig), diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx index e13d1157ef..d51f66010c 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx @@ -14,14 +14,53 @@ * limitations under the License. */ -import React from 'react'; -import { screen } from '@testing-library/react'; +import React, { useCallback } from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import { + MockAnalyticsApi, + TestApiProvider, +} from '@backstage/frontend-test-utils'; +import { analyticsApiRef, useAnalytics } from '@backstage/frontend-plugin-api'; import { renderInTestApp } from './renderInTestApp'; describe('renderInTestApp', () => { it('should render the given component in a page', async () => { - const Component = () =>
Test
; - renderInTestApp(); - expect(screen.getByText('Test')).toBeInTheDocument(); + const IndexPage = () =>
Index Page
; + renderInTestApp(); + expect(screen.getByText('Index Page')).toBeInTheDocument(); + }); + + it('should works with apis provider', async () => { + const IndexPage = () => { + const analyticsApi = useAnalytics(); + const handleClick = useCallback(() => { + analyticsApi.captureEvent('click', 'See details'); + }, [analyticsApi]); + return ( +
+ Index Page + + See details + +
+ ); + }; + + const analyticsApiMock = new MockAnalyticsApi(); + + renderInTestApp( + + + , + ); + + fireEvent.click(screen.getByRole('link', { name: 'See details' })); + + const events = analyticsApiMock.getEvents(); + + expect(events[1]).toMatchObject({ + action: 'click', + subject: 'See details', + }); }); }); diff --git a/yarn.lock b/yarn.lock index 8a7e8a1dac..8689a6ad0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4227,11 +4227,13 @@ __metadata: resolution: "@backstage/frontend-test-utils@workspace:packages/frontend-test-utils" dependencies: "@backstage/cli": "workspace:^" + "@backstage/core-components": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@testing-library/jest-dom": ^6.0.0 + "@types/react": "*" peerDependencies: "@testing-library/react": ^12.1.3 || ^13.0.0 || ^14.0.0 react: ^16.13.1 || ^17.0.0 || ^18.0.0 From bef798ae725c84be3363c859dd085779df63b288 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 11 Dec 2023 10:10:14 +0100 Subject: [PATCH 04/12] fix(frontend-app-api): fix apis overriding Signed-off-by: Camila Belo --- .../frontend-app-api/src/wiring/createApp.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index 67b8494953..a88c03b188 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -388,7 +388,14 @@ function createApiHolder( (x): x is typeof createTranslationExtension.translationDataRef.T => !!x, ) ?? []; + const apis = new Map(); + + // removing duplicates by id, overriding the default one with the plugin one for (const factory of [...defaultApis, ...pluginApis]) { + apis.set(factory.api.id, factory); + } + + for (const factory of apis.values()) { factoryRegistry.register('default', factory); } @@ -466,15 +473,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); From 7e4b0db5d169806027653c870948fe09fa18fbfb Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Sat, 9 Dec 2023 12:29:43 +0100 Subject: [PATCH 05/12] chore: add changeset file Signed-off-by: Camila Belo --- .changeset/cool-fans-wash.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cool-fans-wash.md diff --git a/.changeset/cool-fans-wash.md b/.changeset/cool-fans-wash.md new file mode 100644 index 0000000000..8154298047 --- /dev/null +++ b/.changeset/cool-fans-wash.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-test-utils': patch +--- + +Accepts rendering more than a route in the test app. From 66ce9f6d1eb5b2d7ffd75835756ddd8dd1c64524 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 13 Dec 2023 16:41:57 +0100 Subject: [PATCH 06/12] refactor(frontend-app-api): converting default apis into extensions Signed-off-by: Camila Belo --- packages/frontend-app-api/src/wiring/createApp.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index a88c03b188..4dc9eda4a0 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -103,6 +103,8 @@ import { toInternalBackstagePlugin } from '../../../frontend-plugin-api/src/wiri import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides'; import { DefaultComponentsApi } from '../apis/implementations/ComponentsApi'; +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 */ @@ -388,14 +391,7 @@ function createApiHolder( (x): x is typeof createTranslationExtension.translationDataRef.T => !!x, ) ?? []; - const apis = new Map(); - - // removing duplicates by id, overriding the default one with the plugin one - for (const factory of [...defaultApis, ...pluginApis]) { - apis.set(factory.api.id, factory); - } - - for (const factory of apis.values()) { + for (const factory of pluginApis) { factoryRegistry.register('default', factory); } From 67409cbfe5d03d5f845c8c2097a6947a766183db Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 13 Dec 2023 16:43:24 +0100 Subject: [PATCH 07/12] refactor(frontend-test-utils): simplying core nav and router for test apps Signed-off-by: Camila Belo --- .../src/wiring/createApp.test.tsx | 19 ++ packages/frontend-test-utils/package.json | 3 +- .../src/app/createExtensionTester.test.tsx | 6 +- .../src/app/createExtensionTester.ts | 136 -------- .../src/app/createExtensionTester.tsx | 301 ++++++++++++++++++ .../src/app/renderInTestApp.test.tsx | 4 +- yarn.lock | 1 + 7 files changed, 327 insertions(+), 143 deletions(-) delete mode 100644 packages/frontend-test-utils/src/app/createExtensionTester.ts create mode 100644 packages/frontend-test-utils/src/app/createExtensionTester.tsx diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx index dafad5365c..338d293132 100644 --- a/packages/frontend-app-api/src/wiring/createApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx @@ -210,6 +210,25 @@ describe('createApp', () => { ] + apis [ + + + + + + + + + + + + + + + + + + ] " `); }); diff --git a/packages/frontend-test-utils/package.json b/packages/frontend-test-utils/package.json index 9ca546721f..33b1f41a8c 100644 --- a/packages/frontend-test-utils/package.json +++ b/packages/frontend-test-utils/package.json @@ -39,6 +39,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/app/createExtensionTester.test.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx index bdcefb7b8b..de52ca52de 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx @@ -103,7 +103,7 @@ describe('createExtensionTester', () => { ).resolves.toBeInTheDocument(); }); - it.skip('should accepts a custom config', async () => { + it('should accepts a custom config', async () => { const indexPageExtension = createExtension({ ...defaultDefinition, configSchema: createSchemaFromZod(z => @@ -175,7 +175,7 @@ describe('createExtensionTester', () => { ).resolves.toBeInTheDocument(); }); - it.skip('should capture click events in anylitics', async () => { + it('should capture click events in anylitics', async () => { // Mocking the analytics api implementation const analyticsApiMock = new MockAnalyticsApi(); @@ -219,7 +219,7 @@ describe('createExtensionTester', () => { fireEvent.click(await screen.findByRole('button', { name: 'See details' })); await waitFor(() => - expect(analyticsApiMock.getEvents()[1]).toMatchObject({ + expect(analyticsApiMock.getEvents()[0]).toMatchObject({ action: 'click', subject: 'See details', }), diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.ts b/packages/frontend-test-utils/src/app/createExtensionTester.ts deleted file mode 100644 index 18c3c84ec9..0000000000 --- a/packages/frontend-test-utils/src/app/createExtensionTester.ts +++ /dev/null @@ -1,136 +0,0 @@ -/* - * 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 { createSpecializedApp } from '@backstage/frontend-app-api'; -import { - ExtensionDefinition, - coreExtensionData, - createExtension, - createExtensionOverrides, -} from '@backstage/frontend-plugin-api'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition'; -import { MockConfigApi } from '@backstage/test-utils'; -import { JsonArray, JsonObject, JsonValue } from '@backstage/types'; -import { RenderResult, render } from '@testing-library/react'; - -/** @public */ -export class ExtensionTester { - /** @internal */ - static forSubject( - subject: ExtensionDefinition, - options?: { config?: TConfig }, - ): ExtensionTester { - const tester = new ExtensionTester(); - const { output, factory, ...rest } = subject; - // attaching to core/routes to render as index route - const extension = createExtension({ - ...rest, - attachTo: { id: 'core/routes', input: 'routes' }, - output: { - ...output, - path: coreExtensionData.routePath, - }, - factory: params => ({ - ...factory(params), - path: '/', - }), - }); - tester.add(extension, options); - return tester; - } - - readonly #extensions = new Array<{ - id: string; - definition: ExtensionDefinition; - config?: JsonValue; - }>(); - - add( - extension: ExtensionDefinition, - options?: { config?: TConfig }, - ): ExtensionTester { - const { name, namespace } = extension; - - const definition = { - ...extension, - // setting name "test" as fallback - name: !namespace && !name ? 'test' : name, - }; - - const { id } = resolveExtensionDefinition(definition); - - this.#extensions.push({ - id, - definition, - config: options?.config as JsonValue, - }); - - return this; - } - - render(options?: { config?: JsonObject }): RenderResult { - const { config = {} } = options ?? {}; - - const [subject, ...rest] = this.#extensions; - if (!subject) { - throw new Error( - 'No subject found. At least one extension should be added to the tester.', - ); - } - - const extensionsConfig: JsonArray = [ - ...rest.map(extension => ({ - [extension.id]: { - config: extension.config, - }, - })), - { - [subject.id]: { - config: subject.config, - disabled: false, - }, - }, - ]; - - const finalConfig = { - ...config, - app: { - ...(typeof config.app === 'object' ? config.app : undefined), - extensions: extensionsConfig, - }, - }; - - const app = createSpecializedApp({ - features: [ - createExtensionOverrides({ - extensions: this.#extensions.map(extension => extension.definition), - }), - ], - config: new MockConfigApi(finalConfig), - }); - - return render(app.createRoot()); - } -} - -/** @public */ -export function createExtensionTester( - subject: ExtensionDefinition, - options?: { config?: TConfig }, -): ExtensionTester { - return ExtensionTester.forSubject(subject, options); -} diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.tsx new file mode 100644 index 0000000000..0404cf1ade --- /dev/null +++ b/packages/frontend-test-utils/src/app/createExtensionTester.tsx @@ -0,0 +1,301 @@ +/* + * 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 React, { ComponentType, ReactNode, useContext, useState } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { Link } from '@backstage/core-components'; +import { RenderResult, render } from '@testing-library/react'; +import { createSpecializedApp } from '@backstage/frontend-app-api'; +import { + ExtensionDefinition, + IconComponent, + IdentityApi, + RouteRef, + configApiRef, + coreExtensionData, + createExtension, + createExtensionInput, + createExtensionOverrides, + createNavItemExtension, + useApi, + useRouteRef, +} from '@backstage/frontend-plugin-api'; +import { MockConfigApi } from '@backstage/test-utils'; +import { JsonArray, JsonObject, JsonValue } from '@backstage/types'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { createSignInPageExtension } from '../../../frontend-plugin-api/src/extensions/createSignInPageExtension'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { InternalExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/createExtension'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { InternalAppContext } from '../../../frontend-app-api/src/wiring/InternalAppContext'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { SignInPageProps } from '../../../core-plugin-api'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { getBasePath } from '../../../core-app-api/src/app/AppRouter'; + +const NavItem = (props: { + routeRef: RouteRef; + title: string; + icon: IconComponent; +}) => { + const { routeRef, title, icon: Icon } = props; + const to = useRouteRef(routeRef)(); + return ( +
  • + + {title} + +
  • + ); +}; + +const TestCoreNavExtension = createExtension({ + namespace: 'core', + name: 'nav', + attachTo: { id: 'core/layout', input: 'nav' }, + inputs: { + items: createExtensionInput({ + target: createNavItemExtension.targetDataRef, + }), + }, + output: { + element: coreExtensionData.reactElement, + }, + factory({ inputs }) { + return { + element: ( + + ), + }; + }, +}); + +const AuthenticationProvider = (props: { + signInPage?: ComponentType; + children: ReactNode; +}) => { + const { signInPage: SignInPage, children } = props; + const configApi = useApi(configApiRef); + const signOutTargetUrl = getBasePath(configApi) || '/'; + + const internalAppContext = useContext(InternalAppContext); + if (!internalAppContext) { + throw new Error('AppRouter must be rendered within the AppProvider'); + } + + const { appIdentityProxy } = internalAppContext; + const [identityApi, setIdentityApi] = useState(); + + if (!SignInPage) { + appIdentityProxy.setTarget( + { + getUserId: () => 'guest', + getIdToken: async () => undefined, + getProfile: () => ({ + email: 'guest@example.com', + displayName: 'Guest', + }), + getProfileInfo: async () => ({ + email: 'guest@example.com', + displayName: 'Guest', + }), + getBackstageIdentity: async () => ({ + type: 'user', + userEntityRef: 'user:default/guest', + ownershipEntityRefs: ['user:default/guest'], + }), + getCredentials: async () => ({}), + signOut: async () => {}, + }, + { signOutTargetUrl }, + ); + + return children; + } + + if (!identityApi) { + return ; + } + + appIdentityProxy.setTarget(identityApi, { + signOutTargetUrl, + }); + + return children; +}; + +const TestCoreRouterExtension = createExtension({ + namespace: 'core', + name: 'router', + attachTo: { id: 'core', input: 'root' }, + inputs: { + signInPage: createExtensionInput( + { + component: createSignInPageExtension.componentDataRef, + }, + { singleton: true, optional: true }, + ), + children: createExtensionInput( + { + element: coreExtensionData.reactElement, + }, + { singleton: true }, + ), + }, + output: { + element: coreExtensionData.reactElement, + }, + factory({ inputs }) { + const SignInPage = inputs.signInPage?.output.component; + const children = inputs.children.output.element; + + return { + element: ( + + + {children} + + + ), + }; + }, +}); + +/** @public */ +export class ExtensionTester { + /** @internal */ + static forSubject( + subject: ExtensionDefinition, + options?: { config?: TConfig }, + ): ExtensionTester { + const tester = new ExtensionTester(); + const { output, factory, ...rest } = + subject as InternalExtensionDefinition; + // attaching to core/routes to render as index route + const extension = createExtension({ + ...rest, + attachTo: { id: 'core/routes', input: 'routes' }, + output: { + ...output, + path: coreExtensionData.routePath, + }, + factory: params => ({ + ...factory(params), + path: '/', + }), + }); + tester.add(extension, options); + return tester; + } + + readonly #extensions = new Array<{ + id: string; + definition: ExtensionDefinition; + config?: JsonValue; + }>(); + + add( + extension: ExtensionDefinition, + options?: { config?: TConfig }, + ): ExtensionTester { + const { name, namespace } = extension; + + const definition = { + ...extension, + // setting name "test" as fallback + name: !namespace && !name ? 'test' : name, + }; + + const { id } = resolveExtensionDefinition(definition); + + this.#extensions.push({ + id, + definition, + config: options?.config as JsonValue, + }); + + return this; + } + + render(options?: { config?: JsonObject }): RenderResult { + const { config = {} } = options ?? {}; + + const [subject, ...rest] = this.#extensions; + if (!subject) { + throw new Error( + 'No subject found. At least one extension should be added to the tester.', + ); + } + + const extensionsConfig: JsonArray = [ + ...rest.map(extension => ({ + [extension.id]: { + config: extension.config, + }, + })), + { + [subject.id]: { + config: subject.config, + disabled: false, + }, + }, + ]; + + const finalConfig = { + ...config, + app: { + ...(typeof config.app === 'object' ? config.app : undefined), + extensions: extensionsConfig, + }, + }; + + const app = createSpecializedApp({ + features: [ + createExtensionOverrides({ + extensions: [ + ...this.#extensions.map(extension => extension.definition), + TestCoreNavExtension, + TestCoreRouterExtension, + ], + }), + ], + config: new MockConfigApi(finalConfig), + }); + + return render(app.createRoot()); + } +} + +/** @public */ +export function createExtensionTester( + subject: ExtensionDefinition, + options?: { config?: TConfig }, +): ExtensionTester { + return ExtensionTester.forSubject(subject, options); +} diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx index d51f66010c..a6783d43a3 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx @@ -56,9 +56,7 @@ describe('renderInTestApp', () => { fireEvent.click(screen.getByRole('link', { name: 'See details' })); - const events = analyticsApiMock.getEvents(); - - expect(events[1]).toMatchObject({ + expect(analyticsApiMock.getEvents()[0]).toMatchObject({ action: 'click', subject: 'See details', }); diff --git a/yarn.lock b/yarn.lock index 8689a6ad0d..910432180d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4237,6 +4237,7 @@ __metadata: peerDependencies: "@testing-library/react": ^12.1.3 || ^13.0.0 || ^14.0.0 react: ^16.13.1 || ^17.0.0 || ^18.0.0 + react-router-dom: 6.0.0-beta.0 || ^6.3.0 languageName: unknown linkType: soft From 941f13e6ff66892f1773a70719140d5cc132b8ca Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 18 Dec 2023 12:19:18 +0100 Subject: [PATCH 08/12] docs(frontend-test-utils): improve changeset content Co-authored-by: Patrik Oldsberg Signed-off-by: Camila Belo --- .changeset/cool-fans-wash.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/cool-fans-wash.md b/.changeset/cool-fans-wash.md index 8154298047..07d38e7ea2 100644 --- a/.changeset/cool-fans-wash.md +++ b/.changeset/cool-fans-wash.md @@ -2,4 +2,4 @@ '@backstage/frontend-test-utils': patch --- -Accepts rendering more than a route in the test app. +The `createExtensionTester` helper is now able to render more than one route in the test app. From 88df7561be8ea891680b40e48e7a148b430a111b Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 18 Dec 2023 12:32:15 +0100 Subject: [PATCH 09/12] refactor(frontend-test-utils): remove components dependency Signed-off-by: Camila Belo --- packages/frontend-test-utils/package.json | 1 - .../src/app/createExtensionTester.test.tsx | 4 ++-- .../frontend-test-utils/src/app/createExtensionTester.tsx | 8 +++----- yarn.lock | 1 - 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/frontend-test-utils/package.json b/packages/frontend-test-utils/package.json index 33b1f41a8c..7f157cdc4f 100644 --- a/packages/frontend-test-utils/package.json +++ b/packages/frontend-test-utils/package.json @@ -31,7 +31,6 @@ "dist" ], "dependencies": { - "@backstage/core-components": "workspace:^", "@backstage/frontend-app-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/test-utils": "workspace:^", diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx index de52ca52de..04da2b7e89 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx @@ -15,6 +15,7 @@ */ import React, { useCallback } from 'react'; +import { Link } from 'react-router-dom'; import { fireEvent, screen, waitFor } from '@testing-library/react'; import { analyticsApiRef, @@ -27,7 +28,6 @@ import { useAnalytics, useApi, } from '@backstage/frontend-plugin-api'; -import { Link } from '@backstage/core-components'; import { MockAnalyticsApi } from '../apis'; import { createExtensionTester } from './createExtensionTester'; @@ -175,7 +175,7 @@ describe('createExtensionTester', () => { ).resolves.toBeInTheDocument(); }); - it('should capture click events in anylitics', async () => { + it('should capture click events in analytics', async () => { // Mocking the analytics api implementation const analyticsApiMock = new MockAnalyticsApi(); diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.tsx index 0404cf1ade..fdac4746d5 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.tsx @@ -15,8 +15,7 @@ */ import React, { ComponentType, ReactNode, useContext, useState } from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { Link } from '@backstage/core-components'; +import { MemoryRouter, Link } from 'react-router-dom'; import { RenderResult, render } from '@testing-library/react'; import { createSpecializedApp } from '@backstage/frontend-app-api'; import { @@ -40,7 +39,7 @@ import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wir // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { createSignInPageExtension } from '../../../frontend-plugin-api/src/extensions/createSignInPageExtension'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { InternalExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/createExtension'; +import { toInternalExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/createExtension'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { InternalAppContext } from '../../../frontend-app-api/src/wiring/InternalAppContext'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports @@ -195,8 +194,7 @@ export class ExtensionTester { options?: { config?: TConfig }, ): ExtensionTester { const tester = new ExtensionTester(); - const { output, factory, ...rest } = - subject as InternalExtensionDefinition; + const { output, factory, ...rest } = toInternalExtensionDefinition(subject); // attaching to core/routes to render as index route const extension = createExtension({ ...rest, diff --git a/yarn.lock b/yarn.lock index 910432180d..aef0f13137 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4227,7 +4227,6 @@ __metadata: resolution: "@backstage/frontend-test-utils@workspace:packages/frontend-test-utils" dependencies: "@backstage/cli": "workspace:^" - "@backstage/core-components": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/test-utils": "workspace:^" From 9d82ee99f23dc3bfeb15fdd49453ac6a9a67a543 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 18 Dec 2023 12:43:51 +0100 Subject: [PATCH 10/12] refactor: apply testing docs suggestions Signed-off-by: Camila Belo --- .../building-plugins/testing.md | 90 +++++++++---------- 1 file changed, 44 insertions(+), 46 deletions(-) diff --git a/docs/frontend-system/building-plugins/testing.md b/docs/frontend-system/building-plugins/testing.md index c3315d2821..7fe719b1a3 100644 --- a/docs/frontend-system/building-plugins/testing.md +++ b/docs/frontend-system/building-plugins/testing.md @@ -14,7 +14,7 @@ description: Testing plugins in the frontend system Utilities for testing frontend features and components are available in `@backstage/frontend-test-utils`. -## Testing components +## Testing React components A component can be used for more than one extension, and it should be tested independently of an extension environment. @@ -28,7 +28,7 @@ import { EntityDetails } from './plugin'; describe('Entity details component', () => { it('should render the entity name and owner', async () => { - renderInTestApp(); + await renderInTestApp(); await expect( screen.getByText('The entity "test" is owned by "tools"'), @@ -37,7 +37,7 @@ describe('Entity details component', () => { }); ``` -It's important to highlight that mocking APIs for components is different from mocking them for extensions. In the snippet below, we wrapped the component within a `TestApiProvider` for mocking the Catalog API: +To mock [Utility APIs](../utility-apis/01-index.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'; @@ -52,14 +52,15 @@ import { EntityDetails } from './plugin'; describe('Entity details component', () => { it('should render the entity name and owner', async () => { - const catalogApiMock: Partial = { - getEntityFacets: () => - Promise.resolve({ + const catalogApiMock = { + async getEntityFacets() { + return { facets: { 'relations.ownedBy': [{ count: 1, value: 'group:default/tools' }], }, - }), - }; + }, + } + } satisfies Partial; const entityRef = stringifyEntityRef({ kind: 'Component', @@ -67,7 +68,7 @@ describe('Entity details component', () => { name: 'test', }); - renderInTestApp( + await renderInTestApp( , @@ -80,9 +81,9 @@ describe('Entity details component', () => { }); ``` -## Testing features +## Testing extensions -To facilitate testing of frontend features, 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. +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: @@ -97,9 +98,7 @@ import { indexPageExtension } from './plugin'; describe('Index page', () => { it('should render a the index page', () => { - const tester = createExtensionTester(indexPageExtension); - - tester.render(); + createExtensionTester(indexPageExtension).render(); expect(screen.getByText('Index Page')).toBeInTheDocument(); }); @@ -118,17 +117,18 @@ import { indexPageExtension, detailsPageExtension } from './plugin'; describe('Index page', async () => { it('should link to the details page', () => { - const tester = createExtensionTester(indexPageExtension); + createExtensionTester(indexPageExtension) + // Adding more extensions to the preset being tested + .add(detailsPageExtension) + .render(); - // Adding more extensions to the preset being tested - tester.add(detailsPageExtension); - tester.render(); + await expect(screen.findByText('Index Page')).toBeInTheDocument(); - expect(screen.getByText('Index Page')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('link', { name: 'See details' })); - userEvent.click(screen.getByRole('link', { name: 'See details' })); - - await expect(screen.getByText('Details Page')).resolves.toBeInTheDocument(); + await expect( + screen.findByText('Details Page'), + ).resolves.toBeInTheDocument(); }); }); ``` @@ -154,7 +154,7 @@ import { import { indexPageExtension } from './plugin'; describe('Index page', () => { - it('should capture click events in anylitics', async () => { + it('should capture click events in analytics', async () => { // Mocking the analytics api implementation const analyticsApiMock = new MockAnalyticsApi(); @@ -165,14 +165,14 @@ describe('Index page', () => { }), }); - const tester = createExtensionTester(indexPageExtension); + createExtensionTester(indexPageExtension) + // Overriding the analytics api extension + .add(analyticsApiOverride) + .render(); - // Overriding the analytics api extension - tester.add(analyticsApiOverride); - - tester.render(); - - userEvent.click(await screen.findByRole('link', { name: 'See details' })); + await userEvent.click( + await screen.findByRole('link', { name: 'See details' }), + ); expect(analyticsApiMock.getEvents()[0]).toMatchObject({ action: 'click', @@ -194,24 +194,22 @@ import { indexPageExtension, detailsPageExtension } from './plugin'; describe('Index page', () => { it('should accepts a custom title via config', async () => { - const tester = createExtensionTester(indexPageExtension, { + createExtensionTester(indexPageExtension, { // Configuration specific of index page config: { title: 'Custom index' }, - }); - - tester.add(detailsExtensionPage, { - // Configuration specific of details page - config: { title: 'Custom details' }, - }); - - tester.render({ - // Configuration specific of the instance - config: { - app: { - title: 'Custom app', + }) + .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' }), @@ -221,7 +219,7 @@ describe('Index page', () => { screen.findByRole('heading', { name: 'Custom index' }), ).resolves.toBeInTheDocument(); - userEvent.click(screen.getByRole('link', { name: 'See details' })); + await userEvent.click(screen.getByRole('link', { name: 'See details' })); await expect( screen.findByText('Custom details'), From 9bda7ea3d7c8152bf948c2b62e50a6085e4b77d4 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 18 Dec 2023 13:49:35 +0100 Subject: [PATCH 11/12] docs: fix link to utility apis Signed-off-by: Camila Belo --- docs/frontend-system/building-plugins/testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/frontend-system/building-plugins/testing.md b/docs/frontend-system/building-plugins/testing.md index 7fe719b1a3..8177724ae1 100644 --- a/docs/frontend-system/building-plugins/testing.md +++ b/docs/frontend-system/building-plugins/testing.md @@ -37,7 +37,7 @@ describe('Entity details component', () => { }); ``` -To mock [Utility APIs](../utility-apis/01-index.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: +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'; From 63aab7c050a992e64d9fd52e1fc5caa5023c51fe Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 18 Dec 2023 14:26:42 +0100 Subject: [PATCH 12/12] Update docs/frontend-system/building-plugins/testing.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Signed-off-by: Patrik Oldsberg --- docs/frontend-system/building-plugins/testing.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/frontend-system/building-plugins/testing.md b/docs/frontend-system/building-plugins/testing.md index 8177724ae1..dbe883c55b 100644 --- a/docs/frontend-system/building-plugins/testing.md +++ b/docs/frontend-system/building-plugins/testing.md @@ -31,7 +31,7 @@ describe('Entity details component', () => { await renderInTestApp(); await expect( - screen.getByText('The entity "test" is owned by "tools"'), + screen.findByText('The entity "test" is owned by "tools"'), ).resolves.toBeInTheDocument(); }); }); @@ -75,7 +75,7 @@ describe('Entity details component', () => { ); await expect( - screen.getByText('The entity "test" is owned by "tools"'), + screen.findByText('The entity "test" is owned by "tools"'), ).resolves.toBeInTheDocument(); }); });