PlatformEngr® Backstage plugins course

Module 03 · The backend plugin · 30 min · You are: Platform team

Test the backend plugin

In this module

  • You learn: How startTestBackend and mockServices replace 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-backend that 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.

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 TestDatabases from 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

  1. What does startTestBackend give you that a unit test on scoreEntity does 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
  2. 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
  3. 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
  1. The real plugin wiring: services, routes, database and schedule It starts the plugin as the backend would, with mocks where you want them.
  2. Add catalogServiceMock.factory({ entities: [] }) to the features The fake catalog is a feature like any other.
  3. 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.

Report a problem with this module