Plugin Testing & Publishing
Custom Backstage plugins only become reusable platform assets once they are tested against the framework’s real runtime contracts and published to a registry your CI and developer machines can resolve. This page covers the test pyramid for frontend and backend plugins using the official Backstage test utilities, and the workflow for cutting versioned releases to an internal npm registry.
A plugin that passes yarn build but has no tests is a liability: it will silently break on the next core upgrade, and consuming teams have no signal until the portal fails in staging. Treat every plugin as a published package with a stable API surface — the same discipline you apply when Building Custom Backstage Plugins should extend through its release lifecycle.
Prerequisites & Environment Baseline
- Node.js 20.x (LTS) and Yarn 4.1+ with the workspace at the repo root configured for the Backstage monorepo layout.
@backstage/cli0.26.x — provides thebackstage-cli package testwrapper around Jest, preconfigured with the Backstage transform and module resolution.@backstage/test-utils1.5.x forrenderInTestApp,TestApiProvider, andmockApis.@backstage/backend-test-utils0.4.x forstartTestBackendandmockServiceson backend plugins.- Read access to your internal registry (Verdaccio, JFrog Artifactory, GitHub Packages, or AWS CodeArtifact) and a publish token exported as
${NPM_TOKEN}. - A scoped package name such as
@internal/backstage-plugin-*so the registry can route the scope to your private upstream.
Step-by-Step Configuration & Plugin Architecture
1. Configure the test runner
The Backstage CLI ships a Jest config you extend per package. Keep coverage thresholds in the root package.json so CI fails on regressions rather than warning silently.
// package.json (repo root)
// Requires @backstage/cli >= 0.26.0
{
"jest": {
"coverageThreshold": {
"global": { "branches": 70, "functions": 75, "lines": 80, "statements": 80 }
}
}
}
2. Unit-test a frontend component
renderInTestApp wraps your component in the providers (theme, router, analytics) that Backstage components assume at runtime. Use TestApiProvider to inject mock implementations of any API your component consumes.
// plugins/custom/src/components/ServiceList/ServiceList.test.tsx
// Requires @backstage/test-utils >= 1.5.0
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { screen } from '@testing-library/react';
import { ServiceList } from './ServiceList';
const mockCatalog = {
getEntities: jest.fn().mockResolvedValue({
items: [{ metadata: { name: 'payments-api' }, kind: 'Component' }],
}),
};
describe('ServiceList', () => {
it('renders entities returned by the catalog API', async () => {
await renderInTestApp(
<TestApiProvider apis={[[catalogApiRef, mockCatalog]]}>
<ServiceList />
</TestApiProvider>,
);
expect(await screen.findByText('payments-api')).toBeInTheDocument();
});
});
3. Integration-test a backend plugin
For backend plugins built on the new backend system, startTestBackend boots an in-memory backend with mocked core services so you can assert real HTTP behavior through supertest. This is the same pattern you use when adding a backend plugin with a REST endpoint.
// plugins/custom-backend/src/plugin.test.ts
// Requires @backstage/backend-test-utils >= 0.4.0
import { startTestBackend } from '@backstage/backend-test-utils';
import request from 'supertest';
import { customPlugin } from './plugin';
describe('customPlugin', () => {
it('serves health on /api/custom/health', async () => {
const { server } = await startTestBackend({ features: [customPlugin] });
const res = await request(server).get('/api/custom/health');
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: 'ok' });
});
});
4. Define the public API surface
A plugin’s src/index.ts is its package contract. The Backstage CLI’s API report (yarn build:api-reports) snapshots exported types so an accidental breaking change to a consumer-facing signature fails CI. Keep entity-facing types aligned with your Catalog Integration Patterns so published types match what the catalog actually emits.
5. Prepare the package for publishing
// plugins/custom/package.json
// Requires @backstage/cli >= 0.26.0
{
"name": "@internal/backstage-plugin-custom",
"version": "0.3.1",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts",
"publishConfig": {
"access": "restricted",
"registry": "https://npm.internal.corp/"
},
"files": ["dist", "config.d.ts"],
"scripts": {
"build": "backstage-cli package build",
"test": "backstage-cli package test",
"prepack": "backstage-cli package prepack"
}
}
backstage-cli package prepack rewrites main/types to point at the built dist/ files so consumers resolve compiled output, not your TypeScript source.
Validation & Health Checks
Run the full gate locally before pushing:
# Requires @backstage/cli >= 0.26.0
yarn tsc --noEmit # type check the whole workspace
yarn backstage-cli repo lint --since origin/main
yarn backstage-cli repo test --coverage --since origin/main
yarn build:api-reports --check # fails if the public API drifted
Expected output from the test run ends with a coverage table and a passing summary:
Test Suites: 14 passed, 14 total
Tests: 62 passed, 62 total
----------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
----------|---------|----------|---------|---------|
All files | 86.4 | 74.1 | 81.0 | 85.9 |
Confirm the package would publish the right files without actually pushing:
# Requires npm >= 10.0.0
npm publish --dry-run --workspace @internal/backstage-plugin-custom
# tarball contents should list only dist/, config.d.ts, package.json, README
Maintenance & Lifecycle Management
- Versioning: follow semver. A changed prop type or removed export is a major bump; a new optional prop is a minor. Automate this with Changesets (
yarn changeset) so the version and changelog are derived from intent, not memory. - Upgrade path: when bumping core Backstage packages, run
yarn backstage-cli versions:bumpand re-run the full gate. The API report diff tells you immediately whether the upgrade changed your plugin’s surface. - Deprecation: mark exports with
@deprecatedJSDoc tags — these surface in the API report and editor tooltips — and keep them for at least one minor release before removal. - Metrics: track adoption by querying download counts per version from the registry, and alert when a deprecated major is still being pulled by CI.
- Rollback: never unpublish. Publish a patch or use
npm dist-tagto move thelatesttag back to the last good version so consumers resolving^ranges recover automatically.
Common Pitfalls & Mitigation Strategies
- Testing against jsdom without
renderInTestApp— components throw because the router or theme context is missing. Root cause: Backstage components assume app-level providers. Fix: always wrap UI under test inrenderInTestApp. - Publishing TypeScript source instead of
dist/— consumers get type errors and unbundled imports. Root cause: skippingprepack. Fix: runbackstage-cli package prepack(or rely on theprepackscript) beforenpm publish. - Mutable
latestwith no immutable tags — a republish silently changes what^resolves to. Root cause: treating the registry as mutable. Fix: enable immutable versions on the registry and gate publishes behind a git tag. - Floating core ranges in
dependencies— pulls incompatible core versions into the consumer. Root cause: core packages belong inpeerDependencies. Fix: list@backstage/core-*as peers and pin them only in the app. - Coverage thresholds set per-package — drift goes unnoticed across the monorepo. Fix: enforce thresholds at the repo root so the aggregate gates merges.
Frequently Asked Questions
Should I use the Backstage CLI test command or call Jest directly?
Use backstage-cli package test. It applies the Backstage Jest transform, module-name mapping, and the SWC-based TypeScript compile that the framework expects. Calling Jest directly means re-implementing that config and risks subtle resolution mismatches between test and build.
How do I test a plugin that calls the permission framework?
Inject a mock permission API via TestApiProvider for frontend, or use mockServices.permissions() from @backstage/backend-test-utils for backend. Assert both the allowed and denied paths so RBAC regressions surface in CI rather than production.
Do I need to publish every plugin, or can I keep them in the monorepo?
If a plugin is only ever consumed by one portal app in the same repo, workspace resolution is enough. Publish once a second repo or team needs it, or when you want independent release cadence — at which point the versioning and registry workflow above becomes mandatory.