Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions TESTING_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Frontend Plugin Testing Guide

## Testing Utilities

### `renderInTestApp` (from `@backstage/test-utils`)

The **recommended default** for rendering components in tests. Provides full Backstage context: theme, routing, API registry, and error boundaries.

Use for:

- Page-level components that depend on routing or Backstage APIs
- Components that use Backstage UI components (`Progress`, `Table`, `EmptyState`, etc.) which internally call hooks like `useTranslationRef()`

```tsx
import { renderInTestApp } from '@backstage/test-utils';

await renderInTestApp(<MyPage />);
```

### `EntityProvider` (from `@backstage/plugin-catalog-react`)

Wrap components that call `useEntity()` to provide entity context. Use alongside `renderInTestApp`.

```tsx
import { EntityProvider } from '@backstage/plugin-catalog-react';

await renderInTestApp(
<EntityProvider entity={mockEntity}>
<MyPage />
</EntityProvider>,
);
```

### `TestApiProvider` (from `@backstage/test-utils`)

For standalone scenarios where you need specific API mocks but not the full app context (no routing needed). Prefer `renderInTestApp` when possible.

### Plain `render()` (from `@testing-library/react`)

For simple components with no Backstage context dependencies (no Backstage hooks or components used internally).

```tsx
import { render } from '@testing-library/react';

render(<MyButton onClick={fn} />);
```

## What to Mock

**Do mock:**

- Your own hooks (`../../hooks`)
- Sibling/child components (to isolate the unit under test)
- External service plugins (`@openchoreo/backstage-plugin-react`)

**Do NOT mock:**

- `@backstage/core-components` — use `renderInTestApp` instead, which provides the required context. Mocking these components is [documented as broken](https://github.com/backstage/backstage/issues/20713) and strips away real rendering behavior.
- `@backstage/plugin-catalog-react` — use `EntityProvider` to supply entity context.
- MUI components — they work without any special context.

## Quick Reference

| Component type | Render with | Entity context |
| ---------------------------------- | ----------------- | -------------------------------------- |
| Page (uses routing + Backstage UI) | `renderInTestApp` | `EntityProvider` if uses `useEntity()` |
| Component using Backstage UI only | `renderInTestApp` | No |
| Pure component (MUI / HTML only) | `render` | No |

## Test Structure

```tsx
// 1. Imports
import { screen } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import { EntityProvider } from '@backstage/plugin-catalog-react';

// 2. Mock own hooks and siblings only
jest.mock('../../hooks', () => ({ ... }));
jest.mock('./ChildComponent', () => ({ ... }));

// 3. Render helper
function renderPage() {
return renderInTestApp(
<EntityProvider entity={entity}>
<MyPage />
</EntityProvider>,
);
}

// 4. Tests (always async with renderInTestApp)
it('renders content', async () => {
await renderPage();
expect(screen.getByText('Hello')).toBeInTheDocument();
});
```
3 changes: 2 additions & 1 deletion packages/test-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"clean": "backstage-cli package clean"
},
"dependencies": {
"@backstage/backend-test-utils": "1.9.0"
"@backstage/backend-test-utils": "1.9.0",
"@backstage/catalog-model": "1.7.5"
},
"devDependencies": {
"@backstage/cli": "0.34.3"
Expand Down
62 changes: 62 additions & 0 deletions packages/test-utils/src/frontend/entityFixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Entity } from '@backstage/catalog-model';

/**
* Creates a minimal Backstage Component entity for tests.
* Override any field via the `overrides` parameter.
*/
export function mockComponentEntity(
overrides: {
name?: string;
namespace?: string;
annotations?: Record<string, string>;
tags?: string[];
type?: string;
description?: string;
} = {},
): Entity {
const name = overrides.name ?? 'test-component';
return {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name,
namespace: overrides.namespace ?? 'default',
description: overrides.description,
annotations: {
'openchoreo.io/namespace': 'test-ns',
...overrides.annotations,
},
tags: overrides.tags ?? ['service'],
},
spec: {
type: overrides.type ?? 'service',
},
};
}

