Merge pull request #32734 from backstage/rugvip/test-utils

frontend-test-utils: more mockApis and utilities
This commit is contained in:
Patrik Oldsberg
2026-02-10 13:21:16 +01:00
committed by GitHub
77 changed files with 3920 additions and 419 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-test-utils': minor
---
**BREAKING**: Removed the `TestApiRegistry` class, use `TestApiProvider` directly instead, storing reused APIs in a variable, e.g. `const apis = [...] as const`.
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
The `catalogApiMock` test utility now returns a `MockWithApiFactory`, allowing it to be passed directly to test utilities like `renderTestApp` and `TestApiProvider` without needing the `[catalogApiRef, catalogApiMock()]` tuple.
@@ -0,0 +1,5 @@
---
'@backstage/frontend-test-utils': minor
---
Added `MockAlertApi` and `MockFeatureFlagsApi` implementations to the `mockApis` namespace. The mock implementations include useful testing methods like `clearAlerts()`, `waitForAlert()`, `getState()`, `setState()`, and `clearState()` for better test ergonomics.
@@ -0,0 +1,21 @@
---
'@backstage/frontend-test-utils': minor
---
**BREAKING**: The `mockApis` namespace is no longer a re-export from `@backstage/test-utils`. It's now a standalone namespace with mock implementations of most core APIs. Mock API instances can be passed directly to `TestApiProvider`, `renderInTestApp`, and `renderTestApp` without needing `[apiRef, impl]` tuples. As part of this change, the `.factory()` method on some mocks has been removed, since it's now redundant.
```tsx
// Before
import { mockApis } from '@backstage/frontend-test-utils';
renderInTestApp(<MyComponent />, {
apis: [[identityApiRef, mockApis.identity()]],
});
// After - mock APIs can be passed directly
renderInTestApp(<MyComponent />, {
apis: [mockApis.identity()],
});
```
This change also adds `createApiMock`, a public utility for creating mock API factories, intended for plugin authors to create their own `.mock()` variants.
@@ -0,0 +1,5 @@
---
'@backstage/repo-tools': patch
---
The `type-deps` command now detects ambient global types from the `jest` namespace in declaration files, rather than only looking for explicit imports and reference directives.
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-react': patch
---
Added `@backstage/frontend-test-utils` as a dev dependency for mock API usage in tests.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Added `@backstage/frontend-test-utils` as a dev dependency for mock API usage in tests.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/test-utils': patch
'@backstage/backend-test-utils': patch
---
Added `@types/jest` as an optional peer dependency, since jest types are exposed in the public API surface.
@@ -53,7 +53,6 @@ Chai
Chainguard
changeset
changesets
Changesets
chanwit
Chanwit
CI/CD
@@ -68,12 +67,10 @@ Cobertura
codeblocks
Codecov
codehilite
Codehilite
codemod
codemods
codeowners
codescene
CodeScene
composability
composable
config
@@ -125,7 +122,6 @@ devs
dialogs
disambiguator
discoverability
Discoverability
dls
Dockerfile
dockerfiles
@@ -136,7 +132,6 @@ Dominik
DOMPurify
don'ts
dynatrace
Dynatrace
dyno
ecco
elasticsearch
@@ -157,12 +152,10 @@ faqs
featureful
Figma
firehydrant
FireHydrant
Firekube
Firestore
Fiverr
flightcontrol
Flightcontrol
Francesco
Frontside
Gaurav
@@ -188,7 +181,6 @@ haproxy
hardcoded
hardcoding
harness
Harness
Helidon
Henneke
Heroku
@@ -281,7 +273,6 @@ microsite
microtasks
middleware
minikube
Minikube
Minio
misattributed
misconfiguration
@@ -290,7 +281,6 @@ mkdocs
Mkdocs
modularization
monorepo
Monorepo
monorepos
monospace
morgan
@@ -327,7 +317,6 @@ Olausson
Oldsberg
onboarded
onboarding
Onboarding
onelogin
openapi
OpenSearch
@@ -335,7 +324,6 @@ OpenShift
openssl
orgs
overridable
Overridable
padding
paddings
pagerduty
@@ -345,13 +333,13 @@ parallelization
param
params
parseable
passthrough
passwordless
Patrik
pattison
Peloton
PEP
performant
Performant
periskop
Periskop
permissioned
@@ -31,7 +31,7 @@ describe('Entity details component', () => {
});
```
To mock [Utility APIs](../architecture/33-utility-apis.md) that are used by your component, pass API overrides to `renderInTestApp` using the `apis` option. Mock helpers are available from `@backstage/frontend-test-utils` and plugin-specific test utilities:
To mock [Utility APIs](../architecture/33-utility-apis.md) that are used by your component, pass API overrides to `renderInTestApp` using the `apis` option. Mock helpers are available from `@backstage/frontend-test-utils` and plugin-specific test utilities. For a deeper look at the available mock APIs and how to create your own, see [Testing with Utility APIs](../utility-apis/05-testing.md).
```tsx
import { screen } from '@testing-library/react';
@@ -35,6 +35,14 @@ Most utility APIs are usable directly without any configuration. But they are pr
These cases are all described in [the main article](./04-configuring.md).
## Testing with utility APIs
> For details, [see the main article](./05-testing.md).
When testing frontend components and extensions, you often need to provide mock implementations of the utility APIs they depend on. The `@backstage/frontend-test-utils` package provides the `mockApis` namespace with ready-made mocks for all core utility APIs, which can be passed to test utilities like `renderInTestApp` and `TestApiProvider`.
These are described in detail in [the main article](./05-testing.md).
## Migrating from the old frontend system
If you want to learn how to migrate your own utility APIs from the old frontend system to the new one, that's described in the [Migrating APIs guide](../building-plugins/05-migrating.md#migrating-apis).
@@ -0,0 +1,194 @@
---
id: testing
title: Testing with Utility APIs
sidebar_label: Testing
description: Mocking and testing Utility APIs
---
When testing frontend components and extensions, you often need to provide mock implementations of the utility APIs they depend on. The `@backstage/frontend-test-utils` package provides the `mockApis` namespace with ready-made mocks for all core utility APIs.
## The `mockApis` namespace
The `mockApis` namespace is the main entry point for creating mock utility API instances in tests. It provides two usage patterns for each API:
### Fake instances
Call the API function directly to create a fake instance with simplified but functional behavior. These are useful when your test needs the API to actually work, not just be stubbed.
```ts
import { mockApis } from '@backstage/frontend-test-utils';
const configApi = mockApis.config({ data: { app: { title: 'Test' } } });
configApi.getString('app.title'); // 'Test'
const alertApi = mockApis.alert();
alertApi.post({ message: 'hello' });
expect(alertApi.getAlerts()).toHaveLength(1);
```
### Jest mocks
Call `.mock()` to get an instance where every method is a `jest.fn()`. You can optionally provide partial implementations. This is useful when you want to assert that specific methods were called.
```ts
import { mockApis } from '@backstage/frontend-test-utils';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
const permissionApi = mockApis.permission.mock({
authorize: async () => ({ result: AuthorizeResult.ALLOW }),
});
// ... exercise the component ...
expect(permissionApi.authorize).toHaveBeenCalledTimes(1);
```
## Providing mock APIs in tests
### With `renderInTestApp`
```tsx
import { screen } from '@testing-library/react';
import { renderInTestApp, mockApis } from '@backstage/frontend-test-utils';
await renderInTestApp(<MyComponent />, {
apis: [
mockApis.identity({ userEntityRef: 'user:default/guest' }),
mockApis.config({ data: { app: { title: 'Test App' } } }),
],
});
```
You can also use the `[apiRef, implementation]` tuple syntax to provide any API implementation, including ones that aren't from `mockApis`:
```tsx
import { myCustomApiRef } from '../apis';
const myCustomApiInstance = {
// ...
};
await renderInTestApp(<MyComponent />, {
apis: [
mockApis.identity({ userEntityRef: 'user:default/guest' }),
[myCustomApiRef, myCustomApiInstance],
],
});
```
### With `renderTestApp`
The same `apis` option is available on `renderTestApp`, which is commonly used when testing extensions or entity pages:
```tsx
import { renderTestApp, mockApis } from '@backstage/frontend-test-utils';
import { createTestEntityPage } from '@backstage/plugin-catalog-react/testUtils';
renderTestApp({
extensions: [createTestEntityPage({ entity }), myEntityCard],
apis: [mockApis.permission()],
});
```
### With `TestApiProvider`
For standalone rendering scenarios where you're not using `renderInTestApp`, the `TestApiProvider` component accepts the same `apis` format:
```tsx
import { render } from '@testing-library/react';
import { TestApiProvider, mockApis } from '@backstage/frontend-test-utils';
render(
<TestApiProvider
apis={[
mockApis.identity({ userEntityRef: 'user:default/guest' }),
mockApis.alert(),
]}
>
<MyComponent />
</TestApiProvider>,
);
```
## Plugin-specific test mocks
Plugins can provide their own mock APIs that follow the same pattern. For example, `@backstage/plugin-catalog-react` provides `catalogApiMock` in its `/testUtils` entry point:
```tsx
import { renderTestApp } from '@backstage/frontend-test-utils';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
renderTestApp({
extensions: [myExtension],
apis: [catalogApiMock({ entities: [entity] })],
});
```
### Creating your own mock APIs
If you maintain a plugin that exposes a utility API, you can provide mock utilities that follow the same function + `.mock()` pattern as the built-in `mockApis`.
Use `attachMockApiFactory` for fake instances with real behavior, and `createApiMock` for the jest-mocked `.mock()` variant where all methods are `jest.fn()`. The full pattern looks like this:
```ts
import {
attachMockApiFactory,
createApiMock,
type ApiMock,
} from '@backstage/frontend-test-utils';
import { myApiRef, type MyApi } from '@internal/plugin-example-react';
// Fake instance with real behavior
export function myApiMock(options?: { greeting?: string }) {
return attachMockApiFactory(myApiRef, {
greet: async () => options?.greeting ?? 'Hello!',
});
}
// Jest mock variant where all methods are jest.fn()
export namespace myApiMock {
export const mock = createApiMock(myApiRef, () => ({
greet: jest.fn(),
}));
}
```
Consumers can then use it just like the core mocks:
```tsx
// Fake with real behavior
await renderInTestApp(<MyComponent />, {
apis: [myApiMock({ greeting: 'Hi there!' })],
});
// Jest mock for assertions
const api = myApiMock.mock({
greet: async () => 'mocked',
});
await renderInTestApp(<MyComponent />, {
apis: [api],
});
expect(api.greet).toHaveBeenCalledTimes(1);
```
## Available mock APIs
The table below lists all core APIs available through the `mockApis` namespace.
| API | Fake instance | Notes |
| --------------------------------- | --------------------- | ---------------------------------------------------------------------- |
| `mockApis.alert()` | `MockAlertApi` | Collects alerts; has `getAlerts()`, `clearAlerts()`, `waitForAlert()` |
| `mockApis.analytics()` | `MockAnalyticsApi` | Collects events; has `getEvents()` |
| `mockApis.config({ data })` | `MockConfigApi` | Reads from a plain JSON object |
| `mockApis.discovery({ baseUrl })` | Inline | Returns `${baseUrl}/api/${pluginId}`, defaults to `http://example.com` |
| `mockApis.error(options?)` | `MockErrorApi` | Collects errors; has `getErrors()`, `waitForError()` |
| `mockApis.featureFlags(options?)` | `MockFeatureFlagsApi` | In-memory flag state; has `getState()`, `setState()`, `clearState()` |
| `mockApis.fetch(options?)` | `MockFetchApi` | Wraps native `fetch`; supports identity injection and plugin protocol |
| `mockApis.identity(options?)` | Inline | Configurable user ref, ownership, token, profile |
| `mockApis.permission(options?)` | `MockPermissionApi` | Defaults to `ALLOW`; accepts a handler function |
| `mockApis.storage({ data })` | `MockStorageApi` | In-memory storage with bucket support |
| `mockApis.translation()` | `MockTranslationApi` | Passthrough returning default messages from translation refs |
Each of these also has a `.mock()` variant that returns jest mocks, as described above.
+1
View File
@@ -599,6 +599,7 @@ export default {
'frontend-system/utility-apis/creating',
'frontend-system/utility-apis/consuming',
'frontend-system/utility-apis/configuring',
'frontend-system/utility-apis/testing',
],
),
],
+8
View File
@@ -88,5 +88,13 @@
"@types/lodash": "^4.14.151",
"@types/supertest": "^2.0.8",
"supertest": "^7.0.0"
},
"peerDependencies": {
"@types/jest": "*"
},
"peerDependenciesMeta": {
"@types/jest": {
"optional": true
}
}
}
+13
View File
@@ -32,31 +32,44 @@
},
"dependencies": {
"@backstage/config": "workspace:^",
"@backstage/core-app-api": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/frontend-app-api": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/plugin-app": "workspace:^",
"@backstage/plugin-app-react": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/plugin-permission-react": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"i18next": "^22.4.15",
"zen-observable": "^0.10.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
"@types/jest": "*",
"@types/react": "^18.0.0",
"@types/zen-observable": "^0.8.0",
"msw": "^2.0.0",
"react": "^18.0.2",
"react-dom": "^18.0.2",
"react-router-dom": "^6.30.2"
},
"peerDependencies": {
"@testing-library/react": "^16.0.0",
"@types/jest": "*",
"@types/react": "^17.0.0 || ^18.0.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0",
"react-router-dom": "^6.30.2"
},
"peerDependenciesMeta": {
"@types/jest": {
"optional": true
},
"@types/react": {
"optional": true
}
+331 -52
View File
@@ -3,37 +3,77 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AlertApi } from '@backstage/frontend-plugin-api';
import { AlertMessage } from '@backstage/frontend-plugin-api';
import { AnalyticsApi } from '@backstage/frontend-plugin-api';
import { AnalyticsEvent } from '@backstage/frontend-plugin-api';
import { ApiHolder } from '@backstage/frontend-plugin-api';
import { ApiMock } from '@backstage/test-utils';
import { ApiFactory } from '@backstage/frontend-plugin-api';
import { ApiRef } from '@backstage/frontend-plugin-api';
import { AppNode } from '@backstage/frontend-plugin-api';
import { AppNodeInstance } from '@backstage/frontend-plugin-api';
import { ErrorWithContext } from '@backstage/test-utils';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { Config } from '@backstage/config';
import { ConfigApi } from '@backstage/core-plugin-api';
import { ConfigApi as ConfigApi_2 } from '@backstage/frontend-plugin-api';
import { DiscoveryApi } from '@backstage/frontend-plugin-api';
import { DiscoveryApi as DiscoveryApi_2 } from '@backstage/core-plugin-api';
import { ErrorApi } from '@backstage/core-plugin-api';
import { ErrorApi as ErrorApi_2 } from '@backstage/frontend-plugin-api';
import { ErrorApiError } from '@backstage/core-plugin-api';
import { ErrorApiErrorContext } from '@backstage/core-plugin-api';
import { EvaluatePermissionRequest } from '@backstage/plugin-permission-common';
import { EvaluatePermissionResponse } from '@backstage/plugin-permission-common';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { ExtensionDefinitionParameters } from '@backstage/frontend-plugin-api';
import { FeatureFlag } from '@backstage/frontend-plugin-api';
import { FeatureFlagsApi } from '@backstage/frontend-plugin-api';
import { FeatureFlagsSaveOptions } from '@backstage/frontend-plugin-api';
import { FeatureFlagState } from '@backstage/frontend-plugin-api';
import { FetchApi } from '@backstage/core-plugin-api';
import { FetchApi as FetchApi_2 } from '@backstage/frontend-plugin-api';
import { FrontendFeature } from '@backstage/frontend-plugin-api';
import { IdentityApi } from '@backstage/frontend-plugin-api';
import { IdentityApi as IdentityApi_2 } from '@backstage/core-plugin-api';
import { JsonObject } from '@backstage/types';
import { JSX as JSX_2 } from 'react/jsx-runtime';
import { mockApis } from '@backstage/test-utils';
import { MockConfigApi } from '@backstage/test-utils';
import { MockErrorApi } from '@backstage/test-utils';
import { MockErrorApiOptions } from '@backstage/test-utils';
import { MockFetchApi } from '@backstage/test-utils';
import { MockFetchApiOptions } from '@backstage/test-utils';
import { MockPermissionApi } from '@backstage/test-utils';
import { MockStorageApi } from '@backstage/test-utils';
import { MockStorageBucket } from '@backstage/test-utils';
import { JsonValue } from '@backstage/types';
import { Observable } from '@backstage/types';
import { PermissionApi } from '@backstage/plugin-permission-react';
import { ReactNode } from 'react';
import { registerMswTestHooks } from '@backstage/test-utils';
import { RenderResult } from '@testing-library/react';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { testingLibraryDomTypesQueries } from '@testing-library/dom/types/queries';
import { StorageApi } from '@backstage/core-plugin-api';
import { StorageApi as StorageApi_2 } from '@backstage/frontend-plugin-api';
import { StorageValueSnapshot } from '@backstage/core-plugin-api';
import { TranslationApi } from '@backstage/core-plugin-api/alpha';
import { TranslationApi as TranslationApi_2 } from '@backstage/frontend-plugin-api';
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
import { TranslationSnapshot } from '@backstage/core-plugin-api/alpha';
import { withLogCollector } from '@backstage/test-utils';
export { ApiMock };
// @public
export type ApiMock<TApi> = {
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
} & {
[Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return
? TApi[Key] & jest.MockInstance<Return, Args>
: TApi[Key];
};
// @public
export function attachMockApiFactory<TApi, TImpl extends TApi = TApi>(
apiRef: ApiRef<TApi>,
implementation: TImpl,
): TImpl & {
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
};
// @public
export function createApiMock<TApi>(
apiRef: ApiRef<TApi>,
mockFactory: () => jest.Mocked<TApi>,
): (partialImpl?: Partial<TApi>) => ApiMock<TApi>;
// @public (undocumented)
export function createExtensionTester<
@@ -47,7 +87,11 @@ export function createExtensionTester<
},
): ExtensionTester<NonNullable<T['output']>>;
export { ErrorWithContext };
// @public
export type ErrorWithContext = {
error: ErrorApiError;
context?: ErrorApiErrorContext;
};
// @public (undocumented)
export class ExtensionQuery<UOutput extends ExtensionDataRef> {
@@ -100,6 +144,20 @@ export class ExtensionTester<UOutput extends ExtensionDataRef> {
snapshot(): ExtensionSnapshotNode;
}
// @public
export class MockAlertApi implements AlertApi {
// (undocumented)
alert$(): Observable<AlertMessage>;
clearAlerts(): void;
getAlerts(): AlertMessage[];
// (undocumented)
post(alert: AlertMessage): void;
waitForAlert(
predicate: (alert: AlertMessage) => boolean,
timeoutMs?: number,
): Promise<AlertMessage>;
}
// @public
export class MockAnalyticsApi implements AnalyticsApi {
// (undocumented)
@@ -108,36 +166,268 @@ export class MockAnalyticsApi implements AnalyticsApi {
getEvents(): AnalyticsEvent[];
}
export { mockApis };
// @public
export type MockApiFactorySymbol = typeof mockApiFactorySymbol;
export { MockConfigApi };
// @public
export namespace mockApis {
export function alert(): MockWithApiFactory<MockAlertApi>;
export namespace alert {
const mock: (
partialImpl?: Partial<AlertApi> | undefined,
) => ApiMock<AlertApi>;
}
export function analytics(): MockAnalyticsApi &
MockWithApiFactory<AnalyticsApi>;
export namespace analytics {
const // (undocumented)
mock: (
partialImpl?: Partial<AnalyticsApi> | undefined,
) => ApiMock<AnalyticsApi>;
}
export function config(options?: {
data?: JsonObject;
}): MockConfigApi & MockWithApiFactory<ConfigApi_2>;
export namespace config {
const // (undocumented)
mock: (partialImpl?: Partial<Config> | undefined) => ApiMock<Config>;
}
export function discovery(options?: {
baseUrl?: string;
}): DiscoveryApi & MockWithApiFactory<DiscoveryApi>;
export namespace discovery {
const // (undocumented)
mock: (
partialImpl?: Partial<DiscoveryApi> | undefined,
) => ApiMock<DiscoveryApi>;
}
export function error(
options?: MockErrorApiOptions,
): MockErrorApi & MockWithApiFactory<ErrorApi_2>;
export namespace error {
const // (undocumented)
mock: (
partialImpl?: Partial<ErrorApi_2> | undefined,
) => ApiMock<ErrorApi_2>;
}
export function featureFlags(
options?: MockFeatureFlagsApiOptions,
): MockWithApiFactory<MockFeatureFlagsApi>;
export namespace featureFlags {
const mock: (
partialImpl?: Partial<FeatureFlagsApi> | undefined,
) => ApiMock<FeatureFlagsApi>;
}
export function fetch(
options?: MockFetchApiOptions,
): MockFetchApi & MockWithApiFactory<FetchApi_2>;
export namespace fetch {
const // (undocumented)
mock: (
partialImpl?: Partial<FetchApi_2> | undefined,
) => ApiMock<FetchApi_2>;
}
export function identity(options?: {
userEntityRef?: string;
ownershipEntityRefs?: string[];
token?: string;
email?: string;
displayName?: string;
picture?: string;
}): MockWithApiFactory<IdentityApi>;
export namespace identity {
const // (undocumented)
mock: (
partialImpl?: Partial<IdentityApi> | undefined,
) => ApiMock<IdentityApi>;
}
export function permission(options?: {
authorize?:
| AuthorizeResult.ALLOW
| AuthorizeResult.DENY
| ((
request: EvaluatePermissionRequest,
) => AuthorizeResult.ALLOW | AuthorizeResult.DENY);
}): MockPermissionApi & MockWithApiFactory<PermissionApi>;
export namespace permission {
const // (undocumented)
mock: (
partialImpl?: Partial<PermissionApi> | undefined,
) => ApiMock<PermissionApi>;
}
export function storage(options?: {
data?: JsonObject;
}): MockStorageApi & MockWithApiFactory<StorageApi_2>;
export namespace storage {
const // (undocumented)
mock: (
partialImpl?: Partial<StorageApi_2> | undefined,
) => ApiMock<StorageApi_2>;
}
export function translation(): MockTranslationApi &
MockWithApiFactory<TranslationApi_2>;
export namespace translation {
const mock: (
partialImpl?: Partial<TranslationApi_2> | undefined,
) => ApiMock<TranslationApi_2>;
}
}
export { MockErrorApi };
// @public
export class MockConfigApi implements ConfigApi {
constructor({ data }: { data: JsonObject });
get<T = JsonValue>(key?: string): T;
getBoolean(key: string): boolean;
getConfig(key: string): Config;
getConfigArray(key: string): Config[];
getNumber(key: string): number;
getOptional<T = JsonValue>(key?: string): T | undefined;
getOptionalBoolean(key: string): boolean | undefined;
getOptionalConfig(key: string): Config | undefined;
getOptionalConfigArray(key: string): Config[] | undefined;
getOptionalNumber(key: string): number | undefined;
getOptionalString(key: string): string | undefined;
getOptionalStringArray(key: string): string[] | undefined;
getString(key: string): string;
getStringArray(key: string): string[];
has(key: string): boolean;
keys(): string[];
}
export { MockErrorApiOptions };
// @public
export class MockErrorApi implements ErrorApi {
constructor(options?: MockErrorApiOptions);
// (undocumented)
error$(): Observable<{
error: ErrorApiError;
context?: ErrorApiErrorContext;
}>;
// (undocumented)
getErrors(): ErrorWithContext[];
// (undocumented)
post(error: ErrorApiError, context?: ErrorApiErrorContext): void;
// (undocumented)
waitForError(pattern: RegExp, timeoutMs?: number): Promise<ErrorWithContext>;
}
export { MockFetchApi };
// @public
export type MockErrorApiOptions = {
collect?: boolean;
};
export { MockFetchApiOptions };
// @public
export class MockFeatureFlagsApi implements FeatureFlagsApi {
constructor(options?: MockFeatureFlagsApiOptions);
clearState(): void;
// (undocumented)
getRegisteredFlags(): FeatureFlag[];
getState(): Record<string, FeatureFlagState>;
// (undocumented)
isActive(name: string): boolean;
// (undocumented)
registerFlag(flag: FeatureFlag): void;
// (undocumented)
save(options: FeatureFlagsSaveOptions): void;
setState(states: Record<string, FeatureFlagState>): void;
}
export { MockPermissionApi };
// @public
export interface MockFeatureFlagsApiOptions {
initialStates?: Record<string, FeatureFlagState>;
}
export { MockStorageApi };
// @public
export class MockFetchApi implements FetchApi {
constructor(options?: MockFetchApiOptions);
get fetch(): typeof fetch;
}
export { MockStorageBucket };
// @public
export interface MockFetchApiOptions {
baseImplementation?: undefined | 'none' | typeof fetch;
injectIdentityAuth?:
| undefined
| {
token: string;
}
| {
identityApi: Pick<IdentityApi_2, 'getCredentials'>;
};
resolvePluginProtocol?:
| undefined
| {
discoveryApi: Pick<DiscoveryApi_2, 'getBaseUrl'>;
};
}
// @public
export class MockPermissionApi implements PermissionApi {
constructor(
requestHandler?: (
request: EvaluatePermissionRequest,
) => AuthorizeResult.ALLOW | AuthorizeResult.DENY,
);
// (undocumented)
authorize(
request: EvaluatePermissionRequest,
): Promise<EvaluatePermissionResponse>;
}
// @public
export class MockStorageApi implements StorageApi {
// (undocumented)
static create(data?: JsonObject): MockStorageApi;
// (undocumented)
forBucket(name: string): StorageApi;
// (undocumented)
observe$<T extends JsonValue>(
key: string,
): Observable<StorageValueSnapshot<T>>;
// (undocumented)
remove(key: string): Promise<void>;
// (undocumented)
set<T>(key: string, data: T): Promise<void>;
// (undocumented)
snapshot<T extends JsonValue>(key: string): StorageValueSnapshot<T>;
}
// @public
export class MockTranslationApi implements TranslationApi {
// (undocumented)
static create(): MockTranslationApi;
// (undocumented)
getTranslation<
TMessages extends {
[key in string]: string;
},
>(
translationRef: TranslationRef<string, TMessages>,
): TranslationSnapshot<TMessages>;
// (undocumented)
translation$<
TMessages extends {
[key in string]: string;
},
>(): Observable<TranslationSnapshot<TMessages>>;
}
// @public
export type MockWithApiFactory<TApi> = TApi & {
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
};
export { registerMswTestHooks };
// @public
export function renderInTestApp<TApiPairs extends any[] = any[]>(
export function renderInTestApp<const TApiPairs extends any[] = any[]>(
element: JSX.Element,
options?: TestAppOptions<TApiPairs>,
): RenderResult;
// @public
export function renderTestApp<TApiPairs extends any[] = any[]>(
options: RenderTestAppOptions<TApiPairs>,
): RenderResult<testingLibraryDomTypesQueries, HTMLElement, HTMLElement>;
export function renderTestApp<const TApiPairs extends any[] = any[]>(
options?: RenderTestAppOptions<TApiPairs>,
): RenderResult;
// @public
export type RenderTestAppOptions<TApiPairs extends any[] = any[]> = {
@@ -152,37 +442,26 @@ export type RenderTestAppOptions<TApiPairs extends any[] = any[]> = {
};
// @public
export type TestApiPairs<TApiPairs> = TestApiProviderPropsApiPairs<TApiPairs>;
export type TestApiPair<TApi> =
| readonly [ApiRef<TApi>, TApi extends infer TImpl ? Partial<TImpl> : never]
| MockWithApiFactory<TApi>;
// @public
export const TestApiProvider: <T extends any[]>(
props: TestApiProviderProps<T>,
) => JSX_2.Element;
export type TestApiPairs<TApiPairs> = {
[TIndex in keyof TApiPairs]: TestApiPair<TApiPairs[TIndex]>;
};
// @public
export function TestApiProvider<const TApiPairs extends any[]>(
props: TestApiProviderProps<TApiPairs>,
): JSX.Element;
// @public
export type TestApiProviderProps<TApiPairs extends any[]> = {
apis: readonly [...TestApiProviderPropsApiPairs<TApiPairs>];
apis: readonly [...TestApiPairs<TApiPairs>];
children: ReactNode;
};
// @public
export type TestApiProviderPropsApiPair<TApi> = TApi extends infer TImpl
? readonly [ApiRef<TApi>, Partial<TImpl>]
: never;
// @public
export type TestApiProviderPropsApiPairs<TApiPairs> = {
[TIndex in keyof TApiPairs]: TestApiProviderPropsApiPair<TApiPairs[TIndex]>;
};
// @public
export class TestApiRegistry implements ApiHolder {
static from<TApiPairs extends any[]>(
...apis: readonly [...TestApiProviderPropsApiPairs<TApiPairs>]
): TestApiRegistry;
get<T>(api: ApiRef<T>): T | undefined;
}
// @public
export type TestAppOptions<TApiPairs extends any[] = any[]> = {
mountedRoutes?: {
@@ -0,0 +1,107 @@
/*
* Copyright 2025 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 { MockAlertApi } from './MockAlertApi';
describe('MockAlertApi', () => {
it('should collect alerts', () => {
const api = new MockAlertApi();
api.post({ message: 'Test alert 1' });
api.post({ message: 'Test alert 2', severity: 'error' });
api.post({
message: 'Test alert 3',
severity: 'warning',
display: 'permanent',
});
expect(api.getAlerts()).toHaveLength(3);
expect(api.getAlerts()[0]).toMatchObject({ message: 'Test alert 1' });
expect(api.getAlerts()[1]).toMatchObject({
message: 'Test alert 2',
severity: 'error',
});
});
it('should clear alerts', () => {
const api = new MockAlertApi();
api.post({ message: 'Test alert' });
expect(api.getAlerts()).toHaveLength(1);
api.clearAlerts();
expect(api.getAlerts()).toHaveLength(0);
});
it('should notify observers', async () => {
const api = new MockAlertApi();
const messages: string[] = [];
const collected = new Promise<void>(resolve => {
api.alert$().subscribe({
next: alert => {
messages.push(alert.message);
if (messages.length === 2) {
resolve();
}
},
});
});
api.post({ message: 'First' });
api.post({ message: 'Second' });
await collected;
expect(messages).toEqual(['First', 'Second']);
});
it('should wait for matching alert', async () => {
const api = new MockAlertApi();
setTimeout(() => {
api.post({ message: 'Wrong alert' });
api.post({ message: 'Right alert', severity: 'error' });
}, 10);
const alert = await api.waitForAlert(
a => a.message === 'Right alert',
1000,
);
expect(alert).toMatchObject({ message: 'Right alert', severity: 'error' });
});
it('should timeout if alert never appears', async () => {
const api = new MockAlertApi();
await expect(
api.waitForAlert(a => a.message === 'Never posted', 100),
).rejects.toThrow('Timed out waiting for alert');
});
it('should resolve immediately if alert already exists', async () => {
const api = new MockAlertApi();
api.post({ message: 'Already posted' });
const alert = await api.waitForAlert(
a => a.message === 'Already posted',
1000,
);
expect(alert).toMatchObject({ message: 'Already posted' });
});
});
@@ -0,0 +1,102 @@
/*
* Copyright 2025 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 { AlertMessage } from '@backstage/frontend-plugin-api';
import { AlertApi } from '@backstage/frontend-plugin-api';
import { Observable } from '@backstage/types';
import ObservableImpl from 'zen-observable';
/**
* Mock implementation of {@link @backstage/frontend-plugin-api#AlertApi} for testing alert behavior.
*
* @public
* @example
* ```ts
* const alertApi = new MockAlertApi();
* alertApi.post({ message: 'Test alert' });
* expect(alertApi.getAlerts()).toHaveLength(1);
* ```
*/
export class MockAlertApi implements AlertApi {
private readonly alerts: AlertMessage[] = [];
private readonly observers = new Set<(alert: AlertMessage) => void>();
post(alert: AlertMessage) {
this.alerts.push(alert);
this.observers.forEach(observer => observer(alert));
}
alert$(): Observable<AlertMessage> {
return new ObservableImpl(subscriber => {
const observer = (alert: AlertMessage) => {
subscriber.next(alert);
};
this.observers.add(observer);
return () => {
this.observers.delete(observer);
};
});
}
/**
* Get all alerts that have been posted.
*/
getAlerts(): AlertMessage[] {
return this.alerts;
}
/**
* Clear all collected alerts.
*/
clearAlerts(): void {
this.alerts.length = 0;
}
/**
* Wait for an alert matching the given predicate.
*
* @param predicate - Function to test each alert
* @param timeoutMs - Maximum time to wait in milliseconds
* @returns Promise that resolves with the matching alert
*/
async waitForAlert(
predicate: (alert: AlertMessage) => boolean,
timeoutMs: number = 2000,
): Promise<AlertMessage> {
const existing = this.alerts.find(predicate);
if (existing) {
return existing;
}
const observers = this.observers;
return new Promise<AlertMessage>((resolve, reject) => {
const timeoutId = setTimeout(() => {
observers.delete(observer);
reject(new Error('Timed out waiting for alert'));
}, timeoutMs);
function observer(alert: AlertMessage) {
if (predicate(alert)) {
clearTimeout(timeoutId);
observers.delete(observer);
resolve(alert);
}
}
observers.add(observer);
});
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2025 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 { MockAlertApi } from './MockAlertApi';
@@ -0,0 +1,44 @@
/*
* Copyright 2022 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 { MockConfigApi } from './MockConfigApi';
describe('MockConfigApi', () => {
it('is able to read some basic config', () => {
const mock = new MockConfigApi({
data: {
app: {
title: 'Hello',
},
x: 1,
y: false,
z: [{ a: 3 }],
},
});
expect(mock.getString('app.title')).toEqual('Hello');
expect(mock.getNumber('x')).toEqual(1);
expect(mock.getBoolean('y')).toEqual(false);
expect(mock.getConfigArray('z')[0].getOptionalNumber('a')).toEqual(3);
expect(() => mock.getString('x')).toThrow(
"Invalid type in config for key 'x' in 'mock-config', got number, wanted string",
);
expect(() => mock.getString('missing')).toThrow(
"Missing required config value at 'missing'",
);
});
});
@@ -0,0 +1,111 @@
/*
* Copyright 2022 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 { Config, ConfigReader } from '@backstage/config';
import { JsonObject, JsonValue } from '@backstage/types';
import { ConfigApi } from '@backstage/core-plugin-api';
/**
* MockConfigApi is a thin wrapper around {@link @backstage/config#ConfigReader}
* that can be used to mock configuration using a plain object.
*
* @public
* @example
* ```tsx
* const mockConfig = new MockConfigApi({
* data: { app: { baseUrl: 'https://example.com' } },
* });
*
* const rendered = await renderInTestApp(
* <TestApiProvider apis={[[configApiRef, mockConfig]]}>
* <MyTestedComponent />
* </TestApiProvider>,
* );
* ```
*/
export class MockConfigApi implements ConfigApi {
private readonly config: ConfigReader;
// NOTE: not extending in order to avoid inheriting the static `.fromConfigs`
constructor({ data }: { data: JsonObject }) {
this.config = new ConfigReader(data);
}
/** {@inheritdoc @backstage/config#Config.has} */
has(key: string): boolean {
return this.config.has(key);
}
/** {@inheritdoc @backstage/config#Config.keys} */
keys(): string[] {
return this.config.keys();
}
/** {@inheritdoc @backstage/config#Config.get} */
get<T = JsonValue>(key?: string): T {
return this.config.get(key);
}
/** {@inheritdoc @backstage/config#Config.getOptional} */
getOptional<T = JsonValue>(key?: string): T | undefined {
return this.config.getOptional(key);
}
/** {@inheritdoc @backstage/config#Config.getConfig} */
getConfig(key: string): Config {
return this.config.getConfig(key);
}
/** {@inheritdoc @backstage/config#Config.getOptionalConfig} */
getOptionalConfig(key: string): Config | undefined {
return this.config.getOptionalConfig(key);
}
/** {@inheritdoc @backstage/config#Config.getConfigArray} */
getConfigArray(key: string): Config[] {
return this.config.getConfigArray(key);
}
/** {@inheritdoc @backstage/config#Config.getOptionalConfigArray} */
getOptionalConfigArray(key: string): Config[] | undefined {
return this.config.getOptionalConfigArray(key);
}
/** {@inheritdoc @backstage/config#Config.getNumber} */
getNumber(key: string): number {
return this.config.getNumber(key);
}
/** {@inheritdoc @backstage/config#Config.getOptionalNumber} */
getOptionalNumber(key: string): number | undefined {
return this.config.getOptionalNumber(key);
}
/** {@inheritdoc @backstage/config#Config.getBoolean} */
getBoolean(key: string): boolean {
return this.config.getBoolean(key);
}
/** {@inheritdoc @backstage/config#Config.getOptionalBoolean} */
getOptionalBoolean(key: string): boolean | undefined {
return this.config.getOptionalBoolean(key);
}
/** {@inheritdoc @backstage/config#Config.getString} */
getString(key: string): string {
return this.config.getString(key);
}
/** {@inheritdoc @backstage/config#Config.getOptionalString} */
getOptionalString(key: string): string | undefined {
return this.config.getOptionalString(key);
}
/** {@inheritdoc @backstage/config#Config.getStringArray} */
getStringArray(key: string): string[] {
return this.config.getStringArray(key);
}
/** {@inheritdoc @backstage/config#Config.getOptionalStringArray} */
getOptionalStringArray(key: string): string[] | undefined {
return this.config.getOptionalStringArray(key);
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2022 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 { MockConfigApi } from './MockConfigApi';
@@ -0,0 +1,104 @@
/*
* Copyright 2020 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 { MockErrorApi } from './MockErrorApi';
async function ifResolved<T>(promise: Promise<T>): Promise<T | 'not-yet'> {
return Promise.race([promise, Promise.resolve<'not-yet'>('not-yet')]);
}
describe('MockErrorApi', () => {
it('should throw errors by default', () => {
const api = new MockErrorApi();
expect(() => api.post(new Error('NOPE'))).toThrow(
'MockErrorApi received unexpected error, Error: NOPE',
);
});
it('should collect errors', () => {
const api = new MockErrorApi({ collect: true });
api.post(new Error('e1'));
api.post(new Error('e2'), { hidden: true });
api.post(new Error('e3'));
expect(api.getErrors()).toEqual([
{
error: new Error('e1'),
},
{
error: new Error('e2'),
context: { hidden: true },
},
{
error: new Error('e3'),
},
]);
});
it('should not emit values', async () => {
const api = new MockErrorApi({ collect: true });
const promise = new Promise((resolve, reject) => {
api.error$().subscribe({
next({ error }) {
reject(error);
},
error(error) {
reject(error);
},
complete() {
reject(new Error('observable was completed'));
},
});
setTimeout(() => resolve('timed-out'), 100);
});
await expect(promise).resolves.toBe('timed-out');
});
it('should wait for errors', async () => {
const api = new MockErrorApi({ collect: true });
const wait1 = api.waitForError(/1/);
const wait2 = api.waitForError(/2/);
await expect(ifResolved(wait1)).resolves.toBe('not-yet');
await expect(ifResolved(wait2)).resolves.toBe('not-yet');
api.post(new Error('e0'));
await expect(ifResolved(wait1)).resolves.toBe('not-yet');
await expect(ifResolved(wait2)).resolves.toBe('not-yet');
api.post(new Error('e1'));
await expect(ifResolved(wait1)).resolves.toEqual({
error: new Error('e1'),
});
await expect(ifResolved(wait2)).resolves.toBe('not-yet');
api.post(new Error('e2'), { hidden: true });
await expect(ifResolved(wait2)).resolves.toEqual({
error: new Error('e2'),
context: { hidden: true },
});
});
it('should time out waiting for error', async () => {
const api = new MockErrorApi({ collect: true });
await expect(api.waitForError(/1/, 1)).rejects.toThrow(
'Timed out waiting for error',
);
});
});
@@ -0,0 +1,106 @@
/*
* Copyright 2020 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 {
ErrorApi,
ErrorApiError,
ErrorApiErrorContext,
} from '@backstage/core-plugin-api';
import { Observable } from '@backstage/types';
/**
* Constructor arguments for {@link MockErrorApi}
* @public
*/
export type MockErrorApiOptions = {
// Need to be true if getErrors is used in testing.
collect?: boolean;
};
/**
* ErrorWithContext contains error and ErrorApiErrorContext
* @public
*/
export type ErrorWithContext = {
error: ErrorApiError;
context?: ErrorApiErrorContext;
};
type Waiter = {
pattern: RegExp;
resolve: (err: ErrorWithContext) => void;
};
const nullObservable = {
subscribe: () => ({ unsubscribe: () => {}, closed: true }),
[Symbol.observable]() {
return this;
},
};
/**
* Mock implementation of the {@link core-plugin-api#ErrorApi} to be used in tests.
* Includes withForError and getErrors methods for error testing.
* @public
*/
export class MockErrorApi implements ErrorApi {
private readonly errors = new Array<ErrorWithContext>();
private readonly waiters = new Set<Waiter>();
constructor(private readonly options: MockErrorApiOptions = {}) {}
post(error: ErrorApiError, context?: ErrorApiErrorContext) {
if (this.options.collect) {
this.errors.push({ error, context });
for (const waiter of this.waiters) {
if (waiter.pattern.test(error.message)) {
this.waiters.delete(waiter);
waiter.resolve({ error, context });
}
}
return;
}
throw new Error(`MockErrorApi received unexpected error, ${error}`);
}
error$(): Observable<{
error: ErrorApiError;
context?: ErrorApiErrorContext;
}> {
return nullObservable;
}
getErrors(): ErrorWithContext[] {
return this.errors;
}
waitForError(
pattern: RegExp,
timeoutMs: number = 2000,
): Promise<ErrorWithContext> {
return new Promise<ErrorWithContext>((resolve, reject) => {
setTimeout(() => {
reject(new Error('Timed out waiting for error'));
}, timeoutMs);
this.waiters.add({ resolve, pattern });
});
}
}
@@ -0,0 +1,18 @@
/*
* Copyright 2020 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 { MockErrorApi } from './MockErrorApi';
export type { MockErrorApiOptions, ErrorWithContext } from './MockErrorApi';
@@ -0,0 +1,143 @@
/*
* Copyright 2025 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 { FeatureFlagState } from '@backstage/frontend-plugin-api';
import { MockFeatureFlagsApi } from './MockFeatureFlagsApi';
describe('MockFeatureFlagsApi', () => {
it('should register flags', () => {
const api = new MockFeatureFlagsApi();
api.registerFlag({
name: 'test-flag-1',
pluginId: 'test-plugin',
description: 'Test flag 1',
});
api.registerFlag({
name: 'test-flag-2',
pluginId: 'test-plugin',
description: 'Test flag 2',
});
expect(api.getRegisteredFlags()).toHaveLength(2);
expect(api.getRegisteredFlags()[0].name).toBe('test-flag-1');
});
it('should not register duplicate flags', () => {
const api = new MockFeatureFlagsApi();
api.registerFlag({
name: 'test-flag',
pluginId: 'test-plugin',
description: 'Test flag',
});
api.registerFlag({
name: 'test-flag',
pluginId: 'test-plugin',
description: 'Test flag duplicate',
});
expect(api.getRegisteredFlags()).toHaveLength(1);
});
it('should handle feature flag states', () => {
const api = new MockFeatureFlagsApi();
expect(api.isActive('test-flag')).toBe(false);
api.save({ states: { 'test-flag': FeatureFlagState.Active } });
expect(api.isActive('test-flag')).toBe(true);
api.save({ states: { 'test-flag': FeatureFlagState.None } });
expect(api.isActive('test-flag')).toBe(false);
});
it('should initialize with states', () => {
const api = new MockFeatureFlagsApi({
initialStates: {
'flag-1': FeatureFlagState.Active,
'flag-2': FeatureFlagState.None,
},
});
expect(api.isActive('flag-1')).toBe(true);
expect(api.isActive('flag-2')).toBe(false);
});
it('should save and replace states', () => {
const api = new MockFeatureFlagsApi({
initialStates: { 'flag-1': FeatureFlagState.Active },
});
expect(api.isActive('flag-1')).toBe(true);
api.save({
states: {
'flag-2': FeatureFlagState.Active,
},
});
expect(api.isActive('flag-1')).toBe(false);
expect(api.isActive('flag-2')).toBe(true);
});
it('should save and merge states', () => {
const api = new MockFeatureFlagsApi({
initialStates: { 'flag-1': FeatureFlagState.Active },
});
expect(api.isActive('flag-1')).toBe(true);
api.save({
states: {
'flag-2': FeatureFlagState.Active,
},
merge: true,
});
expect(api.isActive('flag-1')).toBe(true);
expect(api.isActive('flag-2')).toBe(true);
});
it('should get and set state', () => {
const api = new MockFeatureFlagsApi();
api.setState({
'flag-1': FeatureFlagState.Active,
'flag-2': FeatureFlagState.None,
});
const state = api.getState();
expect(state).toEqual({
'flag-1': FeatureFlagState.Active,
'flag-2': FeatureFlagState.None,
});
});
it('should clear state', () => {
const api = new MockFeatureFlagsApi({
initialStates: { 'flag-1': FeatureFlagState.Active },
});
expect(api.isActive('flag-1')).toBe(true);
api.clearState();
expect(api.isActive('flag-1')).toBe(false);
expect(api.getState()).toEqual({});
});
});
@@ -0,0 +1,105 @@
/*
* Copyright 2025 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 {
FeatureFlag,
FeatureFlagsApi,
FeatureFlagsSaveOptions,
FeatureFlagState,
} from '@backstage/frontend-plugin-api';
/**
* Options for configuring {@link MockFeatureFlagsApi}.
*
* @public
*/
export interface MockFeatureFlagsApiOptions {
/**
* Initial feature flag states.
*/
initialStates?: Record<string, FeatureFlagState>;
}
/**
* Mock implementation of {@link @backstage/frontend-plugin-api#FeatureFlagsApi} for testing feature flag behavior.
*
* @public
* @example
* ```ts
* const api = new MockFeatureFlagsApi({
* initialStates: { 'my-feature': FeatureFlagState.Active }
* });
* expect(api.isActive('my-feature')).toBe(true);
* ```
*/
export class MockFeatureFlagsApi implements FeatureFlagsApi {
private readonly registeredFlags: FeatureFlag[] = [];
private readonly states: Map<string, FeatureFlagState>;
constructor(options?: MockFeatureFlagsApiOptions) {
this.states = new Map(Object.entries(options?.initialStates ?? {}));
}
registerFlag(flag: FeatureFlag): void {
if (!this.registeredFlags.some(f => f.name === flag.name)) {
this.registeredFlags.push(flag);
}
}
getRegisteredFlags(): FeatureFlag[] {
return this.registeredFlags;
}
isActive(name: string): boolean {
return this.states.get(name) === FeatureFlagState.Active;
}
save(options: FeatureFlagsSaveOptions): void {
if (options.merge) {
for (const [name, state] of Object.entries(options.states)) {
this.states.set(name, state);
}
} else {
this.states.clear();
for (const [name, state] of Object.entries(options.states)) {
this.states.set(name, state);
}
}
}
/**
* Get the current state of all feature flags as a record.
*/
getState(): Record<string, FeatureFlagState> {
return Object.fromEntries(this.states);
}
/**
* Set the state of multiple feature flags.
*/
setState(states: Record<string, FeatureFlagState>): void {
for (const [name, state] of Object.entries(states)) {
this.states.set(name, state);
}
}
/**
* Clear all feature flag states.
*/
clearState(): void {
this.states.clear();
}
}
@@ -0,0 +1,18 @@
/*
* Copyright 2025 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 { MockFeatureFlagsApi } from './MockFeatureFlagsApi';
export type { MockFeatureFlagsApiOptions } from './MockFeatureFlagsApi';
@@ -0,0 +1,95 @@
/*
* Copyright 2022 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 { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { registerMswTestHooks } from '../../..';
import { MockFetchApi } from './MockFetchApi';
describe('MockFetchApi', () => {
const worker = setupServer();
registerMswTestHooks(worker);
it('works with default constructor', async () => {
worker.use(
http.get('http://example.com/data.json', () =>
HttpResponse.json({ a: 'foo' }, { status: 200 }),
),
);
const m = new MockFetchApi();
const response = await m.fetch('http://example.com/data.json');
await expect(response.json()).resolves.toEqual({ a: 'foo' });
});
describe('baseImplementation', () => {
it('works with a mock implementation', async () => {
const inner = jest.fn();
const m = new MockFetchApi({ baseImplementation: inner });
await m.fetch('http://example.com/data.json');
expect(inner).toHaveBeenLastCalledWith('http://example.com/data.json');
});
});
describe('resolvePluginProtocol', () => {
it('works', async () => {
const inner = jest.fn();
const m = new MockFetchApi({
baseImplementation: inner,
resolvePluginProtocol: {
discoveryApi: {
getBaseUrl: async id => `https://blah.com/api/${id}`,
},
},
});
await m.fetch('plugin://the-plugin/a/data.json');
expect(inner.mock.calls[0][0]).toBe(
'https://blah.com/api/the-plugin/a/data.json',
);
});
});
describe('injectIdentityAuth', () => {
it('works with token', async () => {
const inner = jest.fn();
const m = new MockFetchApi({
baseImplementation: inner,
injectIdentityAuth: { token: 'hello' },
});
await m.fetch('http://example.com/data.json');
expect(inner.mock.calls[0][0].headers?.get('authorization')).toBe(
'Bearer hello',
);
});
it('works with identityApi', async () => {
const inner = jest.fn();
const m = new MockFetchApi({
baseImplementation: inner,
injectIdentityAuth: {
identityApi: {
async getCredentials() {
return { token: 'hello2' };
},
},
},
});
await m.fetch('http://example.com/data.json');
expect(inner.mock.calls[0][0].headers?.get('authorization')).toBe(
'Bearer hello2',
);
});
});
});
@@ -0,0 +1,167 @@
/*
* Copyright 2022 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 {
createFetchApi,
FetchMiddleware,
FetchMiddlewares,
} from '@backstage/core-app-api';
import {
DiscoveryApi,
FetchApi,
IdentityApi,
} from '@backstage/core-plugin-api';
/**
* The options given when constructing a {@link MockFetchApi}.
*
* @public
*/
export interface MockFetchApiOptions {
/**
* Define the underlying base `fetch` implementation.
*
* @defaultValue undefined
* @remarks
*
* Leaving out this parameter or passing `undefined`, makes the API use the
* global `fetch` implementation to make real network requests.
*
* `'none'` swallows all calls and makes no requests at all.
*
* You can also pass in any `fetch` compatible callback, such as a
* `jest.fn()`, if you want to use a custom implementation or to just track
* and assert on calls.
*/
baseImplementation?: undefined | 'none' | typeof fetch;
/**
* Add translation from `plugin://` URLs to concrete http(s) URLs, basically
* simulating what
* {@link @backstage/core-app-api#FetchMiddlewares.resolvePluginProtocol}
* does.
*
* @defaultValue undefined
* @remarks
*
* Leaving out this parameter or passing `undefined`, disables plugin protocol
* translation.
*
* To enable the feature, pass in a discovery API which is then used to
* resolve the URLs.
*/
resolvePluginProtocol?:
| undefined
| { discoveryApi: Pick<DiscoveryApi, 'getBaseUrl'> };
/**
* Add token based Authorization headers to requests, basically simulating
* what {@link @backstage/core-app-api#FetchMiddlewares.injectIdentityAuth}
* does.
*
* @defaultValue undefined
* @remarks
*
* Leaving out this parameter or passing `undefined`, disables auth injection.
*
* To enable the feature, pass in either a static token or an identity API
* which is queried on each request for a token.
*/
injectIdentityAuth?:
| undefined
| { token: string }
| { identityApi: Pick<IdentityApi, 'getCredentials'> };
}
/**
* A test helper implementation of {@link @backstage/core-plugin-api#FetchApi}.
*
* @public
*/
export class MockFetchApi implements FetchApi {
private readonly implementation: FetchApi;
/**
* Creates a mock {@link @backstage/core-plugin-api#FetchApi}.
*/
constructor(options?: MockFetchApiOptions) {
this.implementation = build(options);
}
/** {@inheritdoc @backstage/core-plugin-api#FetchApi.fetch} */
get fetch(): typeof fetch {
return this.implementation.fetch;
}
}
//
// Helpers
//
function build(options?: MockFetchApiOptions): FetchApi {
return createFetchApi({
baseImplementation: baseImplementation(options),
middleware: [
resolvePluginProtocol(options),
injectIdentityAuth(options),
].filter((x): x is FetchMiddleware => Boolean(x)),
});
}
function baseImplementation(
options: MockFetchApiOptions | undefined,
): typeof fetch {
const implementation = options?.baseImplementation;
if (!implementation) {
// Return a wrapper that evaluates global.fetch at call time, not construction time.
// This allows MSW to patch global.fetch after MockFetchApi is constructed.
return (input, init) => global.fetch(input, init);
} else if (implementation === 'none') {
return () => Promise.resolve(new Response());
}
return implementation;
}
function resolvePluginProtocol(
allOptions: MockFetchApiOptions | undefined,
): FetchMiddleware | undefined {
const options = allOptions?.resolvePluginProtocol;
if (!options) {
return undefined;
}
return FetchMiddlewares.resolvePluginProtocol({
discoveryApi: options.discoveryApi,
});
}
function injectIdentityAuth(
allOptions: MockFetchApiOptions | undefined,
): FetchMiddleware | undefined {
const options = allOptions?.injectIdentityAuth;
if (!options) {
return undefined;
}
const identityApi: Pick<IdentityApi, 'getCredentials'> =
'token' in options
? { getCredentials: async () => ({ token: options.token }) }
: options.identityApi;
return FetchMiddlewares.injectIdentityAuth({
identityApi: identityApi as IdentityApi,
allowUrl: () => true,
});
}
@@ -0,0 +1,18 @@
/*
* Copyright 2022 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 { MockFetchApi } from './MockFetchApi';
export type { MockFetchApiOptions } from './MockFetchApi';
@@ -0,0 +1,114 @@
/*
* Copyright 2025 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 { ApiRef } from '@backstage/frontend-plugin-api';
import { ApiFactory } from '@backstage/frontend-plugin-api';
/**
* Symbol used to mark mock API instances with their corresponding API factory.
*
* @ignore
*/
export const mockApiFactorySymbol = Symbol.for('@backstage/mock-api');
/**
* Symbol used to mark mock API instances with their corresponding API factory.
* This allows mock APIs to be passed directly to test utilities without
* needing to explicitly provide the [apiRef, implementation] tuple.
*
* @public
*/
export type MockApiFactorySymbol = typeof mockApiFactorySymbol;
/**
* Type for an API instance that has been marked as a mock API.
*
* @public
*/
export type MockWithApiFactory<TApi> = TApi & {
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
};
/**
* Helper to attach mock API metadata to an instance.
*
* @internal
*/
export function mockWithApiFactory<TApi, TImpl extends TApi = TApi>(
apiRef: ApiRef<TApi>,
implementation: TImpl,
): TImpl & { [mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}> } {
const marked = implementation as TImpl & {
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
};
(marked as any)[mockApiFactorySymbol] = {
api: apiRef,
deps: {},
factory: () => implementation,
};
return marked;
}
/**
* Attaches mock API factory metadata to an API instance, allowing it to be
* passed directly to test utilities without needing to explicitly provide
* the [apiRef, implementation] tuple.
*
* @public
* @example
* ```ts
* const catalogApi = attachMockApiFactory(
* catalogApiRef,
* new InMemoryCatalogClient()
* );
* // Can now be passed directly to TestApiProvider
* <TestApiProvider apis={[catalogApi]}>
* ```
*/
export function attachMockApiFactory<TApi, TImpl extends TApi = TApi>(
apiRef: ApiRef<TApi>,
implementation: TImpl,
): TImpl & { [mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}> } {
const marked = implementation as TImpl & {
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
};
(marked as any)[mockApiFactorySymbol] = {
api: apiRef,
deps: {},
factory: () => implementation,
};
return marked;
}
/**
* Retrieves the API factory from a mock API instance.
* Returns undefined if the value is not a mock API instance.
*
* @internal
*/
export function getMockApiFactory(
value: unknown,
): ApiFactory<any, any, {}> | undefined {
if (
typeof value === 'object' &&
value !== null &&
mockApiFactorySymbol in value &&
typeof (value as any)[mockApiFactorySymbol] === 'object'
) {
return (value as any)[mockApiFactorySymbol];
}
return undefined;
}
@@ -0,0 +1,47 @@
/*
* 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 {
AuthorizeResult,
createPermission,
} from '@backstage/plugin-permission-common';
import { MockPermissionApi } from './MockPermissionApi';
describe('MockPermissionApi', () => {
it('returns ALLOW by default', async () => {
const api = new MockPermissionApi();
await expect(
api.authorize({
permission: createPermission({ name: 'permission.1', attributes: {} }),
}),
).resolves.toEqual({ result: AuthorizeResult.ALLOW });
});
it('allows passing a handler to customize the result', async () => {
const api = new MockPermissionApi(request =>
request.permission.name === 'permission.2'
? AuthorizeResult.DENY
: AuthorizeResult.ALLOW,
);
await expect(
api.authorize({
permission: createPermission({ name: 'permission.2', attributes: {} }),
}),
).resolves.toEqual({ result: AuthorizeResult.DENY });
});
});
@@ -0,0 +1,45 @@
/*
* Copyright 2022 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 { PermissionApi } from '@backstage/plugin-permission-react';
import {
EvaluatePermissionResponse,
EvaluatePermissionRequest,
AuthorizeResult,
} from '@backstage/plugin-permission-common';
/**
* Mock implementation of
* {@link @backstage/plugin-permission-react#PermissionApi}. Supply a
* requestHandler function to override the mock result returned for a given
* request.
*
* @public
*/
export class MockPermissionApi implements PermissionApi {
constructor(
private readonly requestHandler: (
request: EvaluatePermissionRequest,
) => AuthorizeResult.ALLOW | AuthorizeResult.DENY = () =>
AuthorizeResult.ALLOW,
) {}
async authorize(
request: EvaluatePermissionRequest,
): Promise<EvaluatePermissionResponse> {
return { result: this.requestHandler(request) };
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2022 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 { MockPermissionApi } from './MockPermissionApi';
@@ -0,0 +1,280 @@
/*
* Copyright 2020 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 { StorageApi } from '@backstage/core-plugin-api';
import { MockStorageApi } from './MockStorageApi';
describe('WebStorage Storage API', () => {
const createMockStorage = (): StorageApi => {
return MockStorageApi.create();
};
it('should return undefined for values which are unset', async () => {
const storage = createMockStorage();
expect(storage.snapshot('myfakekey').value).toBeUndefined();
expect(storage.snapshot('myfakekey')).toEqual({
key: 'myfakekey',
presence: 'absent',
value: undefined,
});
});
it('should allow the setting and snapshotting of the simple data structures', async () => {
const storage = createMockStorage();
await storage.set('myfakekey', 'helloimastring');
await storage.set('mysecondfakekey', 1234);
await storage.set('mythirdfakekey', true);
expect(storage.snapshot('myfakekey').value).toBe('helloimastring');
expect(storage.snapshot('mysecondfakekey').value).toBe(1234);
expect(storage.snapshot('mythirdfakekey').value).toBe(true);
expect(storage.snapshot('myfakekey')).toEqual({
key: 'myfakekey',
presence: 'present',
value: 'helloimastring',
});
expect(storage.snapshot('mysecondfakekey')).toEqual({
key: 'mysecondfakekey',
presence: 'present',
value: 1234,
});
expect(storage.snapshot('mythirdfakekey')).toEqual({
key: 'mythirdfakekey',
presence: 'present',
value: true,
});
});
it('should allow setting of complex datastructures', async () => {
const storage = createMockStorage();
const mockData = {
something: 'here',
is: [{ super: { complex: [{ but: 'something', why: true }] } }],
};
await storage.set('myfakekey', mockData);
expect(storage.snapshot('myfakekey').value).toEqual(mockData);
expect(storage.snapshot('myfakekey')).toEqual({
key: 'myfakekey',
presence: 'present',
value: mockData,
});
});
it('should subscribe to key changes when setting a new value', async () => {
const storage = createMockStorage();
const wrongKeyNextHandler = jest.fn();
const selectedKeyNextHandler = jest.fn();
const mockData = { hello: 'im a great new value' };
await new Promise<void>(resolve => {
storage.observe$<typeof mockData>('correctKey').subscribe({
next: (...args) => {
selectedKeyNextHandler(...args);
resolve();
},
});
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
storage.set('correctKey', mockData);
});
expect(wrongKeyNextHandler).not.toHaveBeenCalled();
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'correctKey',
presence: 'present',
value: mockData,
});
});
it('should subscribe to key changes when deleting a value', async () => {
const storage = createMockStorage();
const wrongKeyNextHandler = jest.fn();
const selectedKeyNextHandler = jest.fn();
const mockData = { hello: 'im a great new value' };
storage.set('correctKey', mockData);
await new Promise<void>(resolve => {
storage.observe$('correctKey').subscribe({
next: (...args) => {
selectedKeyNextHandler(...args);
resolve();
},
});
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
storage.remove('correctKey');
});
expect(wrongKeyNextHandler).not.toHaveBeenCalled();
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'correctKey',
presence: 'absent',
value: undefined,
});
});
it('should be able to create different buckets for different uses', async () => {
const rootStorage = createMockStorage();
const firstStorage = rootStorage.forBucket('userSettings');
const secondStorage = rootStorage.forBucket('profileSettings');
const keyName = 'blobby';
await firstStorage.set(keyName, 'boop');
await secondStorage.set(keyName, 'deerp');
expect(firstStorage.snapshot(keyName)).not.toBe(
secondStorage.snapshot(keyName),
);
expect(firstStorage.snapshot(keyName).value).toBe('boop');
expect(secondStorage.snapshot(keyName).value).toBe('deerp');
expect(firstStorage.snapshot(keyName)).not.toEqual(
secondStorage.snapshot(keyName),
);
expect(firstStorage.snapshot(keyName)).toEqual({
key: keyName,
presence: 'present',
value: 'boop',
});
expect(secondStorage.snapshot(keyName)).toEqual({
key: keyName,
presence: 'present',
value: 'deerp',
});
});
it('should not clash with other namespaces when creating buckets', async () => {
const rootStorage = createMockStorage();
// when getting key test2 it will translate to /profile/something/deep/test2
const firstStorage = rootStorage
.forBucket('profile')
.forBucket('something')
.forBucket('deep');
// when getting key deep/test2 it will translate to /profile/something/deep/test2
const secondStorage = rootStorage.forBucket('profile/something');
await firstStorage.set('test2', { error: true });
expect(secondStorage.snapshot('deep/test2').value).toBe(undefined);
expect(secondStorage.snapshot('deep/test2')).toMatchObject({
presence: 'absent',
});
});
it('should not reuse storage instances between different rootStorages', async () => {
const rootStorage1 = createMockStorage();
const rootStorage2 = createMockStorage();
const firstStorage = rootStorage1.forBucket('something');
const secondStorage = rootStorage2.forBucket('something');
await firstStorage.set('test2', true);
expect(firstStorage.snapshot('test2').value).toBe(true);
expect(secondStorage.snapshot('test2').value).toBe(undefined);
expect(firstStorage.snapshot('test2')).toEqual({
key: 'test2',
presence: 'present',
value: true,
});
expect(secondStorage.snapshot('test2')).toEqual({
key: 'test2',
presence: 'absent',
value: undefined,
});
});
it('should freeze the snapshot value', async () => {
const storage = createMockStorage();
const data = { foo: 'bar', baz: [{ foo: 'bar' }] };
storage.set('foo', data);
const snapshot = storage.snapshot<typeof data>('foo');
expect(snapshot.value).not.toBe(data);
if (snapshot.presence !== 'present') {
throw new Error('Invalid presence');
}
expect(() => {
snapshot.value.foo = 'buzz';
}).toThrow(/Cannot assign to read only property/);
expect(() => {
snapshot.value.baz[0].foo = 'buzz';
}).toThrow(/Cannot assign to read only property/);
expect(() => {
snapshot.value.baz.push({ foo: 'buzz' });
}).toThrow(/Cannot add property 1, object is not extensible/);
});
it('should freeze observed values', async () => {
const storage = createMockStorage();
const snapshotPromise = new Promise<any>(resolve => {
storage.observe$('test').subscribe({
next: resolve,
});
});
storage.set('test', {
foo: {
bar: 'baz',
},
});
const snapshot = await snapshotPromise;
expect(snapshot.presence).toBe('present');
expect(() => {
snapshot.value!.foo.bar = 'qux';
}).toThrow(/Cannot assign to read only property 'bar' of object/);
});
it('should JSON serialize stored values', async () => {
const storage = createMockStorage();
storage.set<any>('test', {
foo: {
toJSON() {
return {
bar: 'baz',
};
},
},
});
expect(storage.snapshot('test')).toMatchObject({
presence: 'present',
value: {
foo: {
bar: 'baz',
},
},
});
});
});
@@ -0,0 +1,141 @@
/*
* Copyright 2020 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 { StorageApi, StorageValueSnapshot } from '@backstage/core-plugin-api';
import { JsonObject, JsonValue, Observable } from '@backstage/types';
import ObservableImpl from 'zen-observable';
/**
* Mock implementation of the {@link core-plugin-api#StorageApi} to be used in tests
*
* @public
*/
export class MockStorageApi implements StorageApi {
private readonly namespace: string;
private readonly data: JsonObject;
private readonly bucketStorageApis: Map<string, MockStorageApi>;
private constructor(
namespace: string,
bucketStorageApis: Map<string, MockStorageApi>,
data?: JsonObject,
) {
this.namespace = namespace;
this.bucketStorageApis = bucketStorageApis;
this.data = { ...data };
}
static create(data?: JsonObject) {
// Translate a nested data object structure into a flat object with keys
// like `/a/b` with their corresponding leaf values
const keyValues: { [key: string]: any } = {};
function put(value: { [key: string]: any }, namespace: string) {
for (const [key, val] of Object.entries(value)) {
if (typeof val === 'object' && val !== null) {
put(val, `${namespace}/${key}`);
} else {
const namespacedKey = `${namespace}/${key.replace(/^\//, '')}`;
keyValues[namespacedKey] = val;
}
}
}
put(data ?? {}, '');
return new MockStorageApi('', new Map(), keyValues);
}
forBucket(name: string): StorageApi {
if (!this.bucketStorageApis.has(name)) {
this.bucketStorageApis.set(
name,
new MockStorageApi(
`${this.namespace}/${name}`,
this.bucketStorageApis,
this.data,
),
);
}
return this.bucketStorageApis.get(name)!;
}
snapshot<T extends JsonValue>(key: string): StorageValueSnapshot<T> {
if (this.data.hasOwnProperty(this.getKeyName(key))) {
const data = this.data[this.getKeyName(key)];
return {
key,
presence: 'present',
value: data as T,
};
}
return {
key,
presence: 'absent',
value: undefined,
};
}
async set<T>(key: string, data: T): Promise<void> {
const serialized = JSON.parse(JSON.stringify(data), (_key, value) => {
if (typeof value === 'object' && value !== null) {
Object.freeze(value);
}
return value;
});
this.data[this.getKeyName(key)] = serialized;
this.notifyChanges({
key,
presence: 'present',
value: serialized,
});
}
async remove(key: string): Promise<void> {
delete this.data[this.getKeyName(key)];
this.notifyChanges({
key,
presence: 'absent',
value: undefined,
});
}
observe$<T extends JsonValue>(
key: string,
): Observable<StorageValueSnapshot<T>> {
return this.observable.filter(({ key: messageKey }) => messageKey === key);
}
private getKeyName(key: string) {
return `${this.namespace}/${encodeURIComponent(key)}`;
}
private notifyChanges<T extends JsonValue>(message: StorageValueSnapshot<T>) {
for (const subscription of this.subscribers) {
subscription.next(message);
}
}
private subscribers = new Set<
ZenObservable.SubscriptionObserver<StorageValueSnapshot<JsonValue>>
>();
private readonly observable = new ObservableImpl<
StorageValueSnapshot<JsonValue>
>(subscriber => {
this.subscribers.add(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
});
}
@@ -0,0 +1,17 @@
/*
* Copyright 2020 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 { MockStorageApi } from './MockStorageApi';
@@ -0,0 +1,127 @@
/*
* Copyright 2020 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 { ReactNode } from 'react';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { ApiProvider } from '../../../core-app-api/src/apis/system';
import { ApiHolder, ApiRef } from '@backstage/frontend-plugin-api';
import {
getMockApiFactory,
type MockWithApiFactory,
} from './MockWithApiFactory';
/**
* Represents a single API implementation, either as a tuple of the reference and the implementation, or a mock with an embedded factory.
* @public
*/
export type TestApiPair<TApi> =
| readonly [ApiRef<TApi>, TApi extends infer TImpl ? Partial<TImpl> : never]
| MockWithApiFactory<TApi>;
/**
* Represents an array of mock API implementation.
* @public
*/
export type TestApiPairs<TApiPairs> = {
[TIndex in keyof TApiPairs]: TestApiPair<TApiPairs[TIndex]>;
};
/** @internal */
export function resolveTestApiEntries<const TApiPairs extends any[]>(
apis: readonly [...TestApiPairs<TApiPairs>],
): ApiHolder {
const apiMap = new Map<string, unknown>();
for (const entry of apis) {
const mockFactory = getMockApiFactory(entry);
if (mockFactory) {
apiMap.set(mockFactory.api.id, mockFactory.factory({}));
} else {
const [apiRef, impl] = entry as readonly [ApiRef<any>, any];
apiMap.set(apiRef.id, impl);
}
}
return {
get: <T,>(ref: ApiRef<T>) => apiMap.get(ref.id) as T | undefined,
};
}
/**
* Properties for the {@link TestApiProvider} component.
*
* @public
*/
export type TestApiProviderProps<TApiPairs extends any[]> = {
apis: readonly [...TestApiPairs<TApiPairs>];
children: ReactNode;
};
/**
* The `TestApiProvider` is a Utility API context provider for standalone rendering
* scenarios where you're not using `renderInTestApp` or other test utilities.
*
* It lets you provide any number of API implementations, without necessarily
* having to fully implement each of the APIs.
*
* @remarks
*
* For most test scenarios, prefer using the `apis` option in `renderInTestApp` or
* `createExtensionTester` instead of wrapping components with `TestApiProvider`.
*
* @example
* ```tsx
* import { render } from '\@testing-library/react';
* import { myCustomApiRef } from '../apis';
* import { TestApiProvider, mockApis } from '\@backstage/frontend-test-utils';
*
* // Mock custom APIs with tuple syntax
* const myCustomApiMock = { myMethod: jest.fn() };
* render(
* <TestApiProvider
* apis={[
* [myCustomApiRef, myCustomApiMock]
* ]}
* >
* <MyComponent />
* </TestApiProvider>
* );
*
* // Use with built-in mock APIs (no tuples needed)
* render(
* <TestApiProvider
* apis={[
* mockApis.identity({ userEntityRef: 'user:default/guest' }),
* mockApis.alert(),
* ]}
* >
* <MyComponent />
* </TestApiProvider>
* );
* ```
*
* @public
*/
export function TestApiProvider<const TApiPairs extends any[]>(
props: TestApiProviderProps<TApiPairs>,
): JSX.Element {
return (
<ApiProvider
apis={resolveTestApiEntries(props.apis)}
children={props.children}
/>
);
}
@@ -0,0 +1,212 @@
/*
* Copyright 2020 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 { createTranslationRef } from '@backstage/core-plugin-api/alpha';
import { MockTranslationApi } from './MockTranslationApi';
describe('MockTranslationApi', () => {
function snapshotWithMessages<
const TMessages extends { [key in string]: string },
>(messages: TMessages) {
const translationApi = MockTranslationApi.create();
const ref = createTranslationRef({
id: 'test',
messages,
});
const snapshot = translationApi.getTranslation(ref);
if (!snapshot.ready) {
throw new Error('Translation snapshot is not ready');
}
return snapshot;
}
it('should format plain messages', () => {
const snapshot = snapshotWithMessages({
foo: 'Foo',
bar: 'Bar',
baz: 'Baz',
});
expect(snapshot.t('foo')).toBe('Foo');
expect(snapshot.t('bar')).toBe('Bar');
expect(snapshot.t('baz')).toBe('Baz');
});
it('should support interpolation', () => {
const snapshot = snapshotWithMessages({
shallow: 'Foo {{ bar }}',
multiple: 'Foo {{ bar }} {{ baz }}',
deep: 'Foo {{ bar.baz }}',
});
// @ts-expect-error
expect(snapshot.t('shallow')).toBe('Foo {{ bar }}');
expect(snapshot.t('shallow', { bar: 'Bar' })).toBe('Foo Bar');
// @ts-expect-error
expect(snapshot.t('multiple')).toBe('Foo {{ bar }} {{ baz }}');
// @ts-expect-error
expect(snapshot.t('multiple', { bar: 'Bar' })).toBe('Foo Bar {{ baz }}');
expect(snapshot.t('multiple', { bar: 'Bar', baz: 'Baz' })).toBe(
'Foo Bar Baz',
);
// @ts-expect-error
expect(snapshot.t('deep')).toBe('Foo {{ bar.baz }}');
expect(snapshot.t('deep', { bar: { baz: 'Baz' } })).toBe('Foo Baz');
});
// Escaping isn't as useful in React, since we don't need to escape HTML in strings
it('should not escape by default', () => {
const snapshot = snapshotWithMessages({
foo: 'Foo {{ foo }}',
});
expect(snapshot.t('foo', { foo: '<div>' })).toBe('Foo <div>');
expect(
snapshot.t('foo', {
foo: '<div>',
interpolation: { escapeValue: true },
}),
).toBe('Foo &lt;div&gt;');
});
it('should support nesting', () => {
const snapshot = snapshotWithMessages({
foo: 'Foo $t(bar) $t(baz)',
bar: 'Nested',
baz: 'Baz {{ qux }}',
});
expect(snapshot.t('foo', { qux: 'Deep' })).toBe('Foo Nested Baz Deep');
});
it('should support jsx interpolation', () => {
const snapshot = snapshotWithMessages({
empty: 'derp',
jsx: '={{ x }}',
jsxNested: '={{ x.y.z }}',
jsxDeep: '<$t(jsx)>',
});
expect(snapshot.t('jsx', { x: <h1>hello</h1> })).toMatchInlineSnapshot(`
<React.Fragment>
=
<h1>
hello
</h1>
</React.Fragment>
`);
expect(snapshot.t('jsx', { replace: { x: <h1>hello</h1> } }))
.toMatchInlineSnapshot(`
<React.Fragment>
=
<h1>
hello
</h1>
</React.Fragment>
`);
expect(
snapshot.t('jsxNested', { replace: { x: { y: { z: <h1>hello</h1> } } } }),
).toMatchInlineSnapshot(`
<React.Fragment>
=
<h1>
hello
</h1>
</React.Fragment>
`);
expect(snapshot.t('jsxDeep', { x: <h1>hello</h1> })).toMatchInlineSnapshot(`
<React.Fragment>
&lt;=
<h1>
hello
</h1>
&gt;
</React.Fragment>
`);
});
it('should support formatting', () => {
const snapshot = snapshotWithMessages({
plain: '= {{ x }}',
number: '= {{ x, number }}',
numberFixed: '= {{ x, number(minimumFractionDigits: 2) }}',
relativeTime: '= {{ x, relativeTime }}',
relativeSeconds: '= {{ x, relativeTime(second) }}',
relativeSecondsShort:
'= {{ x, relativeTime(range: second; style: short) }}',
list: '= {{ x, list }}',
});
expect(snapshot.t('plain', { x: '5' })).toBe('= 5');
expect(snapshot.t('number', { x: 5 })).toBe('= 5');
expect(
snapshot.t('number', {
x: 5,
formatParams: { x: { minimumFractionDigits: 1 } },
}),
).toBe('= 5.0');
expect(snapshot.t('numberFixed', { x: 5 })).toBe('= 5.00');
expect(
snapshot.t('numberFixed', {
x: 5,
formatParams: { x: { minimumFractionDigits: 3 } },
}),
).toBe('= 5.000');
expect(snapshot.t('relativeTime', { x: 3 })).toBe('= in 3 days');
expect(snapshot.t('relativeTime', { x: -3 })).toBe('= 3 days ago');
expect(
snapshot.t('relativeTime', {
x: 15,
formatParams: { x: { range: 'weeks' } },
}),
).toBe('= in 15 weeks');
expect(
snapshot.t('relativeTime', {
x: 15,
formatParams: { x: { range: 'weeks', style: 'short' } },
}),
).toBe('= in 15 wk.');
expect(snapshot.t('relativeSeconds', { x: 1 })).toBe('= in 1 second');
expect(snapshot.t('relativeSeconds', { x: 2 })).toBe('= in 2 seconds');
expect(snapshot.t('relativeSeconds', { x: -3 })).toBe('= 3 seconds ago');
expect(snapshot.t('relativeSeconds', { x: 0 })).toBe('= in 0 seconds');
expect(snapshot.t('relativeSecondsShort', { x: 1 })).toBe('= in 1 sec.');
expect(snapshot.t('relativeSecondsShort', { x: 2 })).toBe('= in 2 sec.');
expect(snapshot.t('relativeSecondsShort', { x: -3 })).toBe('= 3 sec. ago');
expect(snapshot.t('relativeSecondsShort', { x: 0 })).toBe('= in 0 sec.');
expect(snapshot.t('list', { x: ['a'] })).toBe('= a');
expect(snapshot.t('list', { x: ['a', 'b'] })).toBe('= a and b');
expect(snapshot.t('list', { x: ['a', 'b', 'c'] })).toBe('= a, b, and c');
});
it('should support plurals', () => {
const snapshot = snapshotWithMessages({
derp_one: 'derp',
derp_other: 'derps',
derpWithCount_one: '{{ count }} derp',
derpWithCount_other: '{{ count }} derps',
});
expect(snapshot.t('derp', { count: 1 })).toBe('derp');
expect(snapshot.t('derp', { count: 2 })).toBe('derps');
expect(snapshot.t('derp', { count: 0 })).toBe('derps');
expect(snapshot.t('derpWithCount', { count: 1 })).toBe('1 derp');
expect(snapshot.t('derpWithCount', { count: 2 })).toBe('2 derps');
expect(snapshot.t('derpWithCount', { count: 0 })).toBe('0 derps');
});
});
@@ -0,0 +1,110 @@
/*
* 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 {
TranslationApi,
TranslationRef,
TranslationSnapshot,
} from '@backstage/core-plugin-api/alpha';
import { createInstance as createI18n, type i18n as I18n } from 'i18next';
import ObservableImpl from 'zen-observable';
import { Observable } from '@backstage/types';
// Internal import to avoid code duplication, this will lead to duplication in build output
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalTranslationRef } from '../../../../frontend-plugin-api/src/translation/TranslationRef';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { JsxInterpolator } from '../../../../core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi';
const DEFAULT_LANGUAGE = 'en';
/**
* Mock implementation of {@link @backstage/core-plugin-api/alpha#TranslationApi}.
*
* @public
*/
export class MockTranslationApi implements TranslationApi {
static create() {
const i18n = createI18n({
fallbackLng: DEFAULT_LANGUAGE,
supportedLngs: [DEFAULT_LANGUAGE],
interpolation: {
escapeValue: false,
// Used for the JsxInterpolator format hook
alwaysFormat: true,
},
ns: [],
defaultNS: false,
fallbackNS: false,
// Disable resource loading on init, meaning i18n will be ready to use immediately
initImmediate: false,
});
i18n.init();
if (!i18n.isInitialized) {
throw new Error('i18next was unexpectedly not initialized');
}
const interpolator = JsxInterpolator.fromI18n(i18n);
return new MockTranslationApi(i18n, interpolator);
}
readonly #i18n: I18n;
readonly #interpolator: JsxInterpolator;
readonly #registeredRefs = new Set<string>();
private constructor(i18n: I18n, interpolator: JsxInterpolator) {
this.#i18n = i18n;
this.#interpolator = interpolator;
}
getTranslation<TMessages extends { [key in string]: string }>(
translationRef: TranslationRef<string, TMessages>,
): TranslationSnapshot<TMessages> {
const internalRef = toInternalTranslationRef(translationRef);
if (!this.#registeredRefs.has(internalRef.id)) {
this.#registeredRefs.add(internalRef.id);
this.#i18n.addResourceBundle(
DEFAULT_LANGUAGE,
internalRef.id,
internalRef.getDefaultMessages(),
false, // do not merge
true, // overwrite existing
);
}
const t = this.#interpolator.wrapT<TMessages>(
this.#i18n.getFixedT(null, internalRef.id),
);
return {
ready: true,
t,
};
}
translation$<TMessages extends { [key in string]: string }>(): Observable<
TranslationSnapshot<TMessages>
> {
// No need to implement, getTranslation will always return a ready snapshot
return new ObservableImpl<TranslationSnapshot<TMessages>>(_subscriber => {
return () => {};
});
}
}
@@ -14,11 +14,4 @@
* limitations under the License.
*/
export {
TestApiProvider,
TestApiRegistry,
type TestApiProviderPropsApiPair,
type TestApiProviderPropsApiPairs,
type TestApiPairs,
} from './TestApiProvider';
export type { TestApiProviderProps } from './TestApiProvider';
export { MockTranslationApi } from './MockTranslationApi';
@@ -0,0 +1,86 @@
/*
* Copyright 2025 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 { createApiRef } from '@backstage/frontend-plugin-api';
import { createApiMock } from './createApiMock';
import { getMockApiFactory } from './MockWithApiFactory';
describe('createApiMock', () => {
type TestApi = {
greet(name: string): string;
count: number;
};
const testApiRef = createApiRef<TestApi>({ id: 'test.create-mock' });
it('returns a factory function that produces jest mocks', () => {
const mock = createApiMock(testApiRef, () => ({
greet: jest.fn(),
count: 0 as any,
}));
const api = mock();
api.greet('world');
expect(api.greet).toHaveBeenCalledTimes(1);
expect(api.greet).toHaveBeenCalledWith('world');
});
it('applies partial implementations via mockImplementation', () => {
const mock = createApiMock(testApiRef, () => ({
greet: jest.fn(),
count: 0 as any,
}));
const api = mock({ greet: (name: string) => `Hello ${name}!` });
expect(api.greet('world')).toBe('Hello world!');
expect(api.greet).toHaveBeenCalledTimes(1);
});
it('preserves non-function partial values', () => {
const mock = createApiMock(testApiRef, () => ({
greet: jest.fn(),
count: 0 as any,
}));
const api = mock({ count: 42 });
expect(api.count).toBe(42);
});
it('attaches a mock API factory via the symbol', () => {
const mock = createApiMock(testApiRef, () => ({
greet: jest.fn(),
count: 0 as any,
}));
const api = mock();
const factory = getMockApiFactory(api);
expect(factory).toBeDefined();
expect(factory!.api).toBe(testApiRef);
});
it('creates fresh mocks on each call', () => {
const mock = createApiMock(testApiRef, () => ({
greet: jest.fn(),
count: 0 as any,
}));
const api1 = mock();
const api2 = mock();
api1.greet('a');
expect(api1.greet).toHaveBeenCalledTimes(1);
expect(api2.greet).toHaveBeenCalledTimes(0);
});
});
@@ -0,0 +1,87 @@
/*
* Copyright 2026 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 { ApiFactory, type ApiRef } from '@backstage/frontend-plugin-api';
import { mockApiFactorySymbol } from './MockWithApiFactory';
/**
* Represents a mocked version of an API, where you automatically have access to
* the mocked versions of all of its methods along with a factory that returns
* that same mock.
*
* @public
*/
export type ApiMock<TApi> = {
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
} & {
[Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return
? TApi[Key] & jest.MockInstance<Return, Args>
: TApi[Key];
};
/**
* Creates a standardized Backstage Utility API mockfactory function for
* producing mock API instances.
*
* @remarks
*
* Each method in the mock factory is a `jest.fn()`, and you can optionally pass
* partial implementations when calling the returned function. No type
* parameters should be provided to this function, they will be inferred from
* the provided API reference.
*
* @public
* @example
* ```ts
* import { createApiMock } from '@backstage/frontend-test-utils';
* import { myApiRef } from '../apis';
*
* // Set up the mock factory
* const mock = createApiMock(myApiRef, () => ({
* greet: jest.fn(),
* }));
*
* // Create a mock with default behavior
* const api = mock();
*
* // Or with a partial implementation
* const api = mock({ greet: async () => 'Hello!' });
* expect(api.greet).toHaveBeenCalledTimes(1);
* ```
*/
export function createApiMock<TApi>(
apiRef: ApiRef<TApi>,
mockFactory: () => jest.Mocked<TApi>,
): (partialImpl?: Partial<TApi>) => ApiMock<TApi> {
return partialImpl => {
const mock = mockFactory();
if (partialImpl) {
for (const [key, impl] of Object.entries(partialImpl)) {
if (typeof impl === 'function') {
(mock as any)[key].mockImplementation(impl);
} else {
(mock as any)[key] = impl;
}
}
}
(mock as any)[mockApiFactorySymbol] = {
api: apiRef,
deps: {},
factory: () => mock,
};
return mock as unknown as ApiMock<TApi>;
};
}
+68 -13
View File
@@ -14,18 +14,73 @@
* limitations under the License.
*/
export { mockApis } from './mockApis';
export { createApiMock, type ApiMock } from './createApiMock';
export {
MockConfigApi,
type ErrorWithContext,
MockErrorApi,
type MockErrorApiOptions,
MockFetchApi,
type MockFetchApiOptions,
MockPermissionApi,
MockStorageApi,
type MockStorageBucket,
mockApis,
type ApiMock,
} from '@backstage/test-utils';
type MockApiFactorySymbol,
type MockWithApiFactory,
attachMockApiFactory,
} from './MockWithApiFactory';
export {
TestApiProvider,
type TestApiProviderProps,
type TestApiPair,
type TestApiPairs,
} from './TestApiProvider';
export { MockAnalyticsApi } from './AnalyticsApi/MockAnalyticsApi';
/**
* Mock API classes are exported as types only to prevent direct instantiation.
* Always use the `mockApis` namespace to create mock instances (e.g., `mockApis.alert()`).
*/
/**
* @public
*/
export type { MockAlertApi } from './AlertApi';
/**
* @public
*/
export type { MockAnalyticsApi } from './AnalyticsApi';
/**
* @public
*/
export type { MockConfigApi } from './ConfigApi';
/**
* @public
*/
export type {
MockErrorApi,
MockErrorApiOptions,
ErrorWithContext,
} from './ErrorApi';
/**
* @public
*/
export type { MockFetchApi, MockFetchApiOptions } from './FetchApi';
/**
* @public
*/
export type {
MockFeatureFlagsApi,
MockFeatureFlagsApiOptions,
} from './FeatureFlagsApi';
/**
* @public
*/
export type { MockPermissionApi } from './PermissionApi';
/**
* @public
*/
export type { MockStorageApi } from './StorageApi';
/**
* @public
*/
export type { MockTranslationApi } from './TranslationApi';
@@ -0,0 +1,119 @@
/*
* Copyright 2025 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 { FeatureFlagState } from '@backstage/frontend-plugin-api';
import { mockApis } from './mockApis';
describe('mockApis', () => {
describe('alert', () => {
it('can create an instance', () => {
const alert = mockApis.alert();
alert.post({ message: 'test alert' });
expect(alert.getAlerts()).toHaveLength(1);
expect(alert.getAlerts()[0]).toMatchObject({ message: 'test alert' });
});
it('can clear alerts', () => {
const alert = mockApis.alert();
alert.post({ message: 'test' });
expect(alert.getAlerts()).toHaveLength(1);
alert.clearAlerts();
expect(alert.getAlerts()).toHaveLength(0);
});
it('can create a mock and make assertions on it', () => {
const alert = mockApis.alert.mock({
post: jest.fn(msg => {
expect(msg).toMatchObject({ message: 'test' });
}),
});
alert.post({ message: 'test' });
expect(alert.post).toHaveBeenCalledTimes(1);
});
});
describe('featureFlags', () => {
it('can create an instance', () => {
const featureFlags = mockApis.featureFlags({
initialStates: { 'test-flag': FeatureFlagState.Active },
});
expect(featureFlags.isActive('test-flag')).toBe(true);
expect(featureFlags.isActive('other-flag')).toBe(false);
});
it('can save and merge state', () => {
const featureFlags = mockApis.featureFlags({
initialStates: { 'flag-1': FeatureFlagState.Active },
});
featureFlags.save({
states: { 'flag-2': FeatureFlagState.Active },
merge: true,
});
expect(featureFlags.isActive('flag-1')).toBe(true);
expect(featureFlags.isActive('flag-2')).toBe(true);
});
it('can set and clear state using helper methods', () => {
const featureFlags = mockApis.featureFlags();
featureFlags.setState({ 'test-flag': FeatureFlagState.Active });
expect(featureFlags.getState()).toEqual({
'test-flag': FeatureFlagState.Active,
});
featureFlags.clearState();
expect(featureFlags.getState()).toEqual({});
});
it('can create a mock and make assertions on it', () => {
const featureFlags = mockApis.featureFlags.mock({
isActive: jest.fn(() => true),
});
expect(featureFlags.isActive('test')).toBe(true);
expect(featureFlags.isActive).toHaveBeenCalledTimes(1);
});
});
describe('re-exported APIs from test-utils', () => {
it('should have analytics', () => {
expect(mockApis.analytics).toBeDefined();
});
it('should have config', () => {
expect(mockApis.config).toBeDefined();
});
it('should have discovery', () => {
expect(mockApis.discovery).toBeDefined();
});
it('should have identity', () => {
expect(mockApis.identity).toBeDefined();
});
it('should have permission', () => {
expect(mockApis.permission).toBeDefined();
});
it('should have storage', () => {
expect(mockApis.storage).toBeDefined();
});
it('should have translation', () => {
expect(mockApis.translation).toBeDefined();
});
});
});
@@ -0,0 +1,465 @@
/*
* Copyright 2025 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 {
alertApiRef,
analyticsApiRef,
configApiRef,
discoveryApiRef,
errorApiRef,
fetchApiRef,
featureFlagsApiRef,
identityApiRef,
storageApiRef,
translationApiRef,
type AnalyticsApi,
type ConfigApi,
type DiscoveryApi,
type ErrorApi,
type FetchApi,
type IdentityApi,
type StorageApi,
type TranslationApi,
} from '@backstage/frontend-plugin-api';
import {
permissionApiRef,
type PermissionApi,
} from '@backstage/plugin-permission-react';
import { JsonObject } from '@backstage/types';
import {
AuthorizeResult,
EvaluatePermissionRequest,
} from '@backstage/plugin-permission-common';
import { MockAlertApi } from './AlertApi';
import {
MockFeatureFlagsApi,
MockFeatureFlagsApiOptions,
} from './FeatureFlagsApi';
import { MockAnalyticsApi } from './AnalyticsApi';
import { MockConfigApi } from './ConfigApi';
import { MockErrorApi, MockErrorApiOptions } from './ErrorApi';
import { MockFetchApi, MockFetchApiOptions } from './FetchApi';
import { MockStorageApi } from './StorageApi';
import { MockPermissionApi } from './PermissionApi';
import { MockTranslationApi } from './TranslationApi';
import {
mockWithApiFactory,
type MockWithApiFactory,
} from './MockWithApiFactory';
import { createApiMock } from './createApiMock';
/**
* Mock implementations of the core utility APIs, to be used in tests.
*
* @public
* @remarks
*
* There are some variations among the APIs depending on what needs tests
* might have, but overall there are two main usage patterns:
*
* 1: Creating an actual fake API instance, often with a simplified version
* of functionality, by calling the mock API itself as a function.
*
* ```ts
* // The function often accepts parameters that control its behavior
* const foo = mockApis.foo();
* ```
*
* 2: Creating a mock API, where all methods are replaced with jest mocks, by
* calling the API's `mock` function.
*
* ```ts
* // You can optionally supply a subset of its methods to implement
* const foo = mockApis.foo.mock({
* someMethod: () => 'mocked result',
* });
* // After exercising your test, you can make assertions on the mock:
* expect(foo.someMethod).toHaveBeenCalledTimes(2);
* expect(foo.otherMethod).toHaveBeenCalledWith(testData);
* ```
*/
export namespace mockApis {
/**
* Fake implementation of {@link @backstage/frontend-plugin-api#AlertApi}.
*
* @public
* @example
*
* ```tsx
* const alertApi = mockApis.alert();
* alertApi.post({ message: 'Test alert' });
* expect(alertApi.getAlerts()).toHaveLength(1);
* ```
*/
export function alert(): MockWithApiFactory<MockAlertApi> {
const instance = new MockAlertApi();
return mockWithApiFactory(
alertApiRef,
instance,
) as MockWithApiFactory<MockAlertApi>;
}
/**
* Mock helpers for {@link @backstage/frontend-plugin-api#AlertApi}.
*
* @see {@link @backstage/frontend-plugin-api#mockApis.alert}
* @public
*/
export namespace alert {
/**
* Creates a mock implementation of
* {@link @backstage/frontend-plugin-api#AlertApi}. All methods are
* replaced with jest mock functions, and you can optionally pass in a
* subset of methods with an explicit implementation.
*
* @public
*/
export const mock = createApiMock(alertApiRef, () => ({
post: jest.fn(),
alert$: jest.fn(),
}));
}
/**
* Fake implementation of {@link @backstage/frontend-plugin-api#FeatureFlagsApi}.
*
* @public
* @example
*
* ```tsx
* const featureFlagsApi = mockApis.featureFlags({
* initialStates: { 'my-feature': FeatureFlagState.Active },
* });
* expect(featureFlagsApi.isActive('my-feature')).toBe(true);
* ```
*/
export function featureFlags(
options?: MockFeatureFlagsApiOptions,
): MockWithApiFactory<MockFeatureFlagsApi> {
const instance = new MockFeatureFlagsApi(options);
return mockWithApiFactory(
featureFlagsApiRef,
instance,
) as MockWithApiFactory<MockFeatureFlagsApi>;
}
/**
* Mock helpers for {@link @backstage/frontend-plugin-api#FeatureFlagsApi}.
*
* @see {@link @backstage/frontend-plugin-api#mockApis.featureFlags}
* @public
*/
export namespace featureFlags {
/**
* Creates a mock implementation of
* {@link @backstage/frontend-plugin-api#FeatureFlagsApi}. All methods are
* replaced with jest mock functions, and you can optionally pass in a
* subset of methods with an explicit implementation.
*
* @public
*/
export const mock = createApiMock(featureFlagsApiRef, () => ({
registerFlag: jest.fn(),
getRegisteredFlags: jest.fn(),
isActive: jest.fn(),
save: jest.fn(),
}));
}
/**
* Fake implementation of {@link @backstage/core-plugin-api#AnalyticsApi}.
*
* @public
*/
export function analytics(): MockAnalyticsApi &
MockWithApiFactory<AnalyticsApi> {
const instance = new MockAnalyticsApi();
return mockWithApiFactory(analyticsApiRef, instance) as MockAnalyticsApi &
MockWithApiFactory<AnalyticsApi>;
}
/**
* Mock helpers for {@link @backstage/core-plugin-api#AnalyticsApi}.
*
* @public
*/
export namespace analytics {
export const mock = createApiMock(analyticsApiRef, () => ({
captureEvent: jest.fn(),
}));
}
/**
* Fake implementation of {@link @backstage/core-plugin-api/alpha#TranslationApi}.
* By default returns the default translation.
*
* @public
*/
export function translation(): MockTranslationApi &
MockWithApiFactory<TranslationApi> {
const instance = MockTranslationApi.create();
return mockWithApiFactory(
translationApiRef,
instance,
) as MockTranslationApi & MockWithApiFactory<TranslationApi>;
}
/**
* Mock helpers for {@link @backstage/core-plugin-api/alpha#TranslationApi}.
*
* @see {@link @backstage/frontend-plugin-api#mockApis.translation}
* @public
*/
export namespace translation {
/**
* Creates a mock of {@link @backstage/core-plugin-api/alpha#TranslationApi}.
*
* @public
*/
export const mock = createApiMock(translationApiRef, () => ({
getTranslation: jest.fn(),
translation$: jest.fn(),
}));
}
/**
* Fake implementation of {@link @backstage/core-plugin-api#ConfigApi}.
*
* @public
*/
export function config(options?: {
data?: JsonObject;
}): MockConfigApi & MockWithApiFactory<ConfigApi> {
const instance = new MockConfigApi({ data: options?.data ?? {} });
return mockWithApiFactory(configApiRef, instance) as MockConfigApi &
MockWithApiFactory<ConfigApi>;
}
/**
* Mock helpers for {@link @backstage/core-plugin-api#ConfigApi}.
*
* @public
*/
export namespace config {
export const mock = createApiMock(configApiRef, () => ({
has: jest.fn(),
keys: jest.fn(),
get: jest.fn(),
getOptional: jest.fn(),
getConfig: jest.fn(),
getOptionalConfig: jest.fn(),
getConfigArray: jest.fn(),
getOptionalConfigArray: jest.fn(),
getNumber: jest.fn(),
getOptionalNumber: jest.fn(),
getBoolean: jest.fn(),
getOptionalBoolean: jest.fn(),
getString: jest.fn(),
getOptionalString: jest.fn(),
getStringArray: jest.fn(),
getOptionalStringArray: jest.fn(),
}));
}
/**
* Fake implementation of {@link @backstage/core-plugin-api#DiscoveryApi}.
*
* @public
*/
export function discovery(options?: {
baseUrl?: string;
}): DiscoveryApi & MockWithApiFactory<DiscoveryApi> {
const baseUrl = options?.baseUrl ?? 'http://example.com';
const instance: DiscoveryApi = {
async getBaseUrl(pluginId: string) {
return `${baseUrl}/api/${pluginId}`;
},
};
return mockWithApiFactory(discoveryApiRef, instance) as DiscoveryApi &
MockWithApiFactory<DiscoveryApi>;
}
/**
* Mock helpers for {@link @backstage/core-plugin-api#DiscoveryApi}.
*
* @public
*/
export namespace discovery {
export const mock = createApiMock(discoveryApiRef, () => ({
getBaseUrl: jest.fn(),
}));
}
/**
* Fake implementation of {@link @backstage/core-plugin-api#IdentityApi}.
*
* @public
*/
export function identity(options?: {
userEntityRef?: string;
ownershipEntityRefs?: string[];
token?: string;
email?: string;
displayName?: string;
picture?: string;
}): MockWithApiFactory<IdentityApi> {
const {
userEntityRef = 'user:default/test',
ownershipEntityRefs = ['user:default/test'],
token,
email,
displayName,
picture,
} = options ?? {};
const instance: IdentityApi = {
async getBackstageIdentity() {
return { type: 'user', ownershipEntityRefs, userEntityRef };
},
async getCredentials() {
return { token };
},
async getProfileInfo() {
return { email, displayName, picture };
},
async signOut() {},
};
return mockWithApiFactory(identityApiRef, instance) as IdentityApi &
MockWithApiFactory<IdentityApi>;
}
/**
* Mock helpers for {@link @backstage/core-plugin-api#IdentityApi}.
*
* @public
*/
export namespace identity {
export const mock = createApiMock(identityApiRef, () => ({
getBackstageIdentity: jest.fn(),
getCredentials: jest.fn(),
getProfileInfo: jest.fn(),
signOut: jest.fn(),
}));
}
/**
* Fake implementation of {@link @backstage/plugin-permission-react#PermissionApi}.
*
* @public
*/
export function permission(options?: {
authorize?:
| AuthorizeResult.ALLOW
| AuthorizeResult.DENY
| ((
request: EvaluatePermissionRequest,
) => AuthorizeResult.ALLOW | AuthorizeResult.DENY);
}): MockPermissionApi & MockWithApiFactory<PermissionApi> {
const authorizeInput = options?.authorize;
const handler =
typeof authorizeInput === 'function'
? authorizeInput
: () => authorizeInput ?? AuthorizeResult.ALLOW;
const instance = new MockPermissionApi(handler);
return mockWithApiFactory(permissionApiRef, instance) as MockPermissionApi &
MockWithApiFactory<PermissionApi>;
}
/**
* Mock helpers for {@link @backstage/plugin-permission-react#PermissionApi}.
*
* @public
*/
export namespace permission {
export const mock = createApiMock(permissionApiRef, () => ({
authorize: jest.fn(),
}));
}
/**
* Fake implementation of {@link @backstage/core-plugin-api#StorageApi}.
*
* @public
*/
export function storage(options?: {
data?: JsonObject;
}): MockStorageApi & MockWithApiFactory<StorageApi> {
const instance = MockStorageApi.create(options?.data);
return mockWithApiFactory(storageApiRef, instance) as MockStorageApi &
MockWithApiFactory<StorageApi>;
}
/**
* Mock helpers for {@link @backstage/core-plugin-api#StorageApi}.
*
* @public
*/
export namespace storage {
export const mock = createApiMock(storageApiRef, () => ({
forBucket: jest.fn(),
snapshot: jest.fn(),
set: jest.fn(),
remove: jest.fn(),
observe$: jest.fn(),
}));
}
/**
* Fake implementation of {@link @backstage/core-plugin-api#ErrorApi}.
*
* @public
*/
export function error(
options?: MockErrorApiOptions,
): MockErrorApi & MockWithApiFactory<ErrorApi> {
const instance = new MockErrorApi(options);
return mockWithApiFactory(errorApiRef, instance) as MockErrorApi &
MockWithApiFactory<ErrorApi>;
}
/**
* Mock helpers for {@link @backstage/core-plugin-api#ErrorApi}.
*
* @public
*/
export namespace error {
export const mock = createApiMock(errorApiRef, () => ({
post: jest.fn(),
error$: jest.fn(),
}));
}
/**
* Fake implementation of {@link @backstage/core-plugin-api#FetchApi}.
*
* @public
*/
export function fetch(
options?: MockFetchApiOptions,
): MockFetchApi & MockWithApiFactory<FetchApi> {
const instance = new MockFetchApi(options);
return mockWithApiFactory(fetchApiRef, instance) as MockFetchApi &
MockWithApiFactory<FetchApi>;
}
/**
* Mock helpers for {@link @backstage/core-plugin-api#FetchApi}.
*
* @public
*/
export namespace fetch {
export const mock = createApiMock(fetchApiRef, () => ({
fetch: jest.fn(),
}));
}
}
@@ -173,11 +173,11 @@ describe('createExtensionTester', () => {
});
const tester = createExtensionTester(extension, {
apis: [[analyticsApiRef, analyticsApiMock]],
apis: [[analyticsApiRef, analyticsApiMock] as const],
});
renderInTestApp(tester.reactElement(), {
apis: [[analyticsApiRef, analyticsApiMock]],
apis: [[analyticsApiRef, analyticsApiMock] as const],
});
expect(screen.getByText('Test')).toBeInTheDocument();
@@ -38,7 +38,7 @@ import { readAppExtensionsConfig } from '../../../frontend-app-api/src/tree/read
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { createErrorCollector } from '../../../frontend-app-api/src/wiring/createErrorCollector';
import { OpaqueExtensionDefinition } from '@internal/frontend';
import { TestApiRegistry, type TestApiPairs } from '../utils';
import { resolveTestApiEntries, TestApiPairs } from '../apis/TestApiProvider';
/**
* Represents a snapshot of an extension in the app tree.
@@ -96,7 +96,7 @@ export class ExtensionTester<UOutput extends ExtensionDataRef> {
/** @internal */
static forSubject<
T extends ExtensionDefinitionParameters,
TApiPairs extends any[],
const TApiPairs extends any[],
>(
subject: ExtensionDefinition<T>,
options?: {
@@ -281,9 +281,7 @@ export class ExtensionTester<UOutput extends ExtensionDataRef> {
collector,
);
const apiHolder = this.#apis
? TestApiRegistry.from(...this.#apis)
: TestApiRegistry.from();
const apiHolder = resolveTestApiEntries(this.#apis ?? []);
instantiateAppNodeTree(tree.root, apiHolder, collector);
@@ -16,11 +16,8 @@
import { 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 { mockApis, TestApiProvider } from '@backstage/frontend-test-utils';
import { useAnalytics } from '@backstage/frontend-plugin-api';
import { Routes, Route } from 'react-router-dom';
import { renderInTestApp } from './renderInTestApp';
@@ -47,10 +44,10 @@ describe('renderInTestApp', () => {
);
};
const analyticsApiMock = new MockAnalyticsApi();
const analyticsApiMock = mockApis.analytics();
renderInTestApp(
<TestApiProvider apis={[[analyticsApiRef, analyticsApiMock]]}>
<TestApiProvider apis={[analyticsApiMock]}>
<IndexPage />
</TestApiProvider>,
);
@@ -94,10 +91,10 @@ describe('renderInTestApp', () => {
);
};
const analyticsApiMock = new MockAnalyticsApi();
const analyticsApiMock = mockApis.analytics();
renderInTestApp(<IndexPage />, {
apis: [[analyticsApiRef, analyticsApiMock]],
apis: [analyticsApiMock],
});
fireEvent.click(screen.getByRole('button', { name: 'Click me' }));
@@ -32,12 +32,14 @@ import {
FrontendFeature,
createFrontendModule,
createApiFactory,
type ApiRef,
} from '@backstage/frontend-plugin-api';
import { RouterBlueprint } from '@backstage/plugin-app-react';
import appPlugin from '@backstage/plugin-app';
import { type TestApiPairs } from '../utils';
import { getMockApiFactory } from '../apis/MockWithApiFactory';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import type { CreateSpecializedAppInternalOptions } from '../../../frontend-app-api/src/wiring/createSpecializedApp';
import { TestApiPairs } from '../apis/TestApiProvider';
const DEFAULT_MOCK_CONFIG = {
app: { baseUrl: 'http://localhost:3000' },
@@ -88,11 +90,10 @@ export type TestAppOptions<TApiPairs extends any[] = any[]> = {
*
* @example
* ```ts
* import { identityApiRef } from '@backstage/frontend-plugin-api';
* import { mockApis } from '@backstage/frontend-test-utils';
*
* renderInTestApp(<MyComponent />, {
* apis: [[identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })]],
* apis: [mockApis.identity({ userEntityRef: 'user:default/guest' })],
* })
* ```
*/
@@ -163,7 +164,7 @@ const appPluginOverride = appPlugin.withOverrides({
* @public
* Renders the given element in a test app, for use in unit tests.
*/
export function renderInTestApp<TApiPairs extends any[] = any[]>(
export function renderInTestApp<const TApiPairs extends any[] = any[]>(
element: JSX.Element,
options?: TestAppOptions<TApiPairs>,
): RenderResult {
@@ -241,9 +242,14 @@ export function renderInTestApp<TApiPairs extends any[] = any[]>(
},
]),
__internal: options?.apis && {
apiFactoryOverrides: options.apis.map(([apiRef, implementation]) =>
createApiFactory(apiRef, implementation),
),
apiFactoryOverrides: options.apis.map(entry => {
const mockFactory = getMockApiFactory(entry);
if (mockFactory) {
return mockFactory;
}
const [apiRef, implementation] = entry as readonly [ApiRef<any>, any];
return createApiFactory(apiRef, implementation);
}),
},
} as CreateSpecializedAppInternalOptions);
@@ -25,16 +25,18 @@ import {
ExtensionDefinition,
FrontendFeature,
RouteRef,
type ApiRef,
} from '@backstage/frontend-plugin-api';
import { render } from '@testing-library/react';
import { render, type RenderResult } from '@testing-library/react';
import appPlugin from '@backstage/plugin-app';
import { JsonObject } from '@backstage/types';
import { ConfigReader } from '@backstage/config';
import { MemoryRouter } from 'react-router-dom';
import { RouterBlueprint } from '@backstage/plugin-app-react';
import { type TestApiPairs } from '../utils';
import { getMockApiFactory } from '../apis/MockWithApiFactory';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import type { CreateSpecializedAppInternalOptions } from '../../../frontend-app-api/src/wiring/createSpecializedApp';
import { TestApiPairs } from '../apis/TestApiProvider';
const DEFAULT_MOCK_CONFIG = {
app: { baseUrl: 'http://localhost:3000' },
@@ -89,11 +91,10 @@ export type RenderTestAppOptions<TApiPairs extends any[] = any[]> = {
*
* @example
* ```ts
* import { identityApiRef } from '@backstage/frontend-plugin-api';
* import { mockApis } from '@backstage/frontend-test-utils';
*
* renderTestApp({
* apis: [[identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })]],
* apis: [mockApis.identity({ userEntityRef: 'user:default/guest' })],
* extensions: [...],
* })
* ```
@@ -115,12 +116,12 @@ const appPluginOverride = appPlugin.withOverrides({
*
* @public
*/
export function renderTestApp<TApiPairs extends any[] = any[]>(
options: RenderTestAppOptions<TApiPairs>,
) {
const extensions = [...(options.extensions ?? [])];
export function renderTestApp<const TApiPairs extends any[] = any[]>(
options?: RenderTestAppOptions<TApiPairs>,
): RenderResult {
const extensions = [...(options?.extensions ?? [])];
if (options.mountedRoutes) {
if (options?.mountedRoutes) {
for (const [path, routeRef] of Object.entries(options.mountedRoutes)) {
extensions.push(
createExtension({
@@ -150,7 +151,7 @@ export function renderTestApp<TApiPairs extends any[] = any[]>(
params: {
component: ({ children }) => (
<MemoryRouter
initialEntries={options.initialRouteEntries}
initialEntries={options?.initialRouteEntries}
future={{
v7_relativeSplatPath: true,
v7_startTransition: true,
@@ -170,7 +171,7 @@ export function renderTestApp<TApiPairs extends any[] = any[]>(
appPluginOverride,
];
if (options.features) {
if (options?.features) {
features.push(...options.features);
}
@@ -183,9 +184,14 @@ export function renderTestApp<TApiPairs extends any[] = any[]>(
},
]),
__internal: options?.apis && {
apiFactoryOverrides: options.apis.map(([apiRef, implementation]) =>
createApiFactory(apiRef, implementation),
),
apiFactoryOverrides: options.apis.map(entry => {
const mockFactory = getMockApiFactory(entry);
if (mockFactory) {
return mockFactory;
}
const [apiRef, implementation] = entry as readonly [ApiRef<any>, any];
return createApiFactory(apiRef, implementation);
}),
},
} as CreateSpecializedAppInternalOptions);
@@ -22,10 +22,6 @@
export * from './apis';
export * from './app';
export * from './utils';
// Explicit export to satisfy API Extractor
export type { TestApiPairs } from './utils';
export { withLogCollector } from '@backstage/test-utils';
@@ -1,144 +0,0 @@
/*
* Copyright 2020 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 { ReactNode } from 'react';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { ApiProvider } from '../../../core-app-api/src/apis/system';
import { ApiHolder, ApiRef } from '@backstage/frontend-plugin-api';
/**
* Helper type for representing an API reference paired with a partial implementation.
* @public
*/
export type TestApiProviderPropsApiPair<TApi> = TApi extends infer TImpl
? readonly [ApiRef<TApi>, Partial<TImpl>]
: never;
/**
* Helper type for representing an array of API reference pairs.
* @public
*/
export type TestApiProviderPropsApiPairs<TApiPairs> = {
[TIndex in keyof TApiPairs]: TestApiProviderPropsApiPair<TApiPairs[TIndex]>;
};
/**
* Shorter alias for TestApiProviderPropsApiPairs for use in function signatures.
* @public
*/
export type TestApiPairs<TApiPairs> = TestApiProviderPropsApiPairs<TApiPairs>;
/**
* Properties for the {@link TestApiProvider} component.
*
* @public
*/
export type TestApiProviderProps<TApiPairs extends any[]> = {
apis: readonly [...TestApiProviderPropsApiPairs<TApiPairs>];
children: ReactNode;
};
/**
* The `TestApiRegistry` is an {@link @backstage/frontend-plugin-api#ApiHolder} implementation
* that is particularly well suited for development and test environments such as
* unit tests, storybooks, and isolated plugin development setups.
*
* @remarks
*
* For most test scenarios, prefer using the `apis` option in `renderInTestApp` or
* `createExtensionTester` instead of creating a registry directly.
*
* @public
*/
export class TestApiRegistry implements ApiHolder {
/**
* Creates a new {@link TestApiRegistry} with a list of API implementation pairs.
*
* Similar to the {@link TestApiProvider}, there is no need to provide a full
* implementation of each API, it's enough to implement the methods that are tested.
*
* @example
* ```ts
* import { identityApiRef } from '@backstage/frontend-plugin-api';
* import { mockApis } from '@backstage/frontend-test-utils';
*
* const apis = TestApiRegistry.from(
* [identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })],
* );
* ```
*
* @public
* @param apis - A list of pairs mapping an ApiRef to its respective implementation.
*/
static from<TApiPairs extends any[]>(
...apis: readonly [...TestApiProviderPropsApiPairs<TApiPairs>]
) {
return new TestApiRegistry(
new Map(apis.map(([api, impl]) => [api.id, impl])),
);
}
private constructor(private readonly apis: Map<string, unknown>) {}
/**
* Returns an implementation of the API.
*
* @public
*/
get<T>(api: ApiRef<T>): T | undefined {
return this.apis.get(api.id) as T | undefined;
}
}
/**
* The `TestApiProvider` is a Utility API context provider for standalone rendering
* scenarios where you're not using `renderInTestApp` or other test utilities.
*
* It lets you provide any number of API implementations, without necessarily
* having to fully implement each of the APIs.
*
* @remarks
*
* For most test scenarios, prefer using the `apis` option in `renderInTestApp` or
* `createExtensionTester` instead of wrapping components with `TestApiProvider`.
*
* @example
* ```tsx
* import { render } from '\@testing-library/react';
* import { identityApiRef } from '\@backstage/frontend-plugin-api';
* import { TestApiProvider, mockApis } from '\@backstage/frontend-test-utils';
*
* render(
* <TestApiProvider
* apis={[[identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })]]}
* >
* <MyComponent />
* </TestApiProvider>
* );
* ```
*
* @public
*/
export const TestApiProvider = <T extends any[]>(
props: TestApiProviderProps<T>,
) => {
return (
<ApiProvider
apis={TestApiRegistry.from(...props.apis)}
children={props.children}
/>
);
};
@@ -89,7 +89,21 @@ function findAllDeps(declSrc: string) {
.filter(n => !n.startsWith('.'))
// We allow references to these without an explicit dependency.
.filter(n => !['node', 'react'].includes(n));
return Array.from(new Set([...importedDeps, ...referencedDeps]));
// Detect ambient global type namespaces (e.g. jest.Mocked, jest.MockInstance)
// that are used in type positions but don't appear as explicit imports.
// Strip comments first to avoid false positives from JSDoc examples.
const strippedSrc = declSrc
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '');
const ambientDeps: string[] = [];
if (/\bjest\.\w/.test(strippedSrc)) {
ambientDeps.push('jest');
}
return Array.from(
new Set([...importedDeps, ...referencedDeps, ...ambientDeps]),
);
}
/**
+4
View File
@@ -74,12 +74,16 @@
},
"peerDependencies": {
"@testing-library/react": "^16.0.0",
"@types/jest": "*",
"@types/react": "^17.0.0 || ^18.0.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0",
"react-router-dom": "^6.30.2"
},
"peerDependenciesMeta": {
"@types/jest": {
"optional": true
},
"@types/react": {
"optional": true
}
+3 -4
View File
@@ -21,7 +21,6 @@ import {
createTestEntityPage,
catalogApiMock,
} from '@backstage/plugin-catalog-react/testUtils';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import catalogGraphPlugin from './alpha';
import { catalogGraphRouteRef } from './routes';
import { catalogGraphApiRef, DefaultCatalogGraphApi } from './api';
@@ -58,7 +57,7 @@ describe('catalog-graph alpha plugin', () => {
'/catalog-graph': catalogGraphRouteRef,
},
apis: [
[catalogApiRef, catalogApiMock({ entities: [entity] })],
catalogApiMock({ entities: [entity] }),
[catalogGraphApiRef, new DefaultCatalogGraphApi()],
],
});
@@ -95,7 +94,7 @@ describe('catalog-graph alpha plugin', () => {
'/catalog-graph': catalogGraphRouteRef,
},
apis: [
[catalogApiRef, catalogApiMock({ entities: [entity] })],
catalogApiMock({ entities: [entity] }),
[catalogGraphApiRef, new DefaultCatalogGraphApi()],
],
});
@@ -131,7 +130,7 @@ describe('catalog-graph alpha plugin', () => {
'/catalog-graph': catalogGraphRouteRef,
},
apis: [
[catalogApiRef, catalogApiMock({ entities: [entity] })],
catalogApiMock({ entities: [entity] }),
[catalogGraphApiRef, new DefaultCatalogGraphApi()],
],
});
@@ -14,19 +14,12 @@
* limitations under the License.
*/
import { ApiProvider } from '@backstage/core-app-api';
import { AlertApi, alertApiRef, errorApiRef } from '@backstage/core-plugin-api';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import {
mockApis,
renderWithEffects,
TestApiRegistry,
} from '@backstage/test-utils';
import { renderWithEffects } from '@backstage/test-utils';
import { mockApis, TestApiProvider } from '@backstage/frontend-test-utils';
import { waitFor, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SelectedKindsFilter } from './SelectedKindsFilter';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
import { translationApiRef } from '@backstage/core-plugin-api/alpha';
const catalogApi = catalogApiMock.mock({
getEntityFacets: jest.fn().mockResolvedValue({
@@ -40,28 +33,28 @@ const catalogApi = catalogApiMock.mock({
},
}),
});
const apis = TestApiRegistry.from(
[catalogApiRef, catalogApi],
[alertApiRef, {} as AlertApi],
[translationApiRef, mockApis.translation()],
[errorApiRef, { post: jest.fn() }],
);
const apis = [
mockApis.alert(),
mockApis.translation(),
mockApis.error(),
catalogApi,
] as const;
describe('<SelectedKindsFilter/>', () => {
it('should not explode while loading', async () => {
const { baseElement } = await renderWithEffects(
<ApiProvider apis={apis}>
<TestApiProvider apis={apis}>
<SelectedKindsFilter value={['api', 'component']} onChange={() => {}} />
</ApiProvider>,
</TestApiProvider>,
);
expect(baseElement).toBeInTheDocument();
});
it('should render current value', async () => {
await renderWithEffects(
<ApiProvider apis={apis}>
<TestApiProvider apis={apis}>
<SelectedKindsFilter value={['api', 'component']} onChange={() => {}} />
</ApiProvider>,
</TestApiProvider>,
);
expect(screen.getByText('API')).toBeInTheDocument();
@@ -71,9 +64,9 @@ describe('<SelectedKindsFilter/>', () => {
it('should select value', async () => {
const onChange = jest.fn();
await renderWithEffects(
<ApiProvider apis={apis}>
<TestApiProvider apis={apis}>
<SelectedKindsFilter value={['api', 'component']} onChange={onChange} />
</ApiProvider>,
</TestApiProvider>,
);
await userEvent.click(screen.getByLabelText('Open'));
@@ -89,12 +82,12 @@ describe('<SelectedKindsFilter/>', () => {
it('should return undefined if all values are selected', async () => {
const onChange = jest.fn();
await renderWithEffects(
<ApiProvider apis={apis}>
<TestApiProvider apis={apis}>
<SelectedKindsFilter
value={['api', 'component', 'system', 'domain']}
onChange={onChange}
/>
</ApiProvider>,
</TestApiProvider>,
);
await userEvent.click(screen.getByLabelText('Open'));
@@ -112,9 +105,9 @@ describe('<SelectedKindsFilter/>', () => {
it('should return all values when cleared', async () => {
const onChange = jest.fn();
await renderWithEffects(
<ApiProvider apis={apis}>
<TestApiProvider apis={apis}>
<SelectedKindsFilter value={[]} onChange={onChange} />
</ApiProvider>,
</TestApiProvider>,
);
await userEvent.click(screen.getByRole('combobox'));
+5 -1
View File
@@ -68,7 +68,6 @@
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/frontend-test-utils": "workspace:^",
"@backstage/integration-react": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
@@ -91,6 +90,7 @@
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/core-app-api": "workspace:^",
"@backstage/frontend-test-utils": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-scaffolder-common": "workspace:^",
"@backstage/test-utils": "workspace:^",
@@ -107,12 +107,16 @@
"zod": "^3.25.76"
},
"peerDependencies": {
"@backstage/frontend-test-utils": "workspace:^",
"@types/react": "^17.0.0 || ^18.0.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0",
"react-router-dom": "^6.30.2"
},
"peerDependenciesMeta": {
"@backstage/frontend-test-utils": {
"optional": true
},
"@types/react": {
"optional": true
}
@@ -11,10 +11,13 @@ import { Entity } from '@backstage/catalog-model';
import { EntityListContextProps } from '@backstage/plugin-catalog-react';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react/jsx-runtime';
import { MockWithApiFactory } from '@backstage/frontend-test-utils';
import { PropsWithChildren } from 'react';
// @public
export function catalogApiMock(options?: { entities?: Entity[] }): CatalogApi;
export function catalogApiMock(options?: {
entities?: Entity[];
}): MockWithApiFactory<CatalogApi>;
// @public
export namespace catalogApiMock {
@@ -19,6 +19,7 @@ import { Entity } from '@backstage/catalog-model';
import { ApiProvider } from '@backstage/core-app-api';
import { alertApiRef } from '@backstage/core-plugin-api';
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
import { mockApis } from '@backstage/frontend-test-utils';
import { fireEvent, waitFor, screen, within } from '@testing-library/react';
import { capitalize } from 'lodash';
import { catalogApiRef } from '../../api';
@@ -65,12 +66,7 @@ describe('<EntityKindPicker/>', () => {
} as GetEntityFacetsResponse),
},
],
[
alertApiRef,
{
post: jest.fn(),
},
],
[alertApiRef, mockApis.alert()],
);
it('renders available entity kinds', async () => {
@@ -23,6 +23,7 @@ import { EntityKindFilter, EntityTypeFilter } from '../../filters';
import { alertApiRef } from '@backstage/core-plugin-api';
import { ApiProvider } from '@backstage/core-app-api';
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
import { mockApis } from '@backstage/frontend-test-utils';
import { GetEntityFacetsResponse } from '@backstage/catalog-client';
const entities: Entity[] = [
@@ -72,12 +73,7 @@ const apis = TestApiRegistry.from(
} as GetEntityFacetsResponse),
},
],
[
alertApiRef,
{
post: jest.fn(),
},
],
[alertApiRef, mockApis.alert()],
);
describe('<EntityTypePicker/>', () => {
@@ -25,22 +25,12 @@ import { catalogApiRef } from '../../api';
import { entityRouteRef } from '../../routes';
import { screen, waitFor } from '@testing-library/react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { mockApis } from '@backstage/frontend-test-utils';
import * as state from './useUnregisterEntityDialogState';
import { AlertApi, alertApiRef } from '@backstage/core-plugin-api';
import { alertApiRef } from '@backstage/core-plugin-api';
describe('UnregisterEntityDialog', () => {
const alertApi: AlertApi = {
post() {
return undefined;
},
alert$() {
throw new Error('not implemented');
},
};
beforeEach(() => {
jest.spyOn(alertApi, 'post').mockImplementation(() => {});
});
const alertApi = mockApis.alert();
const entity = {
apiVersion: 'backstage.io/v1alpha1',
@@ -340,11 +330,13 @@ describe('UnregisterEntityDialog', () => {
await waitFor(() => {
expect(deleteEntity).toHaveBeenCalled();
expect(onConfirm).toHaveBeenCalled();
expect(alertApi.post).toHaveBeenCalledWith({
message: 'Removed entity n',
severity: 'success',
display: 'transient',
});
expect(alertApi.getAlerts()).toContainEqual(
expect.objectContaining({
message: 'Removed entity n',
severity: 'success',
display: 'transient',
}),
);
});
});
});
@@ -25,7 +25,8 @@ import {
} from '@backstage/core-plugin-api';
import { translationApiRef } from '@backstage/core-plugin-api/alpha';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
import { mockApis, TestApiProvider } from '@backstage/test-utils';
import { TestApiProvider } from '@backstage/test-utils';
import { mockApis } from '@backstage/frontend-test-utils';
import { useMountEffect } from '@react-hookz/web';
import { act, renderHook, waitFor } from '@testing-library/react';
import qs from 'qs';
@@ -111,7 +112,7 @@ const createWrapper =
[identityApiRef, mockIdentityApi],
[storageApiRef, mockApis.storage()],
[starredEntitiesApiRef, new MockStarredEntitiesApi()],
[alertApiRef, { post: jest.fn() }],
[alertApiRef, mockApis.alert()],
[translationApiRef, mockApis.translation()],
[errorApiRef, { error$: jest.fn(), post: jest.fn() }],
]}
@@ -14,42 +14,16 @@
* limitations under the License.
*/
import {
ApiFactory,
ApiRef,
createApiFactory,
} from '@backstage/frontend-plugin-api';
import { ApiFactory, createApiFactory } from '@backstage/frontend-plugin-api';
import { InMemoryCatalogClient } from '@backstage/catalog-client/testUtils';
import { Entity } from '@backstage/catalog-model';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { CatalogApi } from '@backstage/catalog-client';
import { ApiMock } from '@backstage/frontend-test-utils';
/** @internal */
function simpleMock<TApi>(
ref: ApiRef<TApi>,
mockFactory: () => jest.Mocked<TApi>,
): (partialImpl?: Partial<TApi>) => ApiMock<TApi> {
return partialImpl => {
const mock = mockFactory();
if (partialImpl) {
for (const [key, impl] of Object.entries(partialImpl)) {
if (typeof impl === 'function') {
(mock as any)[key].mockImplementation(impl);
} else {
(mock as any)[key] = impl;
}
}
}
return Object.assign(mock, {
factory: createApiFactory({
api: ref,
deps: {},
factory: () => mock,
}),
}) as ApiMock<TApi>;
};
}
import {
createApiMock,
attachMockApiFactory,
type MockWithApiFactory,
} from '@backstage/frontend-test-utils';
/**
* Creates a fake catalog client that handles entities in memory storage. Note
@@ -58,8 +32,11 @@ function simpleMock<TApi>(
*
* @public
*/
export function catalogApiMock(options?: { entities?: Entity[] }): CatalogApi {
return new InMemoryCatalogClient(options);
export function catalogApiMock(options?: {
entities?: Entity[];
}): MockWithApiFactory<CatalogApi> {
const instance = new InMemoryCatalogClient(options);
return attachMockApiFactory(catalogApiRef, instance);
}
/**
@@ -85,7 +62,7 @@ export namespace catalogApiMock {
* Creates a catalog client whose methods are mock functions, possibly with
* some of them overloaded by the caller.
*/
export const mock = simpleMock(catalogApiRef, () => ({
export const mock = createApiMock(catalogApiRef, () => ({
getEntities: jest.fn(),
getEntitiesByRefs: jest.fn(),
queryEntities: jest.fn(),
@@ -23,7 +23,6 @@ import {
EntityCardBlueprint,
EntityContentBlueprint,
} from '../alpha/blueprints';
import { catalogApiRef } from '../api';
const mockEntity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
@@ -386,7 +385,7 @@ describe('createTestEntityPage', () => {
createTestEntityPage({ entity: mockEntity }),
catalogApiCard,
],
apis: [[catalogApiRef, catalogApiMock({ entities: customEntities })]],
apis: [catalogApiMock({ entities: customEntities })],
});
// Should use the user-provided catalog API with custom entities, not mockEntity
@@ -32,11 +32,11 @@ import {
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
import { permissionApiRef } from '@backstage/plugin-permission-react';
import {
mockApis,
renderInTestApp,
TestApiProvider,
TestApiRegistry,
} from '@backstage/test-utils';
import { mockApis } from '@backstage/frontend-test-utils';
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import { EntityLayout } from './EntityLayout';
import { rootRouteRef, unregisterRedirectRouteRef } from '../../routes';
@@ -52,7 +52,7 @@ describe('EntityLayout', () => {
const apis = TestApiRegistry.from(
[catalogApiRef, catalogApiMock()],
[alertApiRef, {} as AlertApi],
[alertApiRef, mockApis.alert()],
[starredEntitiesApiRef, new MockStarredEntitiesApi()],
[permissionApiRef, mockApis.permission()],
);
@@ -22,13 +22,11 @@ import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { screen, waitFor } from '@testing-library/react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { AlertApi, alertApiRef } from '@backstage/core-plugin-api';
import { alertApiRef } from '@backstage/core-plugin-api';
import { mockApis } from '@backstage/frontend-test-utils';
describe('DeleteEntityDialog', () => {
const alertApi: jest.Mocked<AlertApi> = {
post: jest.fn(),
alert$: jest.fn(),
};
const alertApi = mockApis.alert();
const catalogClient = catalogApiMock.mock();
@@ -123,7 +121,9 @@ describe('DeleteEntityDialog', () => {
await waitFor(() => {
expect(catalogClient.removeEntityByUid).toHaveBeenCalledWith('123');
expect(alertApi.post).toHaveBeenCalledWith({ message: 'no no no' });
expect(alertApi.getAlerts()).toContainEqual(
expect.objectContaining({ message: 'no no no' }),
);
});
});
});
@@ -23,15 +23,12 @@ import { render, screen } from '@testing-library/react';
import { ReactNode, useEffect } from 'react';
import { isKind } from './conditions';
import { EntitySwitch } from './EntitySwitch';
import { featureFlagsApiRef } from '@backstage/core-plugin-api';
import { LocalStorageFeatureFlags } from '@backstage/core-app-api';
import { TestApiProvider } from '@backstage/test-utils';
import { TestApiProvider, mockApis } from '@backstage/frontend-test-utils';
const mockFeatureFlagsApi = mockApis.featureFlags.mock();
const mockFeatureFlagsApi = new LocalStorageFeatureFlags();
const Wrapper = ({ children }: { children?: ReactNode }) => (
<TestApiProvider apis={[[featureFlagsApiRef, mockFeatureFlagsApi]]}>
{children}
</TestApiProvider>
<TestApiProvider apis={[mockFeatureFlagsApi]}>{children}</TestApiProvider>
);
describe('EntitySwitch', () => {
+2 -8
View File
@@ -21,7 +21,6 @@ import {
createTestEntityPage,
catalogApiMock,
} from '@backstage/plugin-catalog-react/testUtils';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import orgPlugin from './alpha';
const EntityGroupProfileCard = orgPlugin.getExtension(
@@ -230,12 +229,7 @@ describe('org plugin entity cards', () => {
createTestEntityPage({ entity: groupEntity }),
EntityMembersListCard,
],
apis: [
[
catalogApiRef,
catalogApiMock({ entities: [groupEntity, memberUser] }),
],
],
apis: [catalogApiMock({ entities: [groupEntity, memberUser] })],
});
expect(await screen.findByText('Alice Smith')).toBeInTheDocument();
@@ -261,7 +255,7 @@ describe('org plugin entity cards', () => {
createTestEntityPage({ entity: groupEntity }),
EntityMembersListCard,
],
apis: [[catalogApiRef, catalogApiMock({ entities: [groupEntity] })]],
apis: [catalogApiMock({ entities: [groupEntity] })],
});
expect(
+1
View File
@@ -98,6 +98,7 @@
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/core-app-api": "workspace:^",
"@backstage/frontend-test-utils": "workspace:^",
"@backstage/plugin-catalog": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
@@ -17,6 +17,7 @@
import { useEntityTypeFilter } from '@backstage/plugin-catalog-react';
import { TemplateCategoryPicker } from './TemplateCategoryPicker';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { mockApis } from '@backstage/frontend-test-utils';
import { alertApiRef } from '@backstage/core-plugin-api';
import { fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@@ -26,10 +27,10 @@ jest.mock('@backstage/plugin-catalog-react', () => ({
}));
describe('TemplateCategoryPicker', () => {
const mockAlertApi = { post: jest.fn() };
const mockAlertApi = mockApis.alert();
beforeEach(() => {
mockAlertApi.post.mockClear();
mockAlertApi.clearAlerts();
});
it('should post the error to errorApi if an errors is returned', async () => {
@@ -37,13 +38,16 @@ describe('TemplateCategoryPicker', () => {
error: new Error('something broked'),
});
mockAlertApi.clearAlerts();
await renderInTestApp(
<TestApiProvider apis={[[alertApiRef, mockAlertApi]]}>
<TemplateCategoryPicker />
</TestApiProvider>,
);
expect(mockAlertApi.post).toHaveBeenCalledWith({
expect(mockAlertApi.getAlerts().length).toBeGreaterThanOrEqual(1);
expect(mockAlertApi.getAlerts()[0]).toMatchObject({
message: expect.stringContaining('something broked'),
severity: 'error',
});
@@ -18,11 +18,10 @@ import { renderHook } from '@testing-library/react';
import { useFilteredSchemaProperties } from './useFilteredSchemaProperties';
import { TemplateParameterSchema } from '../../types';
import { TestApiProvider } from '@backstage/test-utils';
import { mockApis } from '@backstage/frontend-test-utils';
import { featureFlagsApiRef } from '@backstage/core-plugin-api';
const mockFeatureFlagApi = {
isActive: jest.fn(),
};
const mockFeatureFlagApi = mockApis.featureFlags.mock();
describe('useFilteredSchemaProperties', () => {
it('should return the same manifest if no feature flag is set', () => {
@@ -15,9 +15,9 @@
*/
import { useTemplateSchema } from './useTemplateSchema';
import { renderHook } from '@testing-library/react';
import { TestApiProvider } from '@backstage/test-utils';
import { TestApiProvider } from '@backstage/frontend-test-utils';
import { mockApis } from '@backstage/frontend-test-utils';
import { PropsWithChildren } from 'react';
import { featureFlagsApiRef } from '@backstage/core-plugin-api';
import { TemplateParameterSchema } from '../../types';
describe('useTemplateSchema', () => {
@@ -49,11 +49,13 @@ describe('useTemplateSchema', () => {
],
};
const mockFeatureFlagsApi = mockApis.featureFlags.mock({
isActive: jest.fn(() => false),
});
const { result } = renderHook(() => useTemplateSchema(manifest), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider
apis={[[featureFlagsApiRef, { isActive: () => false }]]}
>
<TestApiProvider apis={[mockFeatureFlagsApi]}>
{children}
</TestApiProvider>
),
@@ -119,7 +121,9 @@ describe('useTemplateSchema', () => {
const { result } = renderHook(() => useTemplateSchema(manifest), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider
apis={[[featureFlagsApiRef, { isActive: () => false }]]}
apis={[
mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }),
]}
>
{children}
</TestApiProvider>
@@ -163,7 +167,9 @@ describe('useTemplateSchema', () => {
const { result } = renderHook(() => useTemplateSchema(manifest), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider
apis={[[featureFlagsApiRef, { isActive: () => true }]]}
apis={[
mockApis.featureFlags.mock({ isActive: jest.fn(() => true) }),
]}
>
{children}
</TestApiProvider>
@@ -214,7 +220,9 @@ describe('useTemplateSchema', () => {
const { result } = renderHook(() => useTemplateSchema(manifest), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider
apis={[[featureFlagsApiRef, { isActive: () => false }]]}
apis={[
mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }),
]}
>
{children}
</TestApiProvider>
@@ -252,7 +260,9 @@ describe('useTemplateSchema', () => {
const { result } = renderHook(() => useTemplateSchema(manifest), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider
apis={[[featureFlagsApiRef, { isActive: () => false }]]}
apis={[
mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }),
]}
>
{children}
</TestApiProvider>
@@ -356,7 +366,9 @@ describe('useTemplateSchema', () => {
const { result } = renderHook(() => useTemplateSchema(manifest), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
<TestApiProvider
apis={[[featureFlagsApiRef, { isActive: () => false }]]}
apis={[
mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }),
]}
>
{children}
</TestApiProvider>
+1
View File
@@ -108,6 +108,7 @@
"@backstage/cli": "workspace:^",
"@backstage/core-app-api": "workspace:^",
"@backstage/dev-utils": "workspace:^",
"@backstage/frontend-test-utils": "workspace:^",
"@backstage/plugin-catalog": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/plugin-techdocs": "workspace:^",
@@ -23,9 +23,10 @@ import {
EntityKindFilter,
} from '@backstage/plugin-catalog-react';
import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils';
import { AlertApi, alertApiRef } from '@backstage/core-plugin-api';
import { alertApiRef } from '@backstage/core-plugin-api';
import { ApiProvider } from '@backstage/core-app-api';
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
import { mockApis } from '@backstage/frontend-test-utils';
import { GetEntityFacetsResponse } from '@backstage/catalog-client';
const entities: Entity[] = [
@@ -75,12 +76,7 @@ const apis = TestApiRegistry.from(
} as GetEntityFacetsResponse),
},
],
[
alertApiRef,
{
post: jest.fn(),
} as unknown as AlertApi,
],
[alertApiRef, mockApis.alert()],
);
describe('<TemplateTypePicker/>', () => {
+25
View File
@@ -3207,6 +3207,11 @@ __metadata:
yn: "npm:^4.0.0"
zod: "npm:^3.25.76"
zod-to-json-schema: "npm:^3.25.1"
peerDependencies:
"@types/jest": "*"
peerDependenciesMeta:
"@types/jest":
optional: true
languageName: unknown
linkType: soft
@@ -3954,26 +3959,38 @@ __metadata:
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/core-app-api": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/frontend-app-api": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/plugin-app": "workspace:^"
"@backstage/plugin-app-react": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"
"@backstage/plugin-permission-react": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/types": "workspace:^"
"@backstage/version-bridge": "workspace:^"
"@testing-library/jest-dom": "npm:^6.0.0"
"@types/jest": "npm:*"
"@types/react": "npm:^18.0.0"
"@types/zen-observable": "npm:^0.8.0"
i18next: "npm:^22.4.15"
msw: "npm:^2.0.0"
react: "npm:^18.0.2"
react-dom: "npm:^18.0.2"
react-router-dom: "npm:^6.30.2"
zen-observable: "npm:^0.10.0"
zod: "npm:^3.25.76"
peerDependencies:
"@testing-library/react": ^16.0.0
"@types/jest": "*"
"@types/react": ^17.0.0 || ^18.0.0
react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0
react-router-dom: ^6.30.2
peerDependenciesMeta:
"@types/jest":
optional: true
"@types/react":
optional: true
languageName: unknown
@@ -5430,11 +5447,14 @@ __metadata:
zen-observable: "npm:^0.10.0"
zod: "npm:^3.25.76"
peerDependencies:
"@backstage/frontend-test-utils": "workspace:^"
"@types/react": ^17.0.0 || ^18.0.0
react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0
react-router-dom: ^6.30.2
peerDependenciesMeta:
"@backstage/frontend-test-utils":
optional: true
"@types/react":
optional: true
languageName: unknown
@@ -7042,6 +7062,7 @@ __metadata:
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/frontend-test-utils": "workspace:^"
"@backstage/plugin-catalog": "workspace:^"
"@backstage/plugin-catalog-common": "workspace:^"
"@backstage/plugin-catalog-react": "workspace:^"
@@ -7111,6 +7132,7 @@ __metadata:
"@backstage/dev-utils": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/frontend-test-utils": "workspace:^"
"@backstage/integration": "workspace:^"
"@backstage/integration-react": "workspace:^"
"@backstage/plugin-catalog": "workspace:^"
@@ -7967,11 +7989,14 @@ __metadata:
zen-observable: "npm:^0.10.0"
peerDependencies:
"@testing-library/react": ^16.0.0
"@types/jest": "*"
"@types/react": ^17.0.0 || ^18.0.0
react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0
react-router-dom: ^6.30.2
peerDependenciesMeta:
"@types/jest":
optional: true
"@types/react":
optional: true
languageName: unknown