Module 03 · The backend plugin · 30 min · You are: Platform team
Test the backend plugin
In this module
- You learn: How
startTestBackendandmockServicesreplace core services, how to fake the catalog, and what is worth testing in a plugin. - You do: Write two tests that call the API with supertest, run them, and see one catch a change in behaviour.
- You end with: Two passing tests in
readiness-backendthat start the plugin with an in-memory database and a fake catalog. Merged.
A plugin is wiring: services, routes, a database, a schedule. Unit tests on single functions miss the wiring. Backstage lets you start the real plugin in a test with fake services in a second, so test it the way callers use it.
Open this module in the interactive course to track progress.
Concepts
- startTestBackend
- Starts a backend with only the features you list. Core services you do not give are mocks: an in-memory database, a logger, auth that lets requests through. Testing.
- mockServices
- Replace one service with your own behaviour, for example a permissions service that always denies.
- catalogServiceMock
- A catalog with only the entities you give it.
- supertest
- Sends HTTP requests to the test server and checks status codes and bodies.
Files to know
Open them in your editor or on GitHub; the bullets say what to look at.
portal/plugins/readiness-backend/src/plugin.test.ts(reference, your repo): The finished tests, including one from module 08.
Steps
1. Write the tests
The first test also proves nothing is stored before the first evaluation.
Create ~/work/portal/plugins/readiness-backend/src/plugin.test.ts
import { startTestBackend } from '@backstage/backend-test-utils';
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
import request from 'supertest';
import { readinessPlugin } from './plugin';
// A component like team-checkout's, without a runbook link.
const checkout = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'checkout',
annotations: { 'backstage.io/techdocs-ref': 'dir:.' },
},
spec: {
type: 'service',
lifecycle: 'production',
owner: 'team-checkout',
system: 'shop',
},
};
describe('readiness plugin', () => {
it('scores a component and stores the scorecard', async () => {
const { server } = await startTestBackend({
features: [
readinessPlugin,
catalogServiceMock.factory({ entities: [checkout] }),
],
});
await request(server)
.get('/api/readiness/scorecards/component/default/checkout')
.expect(404);
const res = await request(server)
.post('/api/readiness/evaluate')
.send({ entityRef: 'component:default/checkout' });
expect(res.status).toBe(200);
expect(res.body.score).toBe(80);
expect(res.body.checks).toContainEqual(
expect.objectContaining({ id: 'has-runbook', passed: false }),
);
const stored = await request(server).get(
'/api/readiness/scorecards/component/default/checkout',
);
expect(stored.body.score).toBe(80);
});
it('returns 404 for an entity the catalog does not have', async () => {
const { server } = await startTestBackend({
features: [readinessPlugin, catalogServiceMock.factory({ entities: [] })],
});
await request(server)
.post('/api/readiness/evaluate')
.send({ entityRef: 'component:default/nope' })
.expect(404);
});
});
2. Run them
Each test starts its own backend, so tests never share data.
cd ~/work/portal/plugins/readiness-backend && yarn test --watchAll=false
Expect Tests: 2 passed, 2 total.
3. Merge it
cd ~/work/portal
Propose it as a pull request. Put it on its own branch and push it:
git switch -c readiness-tests && git add -A && git commit -qm "Readiness: tests for the backend plugin"
git push -qu origin HEAD
Open the pull request. It prints a link:
gh pr create --fill
Open that link. Files changed shows exactly your diff.
Merge it on GitHub (Squash and merge, then Delete branch), or from the terminal:
gh pr merge --squash --delete-branch
Bring your copy up to date:
git switch -q main && git pull -q
Simplified for the course
The tests use SQLite in memory. CI for a busy plugin also runs them against Postgres with
TestDatabasesfrom the same package.
Break it on purpose: Start the schedule at once
Someone wants scores right after a deploy and removes the default 15-second delay. It works on their laptop.
You practise: reading a failing test as a change in behaviour, not a broken test.
1. Break it
Change in ~/work/portal/plugins/readiness-backend/src/plugin.ts
No more waiting.
- timeout: { minutes: 5 },
- initialDelay: { seconds: 15 },
+ timeout: { minutes: 5 },
cd ~/work/portal/plugins/readiness-backend && yarn test --watchAll=false
2. What you see
Expected: two passing tests. You get: expected 404 "Not Found", got 200 "OK" in the first test.
3. Fix it yourself
Explain why a scorecard exists before the test evaluated anything, then decide: fix the code or the test. You are done when both tests pass.
Hint 1
Who else writes scorecards, apart from POST /evaluate?
Hint 2
Without a delay, the scheduled job runs as soon as the plugin starts: in every test, and on every backend restart in production.
The fix
Change in ~/work/portal/plugins/readiness-backend/src/plugin.ts
- timeout: { minutes: 5 },
+ timeout: { minutes: 5 },
+ initialDelay: { seconds: 15 },
yarn test --watchAll=false
After you fixed it: the five questions
- What failed?
- The scheduled job ran at startup and scored every entity before the test did.
- How was it detected?
- The test that checks nothing is stored before the first evaluation.
- Who responds?
- The platform team.
- How is it corrected?
- Keep the delay. Startup is busy; background work can wait.
- How is it prevented?
- CI runs the plugin tests on every pull request.
Quiz
- What does
startTestBackendgive you that a unit test onscoreEntitydoes not?- The real plugin wiring: services, routes, database and schedule
- A faster test, because no TypeScript has to be compiled
- A connection to the real catalog of your running portal
- How do you test the plugin with an empty catalog?
- Start the portal with no catalog locations configured
- Add
catalogServiceMock.factory({ entities: [] })to the features - Delete every entity from the dev database before the test
- A test fails after a change. What do you check first?
- Whether the test runner is out of date
- Whether the test can be skipped for this release
- Whether the behaviour the test describes was meant to change
Answers
- The real plugin wiring: services, routes, database and schedule It starts the plugin as the backend would, with mocks where you want them.
- Add
catalogServiceMock.factory({ entities: [] })to the features The fake catalog is a feature like any other. - Whether the behaviour the test describes was meant to change A failing test is a question about behaviour; answer it before touching the test.
Field note
I test plugins over HTTP almost only. It is what the frontend and other plugins depend on, and the tests survive every refactor inside the plugin.