Two kinds of constants have been creeping into our tests: inline data objects
({ title, description, body, tagList }) and URLs. Both want a single home.
This chapter gives them one — a data factory and a typed environment module — and
closes Part 4.
Code for this chapter is tagged
ch-17in the repo:
https://github.com/aktibaba/playwright-qa-course — see
src/fixtures-data/article.tsandsrc/utils/env.ts.
A data factory
Every test that makes an article was spelling out the same fields. A factory
centralizes "what a valid article looks like," bakes in uniqueness, and lets a test
override only the part it's testing:
// src/fixtures-data/article.ts
export interface ArticleInput {
title: string;
description: string;
body: string;
tagList: string[];
}
let seq = 0;
export function articleData(overrides: Partial<ArticleInput> = {}): ArticleInput {
seq += 1;
return {
title: `Test Article ${Date.now()}-${seq}`,
description: "Generated by the article factory",
body: "Article body for automated tests.",
tagList: [], // required by the API (Ch.13)
...overrides,
};
}
Our provisioning util now just defers to it:
// src/utils/scenarios.ts
export async function createArticle(api, overrides: Partial<ArticleInput> = {}) {
const res = await api.post("articles", { data: { article: articleData(overrides) } });
// ...
}
So a test stays focused on intent — makeArticle({ tagList: ["integration"] }) — and
the unique title, valid defaults, and the tagList-is-required rule all live in one
place. Change the article shape once, and every test follows.
Why src/fixtures-data/ (the @data alias) and not a fixture? Because this is
pure data — no page, no lifecycle. Factories are plain functions; the
fixtures that use them own setup and teardown. Keeping them separate is the same
layer discipline from Chapter 10.
A typed environment module
URLs are the other scattered constant. env is the single source of truth, and now
it's multi-environment: choose a target with TEST_ENV, override individual URLs
with WEB_URL / API_URL:
// src/utils/env.ts
export type EnvName = "local" | "ci" | "staging";
const ENVIRONMENTS: Record<EnvName, { webURL: string; apiURL: string }> = {
local: { webURL: "http://localhost:3000", apiURL: "http://localhost:3001/api" },
ci: { webURL: "http://localhost:3000", apiURL: "http://localhost:3001/api" },
staging: { webURL: "https://inkwell-staging.example.com", apiURL: "https://inkwell-staging.example.com/api" },
};
const name = (process.env.TEST_ENV as EnvName) || "local";
const base = ENVIRONMENTS[name] ?? ENVIRONMENTS.local;
export const env = {
name,
webURL: process.env.WEB_URL ?? base.webURL,
apiURL: process.env.API_URL ?? base.apiURL,
} as const;
Now the same suite runs anywhere:
npm test # local (default)
TEST_ENV=staging npm test # against the staging deployment
API_URL=http://host:4000/api npm test # one-off override
The key discipline: only env.ts reads process.env. Tests, Page Objects, and
fixtures import env — never environment variables directly. That keeps
configuration in one auditable place (and is the layer rule from Chapter 10 applied
to config).
Part 4, done
The integration milestone is complete: auth once with storageState, seed via the
API and verify in the UI, and now clean factories and environment config. The suite
is fast, isolated, and portable across environments.
Next up — Part 5: Scaling, Config & CI
Chapter 18 — Multi-environment configuration takes the env module we just built
and wires it into Playwright's project system, so a single config can target several
environments with the right base URLs, retries, and metadata. Tag: ch-18.
Following along? Star the repo
and tell me what your test-data factories generate most.
Top comments (0)