Skip to content

Writing Tests

silverlunah edited this page Sep 3, 2026 · 6 revisions

Writing Tests

A project runs on one framework, chosen at creation (Projects). The scaffold, the folder layout and the way you write a test all follow from it.

How much of this is Plum's

Almost none of it. Tests are ordinary Playwright and Cucumber: steps are test.step and Gherkin, page objects and fixtures are your own, the runner config is yours. A spec that imports nothing of Plum's still produces a full report with a row and a duration per step, because the report format and the step reporter are passed on the command line rather than required of your code.

Session replay is the one feature that costs a line, because the recorder has to be injected into a page before it loads:

  • Playwright — take test from fixtures/pages.ts instead of @playwright/test. That one import is the whole cost; expect, Page, test.step, fixtures and projects are all unchanged. Import from @playwright/test anyway and the tests still run and still report, they just have no replay.
  • Cucumber — keep utils/hooks.ts in your config's require list. Those hooks open the browser for each scenario, so a suite that opens its own hands that job over.

Two conventions sit on top of the frameworks' own:

  • A tag is an id, so it has to be unique — see Tags below.
  • Cucumber's retry stays 0. Cucumber reports only the final attempt of a retry, so an internal one hides the earlier attempts; Plum re-runs failed scenarios itself instead, using the project's max-retries setting.

Nothing else is Plum's. If you are bringing an existing suite, Migrating an Existing Repo covers the same ground from the other direction.

The .env

Every test folder has a .env (copied from .env.example):

BASE_URL=https://your-app.example.com
IS_HEADLESS=true

BASE_URL is the app under test. Set IS_HEADLESS=false to watch the browser while you work locally. On the server, runs are always headless.

Tags

Plum selects tests by tag, so every test needs one.

  • @TS-001 — a suite tag (one per file, on Feature / describe)
  • @TC-001 — a case tag (one per scenario / test)
  • Add your own freely: @smoke, @slow, @regression…

A tag expression can be @TC-001, @TC-001 or @TC-002, @smoke and not @slow. The same expression works from the run bar, the CLI, and MCP.

The suite/case tags are also how the Test Repository links a manual case to its automation: a repository case tagged @TC-014 shows the latest verdict from the automated test with the same tag.


Cucumber projects

projects/<slug>/tests/
  cucumber.js            yours: paths, requires, formatters
  features/              Gherkin .feature files
  step_definitions/      TypeScript step implementations
  pages/                 page objects (optional)
  utils/world.ts         the World: your per-scenario state, and the browser
  utils/hooks.ts         Before / BeforeStep / After — Plum's, leave it alone
  utils/recorder.ts      session recording — Plum's, leave it alone
  utils/constants.ts     yours: shared users, selectors, copy
  .env                   BASE_URL, IS_HEADLESS
  package.json           this project's own dependencies

Feature file — the spec and the test at once:

@TS-001
Feature: Demo Sauce Login

  Background:
    Given I am in Demo Sauce Login page

  @TC-001
  Scenario: User can log in with valid credentials
    When I enter "standard_user" in username field
    And I click on the login button
    Then I should be navigated to the products page

Step definitions map each line to code. Keep them thin, and declare them with function rather than an arrow so this is the scenario's World:

import { When } from '@cucumber/cucumber';
import { LoginPage } from '../pages/LoginPage';
import { PlumWorld } from '../utils/world';

When('I enter {string} in username field', async function (this: PlumWorld, username: string) {
  await new LoginPage(this.page).iEnterUsername(username);
});

Steps are re-usable across every feature file — don't duplicate them. Files can be named anything inside step_definitions/.

The World is Cucumber's per-scenario state, set up in utils/world.ts with setWorldConstructor. Every step runs with this bound to a fresh one, so this.page is that scenario's own page and nothing leaks between scenarios running in parallel. Put your own state on it — a signed-in user, an API client, data one step hands to the next:

export class PlumWorld extends World {
  page!: Page;
  orderId?: string;      // yours
}

An arrow function has no this, so a step written as async () => {} cannot reach it.

Page objects hold the messy browser code, and take the page they act on:

import { Page } from '@playwright/test';

export class LoginPage {
  constructor(private readonly page: Page) {}

  async iEnterUsername(username: string) {
    await this.page.fill('#user-name', username);
  }
}

