Testing Backstage Plugins: Unit, Integration, and E2E Strategies

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:

  1. 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.
  2. Permission denials — if permissionEvaluator.authorize() returns DENY, most plugins silently render nothing. Tests that skip permission setup can ship broken access control.
  3. 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.ts

Both 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-utils

The 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:3000

CI 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 run

Common 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

Read more