Applies to all Crossmint repositories. Each app owns its own
tests/directory and Playwright config.
- Consistency over preference — one way to do things, everywhere.
- Readability as documentation — a test name should explain itself without reading the code.
- Scalability — must support E2E, smoke, API, SDK, and unit tests as the platform grows.
- Page Object Model (POM) for all UI tests — decouple selectors and interactions from assertions.
- Tests live with their app — config and tests are colocated inside the app they cover.
Root is always <app-root>/tests/, where <app-root> is the directory that contains the app's package.json and playwright.config.ts.
<app-root>/
├── playwright.config.ts
└── tests/
├── e2e/
│ ├── pages/ ← Page Object classes (UI tests only)
│ │ └── <FeatureName>Page.ts
│ ├── specs/
│ │ └── <domain>/
│ │ └── <feature>.spec.ts
│ ├── fixtures/
│ │ └── <name>.fixture.ts
│ ├── data/
│ │ └── <dataType>.ts
│ └── utils/
│ └── <name>.ts
│
├── smoke/
│ ├── pages/
│ │ └── <FeatureName>Page.ts
│ ├── specs/
│ │ └── <feature>.spec.ts
│ └── data/
│ └── smokeConfig.ts
│
├── api/
│ └── <api-version>/ ← required when the API is versioned (e.g. 2025-06-09, v2)
│ ├── specs/ omit version folder only for unversioned APIs
│ │ └── <domain>/
│ │ └── <feature>.spec.ts
│ ├── helpers/
│ │ ├── <domain>/
│ │ │ └── <scope>-helpers.ts ← scoped helpers (e.g. evm-helpers.ts)
│ │ └── request.ts ← generic HTTP client, shared across all api/
│ └── data/
│ └── apiConstants.ts
│
├── sdk/
│ ├── specs/
│ │ └── <feature>.spec.ts
│ ├── fixtures/
│ │ └── <name>.fixture.ts
│ └── helpers/
│ └── <name>-helpers.ts
│
└── shared/
├── constants/
│ └── globalConstants.ts
├── fixtures/
│ └── commonFixtures.ts
└── utils/
└── pollAndWaitFor.ts
Key paths:
| Artifact | Path |
|---|---|
| Spec file | tests/<suite>/specs/<domain>/<feature>.spec.ts (API tests: tests/api/<version>/specs/<domain>/<feature>.spec.ts) |
| Page Object | tests/<suite>/pages/<FeatureName>Page.ts |
| Scoped helper | tests/api/<version>/helpers/<domain>/<scope>-helpers.ts |
| Generic HTTP client | tests/api/<version>/helpers/request.ts |
| Test data | tests/<suite>/data/<dataType>.ts |
| Fixture | tests/<suite>/fixtures/<name>.fixture.ts |
| Utility | tests/<suite>/utils/<name>.ts |
| Shared code | tests/shared/ |
API versioning: when the API under test is versioned, the version segment is required so specs for different versions can coexist:
tests/api/2025-06-09/specs/wallets/delegated-signers.spec.ts
tests/api/v1-alpha/specs/wallets/wallets.spec.ts
Playwright config points to the tests/ root:
testDir: path.join(__dirname, "tests");| Type | Convention | Example |
|---|---|---|
| Spec files | kebab-case.spec.ts |
delegated-signers.spec.ts |
| Page Objects | PascalCasePage.ts |
CollectionTablePage.ts |
| Scoped helpers | kebab-case-helpers.ts |
evm-helpers.ts, solana-helpers.ts |
| Generic client | request.ts |
request.ts |
| Data / constants | camelCase.ts |
apiConstants.ts, users.ts |
| Utilities | camelCase.ts |
pollAndWaitFor.ts |
| Fixtures | camelCase.fixture.ts |
auth.fixture.ts |
Functions — camelCase
async function createSmartWallet(config: WalletConfig): Promise<Wallet> { ... }
async function loginWithJwt(userIdentifier: string): Promise<void> { ... }
async function waitForTransactionSuccess(walletAddress: string, txId: string): Promise<void> { ... }Constants — UPPER_SNAKE_CASE
export const MAX_RETRY_ATTEMPTS = 5;
export const DEFAULT_TIMEOUT_MS = 60_000;Page Objects — PascalCase
export class BillingTopUpPage { ... }
export class WalletDashboardPage { ... }Single source of truth: data-testid
// Preferred
page.getByTestId("submit-payment-button");
// Acceptable fallback
page.getByRole("heading", { name: "My Assets" });
// Avoid
page.locator(".btn-primary");Selector grouping inside Page Objects:
static readonly SELECTORS = {
ADD_FUNDS_BUTTON: '[data-testid="billing-add-funds-button"]',
SUBMIT_TOPUP_BUTTON: '[data-testid="billing-submit-topup-button"]',
} as const;data-testid format: {feature}-{element-description}
e.g. "billing-add-funds-button", "wallet-creation-submit"
Structure tests as: describe (subject) → describe/when (context) → test (behaviour).
The test name states the behaviour directly — no should/can/does prefix. The verb comes from the action itself.
// Good
test.describe("save button", () => {
test.describe("when fields are missing", () => {
test("is disabled", async () => {});
});
test.describe("when all fields are filled", () => {
test("submits the form", async () => {});
});
});
// Good — flat when context is self-evident from the describe
test.describe("Delegated Signers", { tag: "@critical" }, () => {
test("creates a signer and sends a transaction", async () => {});
test.describe("when the signer is expired", () => {
test("rejects the request with 400", async () => {});
});
});Avoid verb prefixes that add no information:
// Avoid
test("should create a signer and send a transaction", ...);
test("it can create a signer", ...);
test("it returns 400", ...);A test name should read as a sentence when prefixed with its describe chain:
Delegated Signers > when the signer is expired > rejects the request with 400
Tags go on the describe block. Apply to individual tests only when the tag does not apply to the whole suite.
| Tag | Meaning |
|---|---|
@smoke |
Production health checks — must pass before any deploy |
@critical |
High priority, runs on every PR |
@mainonly |
Main branch only (slow or environment-dependent) |
@flaky |
Temporarily disabled in CI — must include a TODO with ticket reference |
Use Playwright's { tag } option — never embed tags inside the string. Embedding tags in the string pollutes reporter output and makes test names harder to read when a test fails.
// Good
test.describe("Wallet Creation", { tag: "@critical" }, () => { ... });
test.describe("Multi-chain Signers", { tag: "@mainonly" }, () => { ... });
// Avoid — tag gets lost in reporter output
test.describe("Wallet Creation @critical", () => { ... });For @flaky, skip conditionally in CI and include a TODO comment:
test("syncs delegated signers across chains", async () => {
test.skip(!!process.env.CI, "TODO(ENG-1234): @flaky — race condition in chain sync");
// test body
});Running by tag:
# Smoke tests only
npx playwright test --grep @smoke
# Critical tests only
npx playwright test --grep @critical
# Exclude slow / environment-specific
npx playwright test --grep-invert "@mainonly"POM applies to e2e/ and smoke/ tests only. API tests use plain exported helper functions.
Use element access for simple, single interactions — it's explicit about what is being interacted with:
// Good — element access
await apiKeysPage.createNewKeyButton.click();
await billingPage.addFundsButton.click();
// Avoid — action wrappers add a naming layer with no value
await apiKeysPage.clickCreateNewKey();
await billingPage.clickAddFunds();Use intent/workflow methods for multi-step flows that would otherwise require the test to know too many implementation details:
// Good — encapsulates a multi-step interaction
await apiKeysPage.createNewKeyForm.fillWithValidData({ name: "my-key" });
await checkoutPage.paymentForm.fillWithCard({ number: "4242...", expiry: "12/26" });
// Avoid — the test should not orchestrate each step manually
await apiKeysPage.keyNameInput.fill("my-key");
await apiKeysPage.keyPermissionsSelect.selectOption("read");
await apiKeysPage.createKeySubmitButton.click();Rule of thumb:
- Single interaction → expose the element:
page.someButton.click() - 3+ steps that always go together → encapsulate as intent method:
page.someForm.fill(data)
export class ApiKeysPage {
// Elements — for simple interactions
readonly createNewKeyButton: Locator;
readonly deleteKeyButton: Locator;
// Sub-objects — for multi-step workflows
readonly createNewKeyForm: CreateNewKeyForm;
constructor(private readonly page: Page) {
this.createNewKeyButton = page.getByTestId("api-keys-create-button");
this.deleteKeyButton = page.getByTestId("api-keys-delete-button");
this.createNewKeyForm = new CreateNewKeyForm(page);
}
}
class CreateNewKeyForm {
constructor(private readonly page: Page) {}
async fillWithValidData({ name }: { name: string }) {
await this.page.getByTestId("api-keys-name-input").fill(name);
await this.page.getByTestId("api-keys-permissions-select").selectOption("read");
await this.page.getByTestId("api-keys-submit-button").click();
}
}// tests/api/2025-06-09/helpers/wallets/evm-helpers.ts
export async function createSmartWallet(adminSignerAddress: Hex): Promise<string> { ... }
export async function createDelegatedSigner(walletAddress: string, delegatedAddress: Hex): Promise<SignatureRequest> { ... }
export async function approveDelegatedSigner(...): Promise<void> { ... }| Convention | |
|---|---|
| E2E | .spec.ts |
| Unit | .test.ts |
| Naming | Same BDD style — describe/when/test, no verb prefix |
| Selectors | Testing Library (getByRole, getByTestId) — same semantic strategy as Playwright UI tests |
- Move file to
tests/<suite>/specs/<domain>/<feature>.spec.ts(API tests:tests/api/<version>/specs/<domain>/<feature>.spec.ts) - Rename file to
kebab-case.spec.ts - Rename data files to
camelCase.ts - Extract inline helpers to
tests/api/<version>/helpers/<domain>/<scope>-helpers.ts - Move shared utils to
tests/shared/utils/ - Rename inline constants to
UPPER_SNAKE_CASE - Convert test descriptions to BDD style:
describe(subject) →when(context) →test(behaviour), no verb prefix - Add
@smoke/@critical/@mainonlytags todescribeblocks - Update
playwright.config.tstestDirtotests/(once per app, on first migration) - Selectors →
data-testidonly (UI tests) - Move selectors into Page Object classes (UI tests)
| Spec file | tests/api/<version>/specs/wallets/delegated-signers.spec.ts |
| Page Object | tests/e2e/pages/WalletDashboardPage.ts |
| Scoped helper | tests/api/<version>/helpers/wallets/evm-helpers.ts |
| Generic client | tests/api/<version>/helpers/request.ts |
| Data file | tests/api/<version>/data/apiConstants.ts |
| Shared util | tests/shared/utils/pollAndWaitFor.ts |
| Function | createSmartWallet() |
| Constant | DEFAULT_CHAIN, MAX_RETRY_ATTEMPTS |
| Selector | data-testid="billing-add-funds-button" |
| Test name | "rejects the request with 400" (no verb prefix) |
| Tags | { tag: "@critical" } { tag: "@smoke" } { tag: "@mainonly" } |