Testing Backstage Plugins: Unit, Integration, and E2E Strategies
Backstage plugins live at the intersection of React frontend, Node.js backend, and Backstage's entity catalog — which makes them surprisingly tricky to test. This guide walks through unit testing plugin backends with @backstage/test-utils, integration-testing catalog API interactions, and running Cypress E2E tests against a real Backstage instance.
Key Takeaways
Use @backstage/test-utils for isolated plugin tests. It provides mock service factories for the catalog, config, permissions, and auth — so you don't need a live Backstage instance to unit-test plugin logic.
Mock the catalog client, not the HTTP layer. Backstage plugins call the catalog via a typed client interface. Mocking CatalogClient directly gives you type-safe mocks that survive API refactors better than nock-style HTTP interception.
Frontend plugin tests belong in React Testing Library, not Enzyme. RTL tests exercise the component through user-visible behavior. Backstage's test utilities provide renderInTestApp() which wires up routing and all required context providers automatically.
E2E tests need a reproducible catalog fixture. Backstage E2E tests are only deterministic when the catalog state is fixed. Use a static catalog from catalog-info.yaml files in your test fixtures, not a live synced SCM catalog.
Test plugin permissions separately from plugin logic. Backstage's permission framework is its own layer. Write dedicated tests that verify allow / deny decisions using mockPermissionEvaluator.
Why Backstage Plugin Testing Is Hard
Backstage is a framework-within-a-framework. A plugin exposes frontend components, backend routes, and catalog processors — each with its own dependency injection model. The @backstage/backend-plugin-api introduced in Backstage 1.20 replaced the old createPlugin/createRouter pattern with a new service-based architecture, which changes how you set up tests.
The three failure modes that slip past manual testing:
- Catalog entity mutations — your plugin renders differently based on entity annotations. Test coverage that uses a hardcoded stub entity misses edge cases like missing annotations, malformed spec fields, or unresolvable relations.
- Permission denials — if
permissionEvaluator.authorize()returnsDENY, most plugins silently render nothing. Tests that skip permission setup can ship broken access control. - Backend router regressions — route handlers that worked in isolation fail when mounted under the Backstage backend because of middleware ordering or auth enforcement.
Project Layout
A typical Backstage plugin lives across two packages:
plugins/
my-plugin/ # frontend
src/
components/
hooks/
plugin.ts
dev/
index.ts # local dev harness
my-plugin-backend/ # backend
src/
router.ts
service/
createRouter.ts
processors/
MyProcessor.tsBoth packages need their own test setup.
Unit Testing the Backend Plugin
Setting Up @backstage/test-utils
Install the test utilities:
yarn add --dev @backstage/test-utils @backstage/backend-test-utilsThe startTestBackend helper from @backstage/backend-test-utils bootstraps a minimal Backstage backend with only the services your plugin needs:
// src/service/createRouter.test.ts
import { startTestBackend } from '@backstage/backend-test-utils';
import { myPlugin } from '../plugin';
describe('myPlugin router', () => {
it('returns 200 for authenticated GET /status', async () => {
const { server } = await startTestBackend({
features: [myPlugin],
});
const response = await request(server).get('/api/my-plugin/status');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ status: 'ok' });
});
});startTestBackend provides sensible defaults: a mock auth service that accepts any token, a mock config with minimal required keys, and an in-memory database. Override specific services when you need to:
import { mockServices } from '@backstage/backend-test-utils';
const { server } = await startTestBackend({
features: [myPlugin],
services: [
mockServices.config({
data: {
'my-plugin': {
apiToken: 'test-token',
baseUrl: 'https://api.example.com',
},
},
}),
],
});Mocking the Catalog Client
The catalog client is the most common dependency in Backstage plugins. Mock it at the interface level:
// src/service/router.test.ts
import { CatalogClient } from '@backstage/catalog-client';
import { mockServices } from '@backstage/backend-test-utils';
const mockCatalogClient = {
getEntities: jest.fn(),
getEntityByRef: jest.fn(),
queryEntities: jest.fn(),
} as unknown as jest.Mocked<CatalogClient>;
const { server } = await startTestBackend({
features: [myPlugin],
services: [
[catalogServiceRef, mockCatalogClient],
],
});
// Now configure the mock per test:
mockCatalogClient.getEntities.mockResolvedValue({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'my-service', namespace: 'default' },
spec: { type: 'service', lifecycle: 'production', owner: 'team-a' },
},
],
});Testing Catalog Processors
Catalog processors transform entities during ingestion. They implement CatalogProcessor and are particularly important to test because bugs cause silent data corruption:
// src/processors/MyAnnotationProcessor.test.ts
import { MyAnnotationProcessor } from './MyAnnotationProcessor';
import { Entity } from '@backstage/catalog-model';
describe('MyAnnotationProcessor', () => {
const processor = new MyAnnotationProcessor();
it('adds sla annotation from spec.tier', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'svc', namespace: 'default', annotations: {} },
spec: { tier: 'platinum' },
};
const emit = jest.fn();
await processor.postProcessEntity(entity, {} as any, emit);
expect(entity.metadata.annotations!['my-company/sla']).toBe('99.99%');
});
it('skips entities with no spec.tier', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'svc', namespace: 'default' },
spec: {},
};
const emit = jest.fn();
await processor.postProcessEntity(entity, {} as any, emit);
expect(entity.metadata.annotations).toBeUndefined();
expect(emit).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
);
});
});Testing Permission Integration
Backstage 1.24+ enforces permissions via permissionIntegrationRouter. Test that your plugin's resource types and conditions are wired correctly:
// src/service/permissions.test.ts
import { mockServices } from '@backstage/backend-test-utils';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
it('blocks unauthenticated access to admin endpoints', async () => {
const mockPermissions = mockServices.permissions.mock();
mockPermissions.authorize.mockResolvedValue([
{ result: AuthorizeResult.DENY },
]);
const { server } = await startTestBackend({
features: [myPlugin],
services: [[permissionsServiceRef, mockPermissions]],
});
const response = await request(server)
.post('/api/my-plugin/admin/action')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(403);
});Unit Testing the Frontend Plugin
renderInTestApp — The Right Way to Render Plugin Components
Backstage frontend components depend on routing context, API holders, theme providers, and translation providers. The renderInTestApp helper from @backstage/test-utils wraps all of this:
// src/components/ServiceCard/ServiceCard.test.tsx
import React from 'react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ServiceCard } from './ServiceCard';
import { myPluginApiRef, MyPluginApi } from '../../api';
const mockApi: jest.Mocked<MyPluginApi> = {
getServiceHealth: jest.fn(),
triggerDeploy: jest.fn(),
};
describe('ServiceCard', () => {
beforeEach(() => {
mockApi.getServiceHealth.mockResolvedValue({
status: 'healthy',
lastCheck: '2026-05-17T08:00:00Z',
});
});
it('renders service status after loading', async () => {
await renderInTestApp(
<TestApiProvider apis={[[myPluginApiRef, mockApi]]}>
<ServiceCard entityRef="component:default/my-service" />
</TestApiProvider>,
);
await waitFor(() =>
expect(screen.getByText('healthy')).toBeInTheDocument(),
);
expect(screen.getByText('my-service')).toBeInTheDocument();
});
it('calls triggerDeploy when button clicked', async () => {
mockApi.triggerDeploy.mockResolvedValue({ jobId: 'job-123' });
await renderInTestApp(
<TestApiProvider apis={[[myPluginApiRef, mockApi]]}>
<ServiceCard entityRef="component:default/my-service" />
</TestApiProvider>,
);
await waitFor(() => screen.getByRole('button', { name: /deploy/i }));
await userEvent.click(screen.getByRole('button', { name: /deploy/i }));
expect(mockApi.triggerDeploy).toHaveBeenCalledWith('component:default/my-service');
await waitFor(() =>
expect(screen.getByText(/job-123/)).toBeInTheDocument(),
);
});
});Mocking the Catalog API on the Frontend
Frontend plugins often call the catalog API directly through the catalogApiRef. Mock it in tests:
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { CatalogApi } from '@backstage/catalog-client';
const mockCatalogApi: Partial<CatalogApi> = {
getEntityByRef: jest.fn().mockResolvedValue({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-service',
namespace: 'default',
annotations: {
'backstage.io/managed-by-location': 'url:https://github.com/org/repo',
},
},
spec: { type: 'service', lifecycle: 'production', owner: 'team-a' },
}),
};
await renderInTestApp(
<TestApiProvider apis={[
[catalogApiRef, mockCatalogApi],
[myPluginApiRef, mockApi],
]}>
<MyPluginPage />
</TestApiProvider>,
);Testing Error States
Error states are commonly skipped in plugin testing. They're easy to cover:
it('shows error message when API call fails', async () => {
mockApi.getServiceHealth.mockRejectedValue(new Error('Network timeout'));
await renderInTestApp(
<TestApiProvider apis={[[myPluginApiRef, mockApi]]}>
<ServiceCard entityRef="component:default/my-service" />
</TestApiProvider>,
);
await waitFor(() =>
expect(screen.getByText(/Network timeout/i)).toBeInTheDocument(),
);
});Integration Testing
Integration tests spin up the actual backend with real HTTP and a controlled catalog state. Use @backstage/backend-test-utils with SQLite for the database:
// integration/catalog-flow.test.ts
import { startTestBackend } from '@backstage/backend-test-utils';
import { catalogPlugin } from '@backstage/plugin-catalog-backend';
import { myPlugin } from '../src/plugin';
describe('integration: my-plugin with catalog', () => {
let server: any;
beforeAll(async () => {
({ server } = await startTestBackend({
features: [catalogPlugin, myPlugin],
services: [
mockServices.database.mock(), // uses SQLite in-memory
],
}));
});
it('enriches catalog entities via processor on ingestion', async () => {
// Ingest a test entity
await request(server)
.post('/api/catalog/entities')
.send({
entity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'test-svc', namespace: 'default' },
spec: { tier: 'gold', type: 'service', lifecycle: 'production', owner: 'team-b' },
},
})
.expect(200);
// Retrieve and verify annotation was added by processor
const resp = await request(server)
.get('/api/catalog/entities/by-name/component/default/test-svc')
.expect(200);
expect(resp.body.metadata.annotations['my-company/sla']).toBe('99.9%');
});
});End-to-End Testing with Cypress
For E2E tests, run a Backstage app with a static catalog fixture and point Cypress at it.
Static Catalog Fixture
# cypress/fixtures/catalog.yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: payments-service
namespace: default
annotations:
my-company/deploy-env: production
spec:
type: service
lifecycle: production
owner: team-payments
---
apiVersion: backstage.io/v1alpha1
kind: Team
metadata:
name: team-payments
namespace: default
spec:
type: team
members: []app-config.test.yaml
catalog:
locations:
- type: file
target: ../../cypress/fixtures/catalog.yaml
rules:
- allow: [Component, Team]
auth:
providers:
guest: {}Cypress Tests
// cypress/e2e/my-plugin.cy.ts
describe('My Plugin — Service View', () => {
beforeEach(() => {
cy.visit('/');
// Backstage guest auth — click through the login screen
cy.contains('Enter').click();
});
it('shows the plugin tab on a component entity page', () => {
cy.visit('/catalog/default/component/payments-service');
cy.contains('My Plugin').click();
cy.get('[data-testid="my-plugin-panel"]').should('be.visible');
});
it('displays health status fetched from backend', () => {
cy.intercept('GET', '/api/my-plugin/health/payments-service', {
statusCode: 200,
body: { status: 'healthy', latencyMs: 42 },
}).as('healthCheck');
cy.visit('/catalog/default/component/payments-service');
cy.contains('My Plugin').click();
cy.wait('@healthCheck');
cy.contains('healthy').should('be.visible');
cy.contains('42ms').should('be.visible');
});
it('handles backend errors gracefully', () => {
cy.intercept('GET', '/api/my-plugin/health/payments-service', {
statusCode: 503,
body: { error: 'Upstream timeout' },
});
cy.visit('/catalog/default/component/payments-service');
cy.contains('My Plugin').click();
cy.contains('Upstream timeout').should('be.visible');
});
});Running Cypress Against a Local Backstage Instance
# Terminal 1: start Backstage with test config
APP_CONFIG_app-config.test.yaml=1 yarn start
<span class="hljs-comment"># Terminal 2: run Cypress
yarn cypress run --config baseUrl=http://localhost:3000CI Pipeline
# .github/workflows/plugin-tests.yaml
name: Plugin Tests
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: yarn install --frozen-lockfile
- run: yarn workspace @internal/my-plugin test --coverage
- run: yarn workspace @internal/my-plugin-backend test --coverage
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: yarn install --frozen-lockfile
- name: Build Backstage
run: yarn build:all
- name: Start Backstage in background
run: |
APP_CONFIG=app-config.test.yaml yarn start &
npx wait-on http://localhost:3000 --timeout 120000
- name: Run Cypress
run: yarn cypress runCommon Mistakes and How to Fix Them
Mistake: Using screen.getByText for async catalog data without waitFor. Catalog data loads asynchronously via the API. Wrap assertions in waitFor or use findBy* queries which retry automatically.
Mistake: Forgetting cleanup between tests. @testing-library/react calls cleanup automatically if you import from @testing-library/react, but only if your test runner supports afterEach. Verify your Jest config includes @testing-library/jest-dom/extend-expect.
Mistake: Mocking the entire @backstage/catalog-client module. Module-level mocking breaks tree-shaking and type checking. Mock at the service reference level with TestApiProvider instead.
Mistake: Not testing the getDefaultMiddleware stack. Backstage backends apply authentication middleware to all routes. Test your plugin with the middleware active to catch routes that accidentally bypass auth.
Conclusion
Testing Backstage plugins requires addressing three distinct layers: the backend router with its service dependencies, the frontend components with their API clients and catalog context, and the end-to-end user flows in a real Backstage instance. The @backstage/test-utils and @backstage/backend-test-utils packages give you the primitives for the first two layers; Cypress handles the third.
Start with unit tests for processors and API handlers — they're fast and catch most logic bugs. Add RTL tests for components that have non-trivial rendering logic. Layer in E2E tests for the critical user flows that span the full stack.
HelpMeTest can monitor your platform engineering pipelines automatically — sign up free