utils/ — world.ts opens the browser and holds your state; recorder.ts is the session recording and hooks.ts wires Cucumber's Before / BeforeStep / After to it. Those last two are Plum's: leave them as they are and add your own hooks below the marked line in hooks.ts. constants.ts is yours.

The World sets baseURL on the browser context from BASE_URL, so a page object can navigate with a relative path: this.page.goto('/').

plum create-step

Interactively adds a Given / When / Then to a step-definition file. It asks which page the step uses; choose an existing page, + New Page, or None — inline the step to get a // TODO: implement body and no page wiring. Inline and page-backed steps can share one file.


Playwright projects

projects/<slug>/tests/
  playwright.config.ts   yours: browsers, timeouts, traces, reporters
  specs/                 *.spec.ts test files
  pages/                 page objects (optional)
  fixtures/pages.ts      your page-object fixtures
  fixtures/plum.ts       session recording — Plum's, leave it alone
  .env                   BASE_URL, IS_HEADLESS
  package.json           this project's own dependencies

Spec file — import test from fixtures/pages.ts (or from fixtures/plum.ts if the spec uses no page objects):

import { test } from '../fixtures/pages';

test.describe('Demo Sauce Login', { tag: '@TS-001' }, () => {
  test('User can log in with valid credentials', { tag: '@TC-001' },
    async ({ login, homepage }) => {
      await test.step('I enter "standard_user" in username field', () =>
        login.iEnterUsername('standard_user'));
      await test.step('I click on the login button', () =>
        login.iClickOnTheLoginButton());
      await test.step('I should be navigated to the products page', () =>
        homepage.iShouldBeNavigatedToTheProductsPage());
    });
});

test.step(name, fn) is Playwright's own, nothing of Plum's. Each one becomes a row in the report step rail that the session replay can scrub to, including steps you put in a beforeEach. Without any, a run still passes or fails correctly, it just has no per-step breakdown. Cucumber gets its steps from Gherkin instead.

They are also read straight from the spec, so Automated Tests lists a test's steps before it has ever run. A step whose name is interpolated shows the expression in angle brackets — I enter "<username>" in username field — the same way a Scenario Outline's placeholders do.

Tests inside one file are spread across workers by fullyParallel: true, which the scaffolded config already sets, so nothing needs adding per spec. They therefore run in any order in separate browsers and must not depend on one another.

Page objects are instance classes taking a Page:

import { Page } from '@playwright/test';

export class LoginPage {
  constructor(private readonly page: Page) {}
  async iEnterUsername(username: string) {
    await this.page.fill('#user-name', username);
  }
}

Register each in fixtures/pages.ts so specs pull it from the test callback:

export const test = base.extend<{ login: LoginPage }>({
  login: async ({ page }, use) => { await use(new LoginPage(page)); },
});

plum check

Run it in a tests folder, or the folder above one:

plum check

It asks each runner to list your tests, then reports what neither runner will:

  • Duplicate ids. A repeated @TC-001 cannot be told apart in a report, links whichever case it finds in the repository, and runs both tests when you ask for either. Playwright rejects a duplicate test title on its own; a duplicate tag it does not care about.
  • Untagged tests. A test with no id tag cannot be run or linked on its own.
  • Files the runner could not read. A syntax error, a bad import, or a duplicate title in one file stops Playwright listing anything at all, which is also what leaves the Automated Tests page empty.

Labels are left alone: @smoke, @slow and the like are meant to repeat, and only id-shaped tags have to be unique. A Scenario Outline counts once, not once per row.

It exits non-zero when it finds something, so it fits a pre-commit hook or a CI step:

"lint-staged": { "**/*.{ts,feature}": "plum check" }

plum create-test

Scaffolds a new test in the current project's framework:

plum create-test                     # prompts for a name
plum create-test --name Checkout     # scriptable
plum create-test --name Checkout --page   # also scaffold a page object

It asks "Also scaffold a page object?" — default no. Decline and you get a self-contained test with inline // TODOs; pass --page (or --no-page) to skip the prompt. A Cucumber run produces .feature + Steps.ts; a Playwright run produces .spec.ts.

Extra dependencies

Add libraries to the project's own tests/package.json, not to Plum. tests/tsconfig.json is generated so an editor resolves @playwright/test, @cucumber/cucumber, etc. against the shared toolchain.

Debugging

Set IS_HEADLESS=false in .env and run a single tag locally — see Running Tests Locally. On the server, use session replay: it records the DOM at every moment, so you can scrub back to the exact state at a failed step.

Clone this wiki locally