diff --git a/.changeset/great-eagles-repair.md b/.changeset/great-eagles-repair.md
new file mode 100644
index 0000000000..886fb5df7a
--- /dev/null
+++ b/.changeset/great-eagles-repair.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-test-utils': patch
+---
+
+Added an `ApiMock`, analogous to `ServiceMock` from the backend test utils.
diff --git a/.changeset/healthy-years-search.md b/.changeset/healthy-years-search.md
new file mode 100644
index 0000000000..a613d11eea
--- /dev/null
+++ b/.changeset/healthy-years-search.md
@@ -0,0 +1,7 @@
+---
+'@backstage/plugin-catalog-react': minor
+---
+
+Add catalog service mocks under the `/testUtils` subpath export.
+
+You can now use e.g. `const catalog = catalogApiMock.mock()` in your test and then do assertions on `catalog.getEntities` without awkward type casting.
diff --git a/.changeset/lovely-bees-walk.md b/.changeset/lovely-bees-walk.md
new file mode 100644
index 0000000000..223acf1769
--- /dev/null
+++ b/.changeset/lovely-bees-walk.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog-node': patch
+---
+
+Documentation for the `testUtils` named export
diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md
index 1337e5eb4f..3229da9898 100644
--- a/packages/frontend-test-utils/report.api.md
+++ b/packages/frontend-test-utils/report.api.md
@@ -3,11 +3,13 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
+///
///
import { AnalyticsApi } from '@backstage/frontend-plugin-api';
import { AnalyticsEvent } from '@backstage/frontend-plugin-api';
import { AnyExtensionDataRef } from '@backstage/frontend-plugin-api';
+import { ApiFactory } 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';
@@ -32,6 +34,15 @@ import { TestApiProviderProps } from '@backstage/test-utils';
import { TestApiRegistry } from '@backstage/test-utils';
import { withLogCollector } from '@backstage/test-utils';
+// @public
+export type ApiMock = {
+ factory: ApiFactory;
+} & {
+ [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return
+ ? TApi[Key] & jest.MockInstance
+ : TApi[Key];
+};
+
// @public (undocumented)
export function createExtensionTester(
subject: ExtensionDefinition,
diff --git a/packages/frontend-test-utils/src/apis/ApiMock.ts b/packages/frontend-test-utils/src/apis/ApiMock.ts
new file mode 100644
index 0000000000..d11689f98a
--- /dev/null
+++ b/packages/frontend-test-utils/src/apis/ApiMock.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2024 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 } from '@backstage/frontend-plugin-api';
+
+/**
+ * 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 = {
+ factory: ApiFactory;
+} & {
+ [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return
+ ? TApi[Key] & jest.MockInstance
+ : TApi[Key];
+};
diff --git a/packages/frontend-test-utils/src/apis/index.ts b/packages/frontend-test-utils/src/apis/index.ts
index 1230be9be0..564c327aef 100644
--- a/packages/frontend-test-utils/src/apis/index.ts
+++ b/packages/frontend-test-utils/src/apis/index.ts
@@ -26,4 +26,5 @@ export {
type MockStorageBucket,
} from '@backstage/test-utils';
+export { type ApiMock } from './ApiMock';
export { MockAnalyticsApi } from './AnalyticsApi/MockAnalyticsApi';
diff --git a/plugins/catalog-node/src/testUtils.ts b/plugins/catalog-node/src/testUtils.ts
index de60594701..fd1134daf4 100644
--- a/plugins/catalog-node/src/testUtils.ts
+++ b/plugins/catalog-node/src/testUtils.ts
@@ -14,4 +14,10 @@
* limitations under the License.
*/
+/**
+ * Backend test helpers for the Catalog plugin.
+ *
+ * @packageDocumentation
+ */
+
export { catalogServiceMock } from './testUtils/catalogServiceMock';
diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json
index cc59c46a6c..672ca2e0ed 100644
--- a/plugins/catalog-react/package.json
+++ b/plugins/catalog-react/package.json
@@ -68,6 +68,7 @@
"@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:^",
@@ -89,7 +90,6 @@
"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:^",
diff --git a/plugins/catalog-react/report-testUtils.api.md b/plugins/catalog-react/report-testUtils.api.md
index 5b00512236..331ef45988 100644
--- a/plugins/catalog-react/report-testUtils.api.md
+++ b/plugins/catalog-react/report-testUtils.api.md
@@ -3,11 +3,28 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
+import { ApiFactory } from '@backstage/frontend-plugin-api';
+import { ApiMock } from '@backstage/frontend-test-utils';
+import { CatalogApi } from '@backstage/catalog-client';
import { DefaultEntityFilters } from '@backstage/plugin-catalog-react';
+import { Entity } from '@backstage/catalog-model';
import { EntityListContextProps } from '@backstage/plugin-catalog-react';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
+// @public
+export function catalogApiMock(options?: { entities?: Entity[] }): CatalogApi;
+
+// @public
+export namespace catalogApiMock {
+ const factory: (options?: {
+ entities?: Entity[];
+ }) => ApiFactory;
+ const mock: (
+ partialImpl?: Partial | undefined,
+ ) => ApiMock;
+}
+
// @public
export function MockEntityListContextProvider<
T extends DefaultEntityFilters = DefaultEntityFilters,
diff --git a/plugins/catalog-react/src/testUtils.ts b/plugins/catalog-react/src/testUtils.ts
index 4688e73feb..ffabf4f10c 100644
--- a/plugins/catalog-react/src/testUtils.ts
+++ b/plugins/catalog-react/src/testUtils.ts
@@ -20,4 +20,5 @@
* @packageDocumentation
*/
+export { catalogApiMock } from './testUtils/catalogApiMock';
export { MockEntityListContextProvider } from './testUtils/MockEntityListContextProvider';
diff --git a/plugins/catalog-react/src/testUtils/catalogApiMock.test.ts b/plugins/catalog-react/src/testUtils/catalogApiMock.test.ts
new file mode 100644
index 0000000000..3501f808f2
--- /dev/null
+++ b/plugins/catalog-react/src/testUtils/catalogApiMock.test.ts
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2024 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 { Entity } from '@backstage/catalog-model';
+import { catalogApiMock } from './catalogApiMock';
+
+const entity1: Entity = {
+ apiVersion: 'v1',
+ kind: 'CustomKind',
+ metadata: {
+ namespace: 'default',
+ name: 'e1',
+ uid: 'u1',
+ },
+};
+
+const entity2: Entity = {
+ apiVersion: 'v1',
+ kind: 'CustomKind',
+ metadata: {
+ namespace: 'default',
+ name: 'e2',
+ uid: 'u2',
+ },
+};
+
+const entities = [entity1, entity2];
+
+describe('catalogApiMock', () => {
+ it('exports the expected functionality', async () => {
+ const emptyFake = catalogApiMock();
+ const notEmptyFake = catalogApiMock({ entities });
+
+ await expect(emptyFake.getEntities()).resolves.toEqual({ items: [] });
+ await expect(notEmptyFake.getEntities()).resolves.toEqual({
+ items: entities,
+ });
+
+ const mock = catalogApiMock.mock();
+ expect(mock.getEntities).toHaveBeenCalledTimes(0);
+ expect(mock.getEntities()).toBeUndefined();
+ mock.getEntities.mockResolvedValue({ items: entities });
+ await expect(mock.getEntities()).resolves.toEqual({ items: entities });
+
+ const mock2 = catalogApiMock.mock({
+ getEntities: async () => ({ items: [entity1] }),
+ });
+ await expect(mock2.getEntities()).resolves.toEqual({ items: [entity1] });
+ });
+});
diff --git a/plugins/catalog-react/src/testUtils/catalogApiMock.ts b/plugins/catalog-react/src/testUtils/catalogApiMock.ts
new file mode 100644
index 0000000000..8cf3b740b0
--- /dev/null
+++ b/plugins/catalog-react/src/testUtils/catalogApiMock.ts
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2024 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,
+ ApiRef,
+ 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(
+ ref: ApiRef,
+ mockFactory: () => jest.Mocked,
+): (partialImpl?: Partial) => ApiMock {
+ 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;
+ };
+}
+
+/**
+ * Creates a fake catalog client that handles entities in memory storage. Note
+ * that this client may be severely limited in functionality, and advanced
+ * functions may not be available at all.
+ *
+ * @public
+ */
+export function catalogApiMock(options?: { entities?: Entity[] }): CatalogApi {
+ return new InMemoryCatalogClient(options);
+}
+
+/**
+ * A collection of mock functionality for the catalog service.
+ *
+ * @public
+ */
+export namespace catalogApiMock {
+ /**
+ * Creates a fake catalog client that handles entities in memory storage. Note
+ * that this client may be severely limited in functionality, and advanced
+ * functions may not be available at all.
+ */
+ export const factory = (options?: {
+ entities?: Entity[];
+ }): ApiFactory =>
+ createApiFactory({
+ api: catalogApiRef,
+ deps: {},
+ factory: () => new InMemoryCatalogClient(options),
+ });
+ /**
+ * Creates a catalog client whose methods are mock functions, possibly with
+ * some of them overloaded by the caller.
+ */
+ export const mock = simpleMock(catalogApiRef, () => ({
+ getEntities: jest.fn(),
+ getEntitiesByRefs: jest.fn(),
+ queryEntities: jest.fn(),
+ getEntityAncestors: jest.fn(),
+ getEntityByRef: jest.fn(),
+ removeEntityByUid: jest.fn(),
+ refreshEntity: jest.fn(),
+ getEntityFacets: jest.fn(),
+ getLocationById: jest.fn(),
+ getLocationByRef: jest.fn(),
+ addLocation: jest.fn(),
+ removeLocationById: jest.fn(),
+ getLocationByEntity: jest.fn(),
+ validateEntity: jest.fn(),
+ }));
+}