An official developer portal of the Government of the Republic of Zambia · Test environmentVerify a certificate
Pamodzi Developer HubBuild, integrate and learn the e-Services framework

Writing a journey spec

A journey is a Playwright spec in the kit's tests/ folder that drives one service through its whole process, as the citizen and each officer would. This lesson shows how a journey is put together, which helpers to use and the rules that keep journeys reliable.

Before you write

  1. Have the service in the seed. A journey needs its service in the test database. The kit's seed creates services

    through the backend's models, exactly as an administrator would configure them: form, documents, process, fees and output template. A service from the legacy catalogue is imported with the backend's own importer; a module's service is then upgraded by the module's set-up script. Use synthetic data only.

  2. Know the process. List every step, the role that does it, the fee, and the document issued. This becomes the

    header comment of your spec.

The shape of a journey

Number the spec after the last one (tests/48-example-permit.spec.js). Start with a header comment that lists each actor and step, then one serial describe with one test per actor's stretch of work:

'use strict';
/**
 * Journey 48: Example Vendor Permit.
 *   Citizen      finds the service, applies (form, two documents), pays the K250 fee
 *   Case Officer takes it on, reviews, inspects the site
 *   Supervisor   approves; the sealed permit is issued
 *   Citizen      sees the permit in My permits, downloads and verifies it
 */
const { test, expect } = require('@playwright/test');
const { cfg, signInCitizen, staffLogin, zampay, sql } = require('./support/helpers');
const { serviceDocs } = require('./support/serviceDocs');

const docs = serviceDocs(test, { service: () => examplePermit(), mda: 'EXAMPLE', key: 'example-vendor-permit' });

test.describe.serial('Example Vendor Permit: apply, review, inspect, pay, approve, verify', () => {
  let c = {};

  test('citizen: finds the service and applies', async ({ page }) => {
    await signInCitizen(page);
    await page.goto(`${cfg.urls.portal}/service/${examplePermit().id}`);
    await expect(page.getByRole('heading', { name: 'Example Vendor Permit' }).first()).toBeVisible();
    // fill the form and upload synthetic PDFs, then send
    // ...
    const [row] = await sql('select id, status from cases where casenumber = $1', [c.caseNumber]);
    expect(row.status).toBe('Submitted');
  });

  test('officer: takes it on and reviews it', async ({ page }) => {
    await staffLogin(page, 'officer');
    // open the case in the back office and complete the step
  });

  test('citizen: pays the fee', async ({ page }) => {
    // open My payments, start payment, then let the ZamPay double report it paid:
    await zampay.pay(c.invoiceNumber, { processor: 'mtn' });
    // wait for "Paid. Thank you" and check the case moved on
  });
});

examplePermit() stands for however your journey finds its seeded service.

The helpers

tests/support/ holds shared helpers. Use them instead of writing your own:

HelperPurpose
helpers.jsURLs and settings (cfg), the seeded ids (seeded), citizen and staff sign-in, a direct API caller, SQL for checks, the ZamPay double's control API, the other doubles' control APIs
apply.jsThe portal's apply flow: open a service, fill the form, upload a document, review and send; and the hub's flow for a node's service
backoffice.jsOpening a case in the back office
arrange.jsArranging a case at a given status through the real process, so a journey still runs when the one before it failed
home.jsWhere the service lives (installation or node) and the citizen's apply, pay and download on the hub
serviceDocs.jsDocumentation capture (next lesson)
docsData.jsReading a case's notifications and permit facts for the documentation

Each module journey usually has a helper of its own that arranges its ministry, officers and service.

Rules for reliable journeys

Act like a user. Find elements by role and visible name (getByRole('button', { name: 'Pay' })) or label (getByLabel('Business name')), the way a person or a screen reader finds them. Avoid CSS selectors tied to layout.

No fixed waits. Never waitForTimeout. Wait for what the user would see: a heading, a status word, a dialog. The kit has no fixed waits; slow conversions (such as rendering a document) are covered by generous timeouts.

Check the outcome, not only the screen. After each stretch, assert the state the platform recorded: the case status, the invoice amount set by the server, the permit row, the seal status. A screen can look right while the record is wrong.

Prove the abuse cases that matter. For money, status and permission changes, include the refusal: a proposer cannot approve their own proposal (403), an amount from the browser is not trusted, a second payment is recorded once, another ministry's officer cannot read the record. The rest of the platform has integration tests for these; the journey proves them through the real screens and APIs.

Cover the whole process. Apply, pay, every officer step with the role it names, the decision, the issued document (download it), seal and QR verification on the public verification page, and its key fields.

Keep journeys independent where you can. A journey may reuse a case left by an earlier one in the same run, but should arrange its own (through arrange.js or its module helper) when that case is missing, so one failure does not hide the next.

Synthetic data only. Seeded accounts use @example.test addresses. Use invented names and identifiers, such as NRC 000000/00/0 and phone +260970000000, and blank synthetic PDFs. Never type real personal data, and never put a real credential in a spec.

Running and debugging

npm run e2e -- tests/48-example-permit.spec.js --headed     # see the browser
npm run stack                                               # keep the stack running ...
npx playwright test tests/48-example-permit.spec.js         # ... and rerun from another terminal
E2E_SKIP_RESET=1 npm run e2e -- tests/48-example-permit.spec.js   # keep the database

When a test fails, read .stack/last-summary.txt first, then the failure's screenshot and trace, then the API log in .stack/logs/api.log. If the application is wrong, leave the test failing and report the bug; do not weaken the test.

A mobile check

The portal must work at 375 px with no horizontal scroll. The kit's mobile project runs specs whose file name contains mobile at 375 × 812 with touch. The documentation capture also takes a 375 px screenshot of every citizen step automatically (next lesson).

Check your understanding

  • What goes in a journey's header comment?
  • Why find elements by role and label rather than CSS selectors?
  • Why are fixed waits not allowed, and what do you wait for instead?
  • Give two abuse cases a journey for a paid service should prove.
  • How does a journey keep running when the journey before it failed?
  • Which data may appear in a journey, and which may never?

Sign in to record your progress.

Previous: Seed profiles and topologies Next: Capturing the service documentation