/**
* Creates a minimal Backstage System entity (project) for tests.
* Override any field via the `overrides` parameter.
*/
export function mockSystemEntity(
overrides: {
name?: string;
namespace?: string;
annotations?: Record<string, string>;
} = {},
): Entity {
const name = overrides.name ?? 'test-project';
return {
apiVersion: 'backstage.io/v1alpha1',
kind: 'System',
metadata: {
name,
namespace: overrides.namespace ?? 'default',
annotations: {
'openchoreo.io/namespace': 'test-ns',
...overrides.annotations,
},
},
spec: {},
};
}
5 changes: 5 additions & 0 deletions packages/test-utils/src/frontend/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {
createMockOpenChoreoClient,
type MockOpenChoreoClient,
} from './mockOpenChoreoClient';
export { mockComponentEntity, mockSystemEntity } from './entityFixtures';
44 changes: 44 additions & 0 deletions packages/test-utils/src/frontend/mockOpenChoreoClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* A fully-mocked OpenChoreoClientApi backed by a Proxy.
* Any method accessed on this object is automatically a `jest.fn()`,
* so it never goes stale when new methods are added to the interface.
*
* Use with `TestApiProvider` to inject into components under test.
*
* @example
* ```tsx
* import { TestApiProvider } from '@backstage/test-utils';
* import { openChoreoClientApiRef } from '@openchoreo/backstage-plugin';
* import { createMockOpenChoreoClient } from '@openchoreo/test-utils';
*
* const mockClient = createMockOpenChoreoClient();
* mockClient.getEnvironments.mockResolvedValue([...]);
*
* render(
* <TestApiProvider apis={[[openChoreoClientApiRef, mockClient]]}>
* <MyComponent />
* </TestApiProvider>
* );
* ```
*/
export type MockOpenChoreoClient = Record<string, jest.Mock>;

export function createMockOpenChoreoClient(
overrides: Partial<Record<string, jest.Mock>> = {},
): MockOpenChoreoClient {
const mocks: Record<string, jest.Mock> = {};
for (const [key, value] of Object.entries(overrides)) {
if (value) {
mocks[key] = value;
}
}

return new Proxy(mocks, {
get(target, prop: string) {
if (!(prop in target)) {
target[prop] = jest.fn();
}
return target[prop];
},
}) as MockOpenChoreoClient;
}
6 changes: 6 additions & 0 deletions packages/test-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,9 @@ export {
buildReadyCondition,
buildNotReadyCondition,
} from './fixtures';
export {
createMockOpenChoreoClient,
type MockOpenChoreoClient,
mockComponentEntity,
mockSystemEntity,
} from './frontend';
1 change: 1 addition & 0 deletions plugins/openchoreo-ci/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@backstage/core-app-api": "1.19.0",
"@backstage/dev-utils": "1.1.14",
"@backstage/test-utils": "1.7.11",
"@openchoreo/test-utils": "workspace:^",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "14.3.1",
"@testing-library/user-event": "14.6.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { render, screen } from '@testing-library/react';
import { BuildStatusChip } from './BuildStatusChip';

// ---- Tests ----

describe('BuildStatusChip', () => {
it('renders success chip for "Succeeded" status', () => {
render(<BuildStatusChip status="Succeeded" />);
expect(screen.getByText('Succeeded')).toBeInTheDocument();
});

it('renders success chip for "Completed" status', () => {
render(<BuildStatusChip status="Completed" />);
expect(screen.getByText('Completed')).toBeInTheDocument();
});

it('renders error chip for "Failed" status', () => {
render(<BuildStatusChip status="Failed" />);
expect(screen.getByText('Failed')).toBeInTheDocument();
});

it('renders error chip for "Error" status', () => {
render(<BuildStatusChip status="Error" />);
expect(screen.getByText('Error')).toBeInTheDocument();
});

it('renders running chip for "Running" status', () => {
render(<BuildStatusChip status="Running" />);
expect(screen.getByText('Running')).toBeInTheDocument();
});

it('renders running chip for "InProgress" status', () => {
render(<BuildStatusChip status="InProgress" />);
expect(screen.getByText('InProgress')).toBeInTheDocument();
});

it('renders pending chip for "Pending" status', () => {
render(<BuildStatusChip status="Pending" />);
expect(screen.getByText('Pending')).toBeInTheDocument();
});

it('renders pending chip for "Queued" status', () => {
render(<BuildStatusChip status="Queued" />);
expect(screen.getByText('Queued')).toBeInTheDocument();
});

it('renders "Unknown" when status is undefined', () => {
render(<BuildStatusChip status={undefined} />);
expect(screen.getByText('Unknown')).toBeInTheDocument();
});

it('renders fallback chip for unrecognized status', () => {
render(<BuildStatusChip status="SomeOtherStatus" />);
expect(screen.getByText('SomeOtherStatus')).toBeInTheDocument();
});
});
Loading
Loading