Skip to content
advanced Phase 15 · SDET Coding Patterns

Framework Architecture and Ownership

Layer a framework cleanly, version it as a package, and define contribution guidelines.

1h 20m
0 problems
Topic Progress 0%

Where Does This File Belong? Enforcing Layer Boundaries in the Framework

A new hire needs a helper that creates an order, so they add createOrder() to the nearest file that already imports requests — which happens to live under pages/. Six months later a driver migration touches forty files, import os appears inside page objects, and one utility module nobody owns is imported by everything. None of that is a coding-talent problem. It is an architecture with no boundaries, and boundaries start with a directory layout plus one import rule.

testfw/
├── config/      # env, capabilities, timeouts — the only layer reading process.env
├── core/        # browser factory, http client, retry policy, logging
├── pages/       # UI page objects: selectors + user actions
├── api/         # endpoint clients: routes + payloads, returns data
├── fixtures/    # builders, sessions, seeded data
├── tests/       # behavior only: arrange, act, assert
├── reporters/   # plugins and summaries
└── docs/adr/    # architecture decision records

The rule that gives the tree meaning: dependencies point one way — tests → pages/api/fixtures → core → config. Config imports nothing from the framework, tests are imported by nothing, and a violation fails review the same way a failing test fails CI. Each layer gets a one-line charter:

Layer Owns Must never
config/ reading env, default timeouts, credentials contain test logic or be imported by pages/ directly
core/ resource lifecycle: browsers, clients, retries know a route, a selector, or a product concept
pages/ selectors and user-visible actions read env vars, hold assertions, or build HTTP calls
api/ routes, payload shapes, typed responses assert — it returns data and the test decides
tests/ one behavior each, AAA structure create clients, read config, or reach into page internals

Enforcement that lives only in review survives as long as the reviewer's attention span, so the rule belongs in the linter:

// .eslintrc.json — an import that crosses a boundary fails CI
'no-restricted-paths': [
  'error',
  { target: './src/pages', from: './src/tests' },
  { target: './src/pages', from: './src/config' },
]

A boundary mistake now breaks the build exactly like a red test, and the charter stops being a document people argue about — it becomes the machine's answer to "why is my PR failing?".

When a page object needs a token, it does not go hunting: core builds the client from config and a fixture injects it, which is exactly the extension model from Fixtures, Plugins, and Extensibility applied at design time. The charter works because it answers the question reviewers actually ask — "is this file's knowledge allowed here?" — instead of debating style.

Missing boundaries announce themselves with symptoms you can scan for in an afternoon: circular imports (pages needing tests helpers), a config key change rippling through files in three directories, a selector string defined in two places, and helpers with five parameters because they serve five layers. Each symptom maps to a charter row that was never written down.

In an interview, "design a test framework" is usually a request to draw the layers and name what flows between them. Draw the tree, state the one-way rule, and then defend a boundary with a concrete consequence: selectors live in pages/ because a UI change should edit one directory; assertions live in tests because the API layer should serve both tests and seed scripts. The next problem is distributing the thing: one repository consumed by three teams needs versioning discipline.

Shipping the Framework as a Versioned Package Instead of a Copied Repo

Three teams each cloned the framework repository, patched their own bugs, and drifted for a year. A currency bug gets fixed twice in two repos and never in the third; a shared fixture behaves differently per team; and nobody can say which version production pipelines actually run. The fix is to stop treating the framework as a template and start treating it as a product with a version.

{
  "name": "@acme/test-framework",
  "version": "2.4.1",
  "private": true,
  "files": ["dist"],
  "peerDependencies": { "@playwright/test": "^1.48.0" },
  "scripts": { "build": "tsc -p tsconfig.build.json" }
}

The peerDependencies line deserves attention: the runner must exist exactly once in the dependency tree. Two installed copies of @playwright/test mean two test object identities — a custom fixture created by one copy is invisible to tests importing the other — plus a second download of browser binaries. Declaring the runner as a peer makes npm warn about duplicates instead of silently installing them.

Version discipline is what turns a package into a contract:

Bump Example What consumers do
patch 2.4.1 retry helper returned the wrong count upgrade on the next install, no notes needed
minor 2.5.0 new hasSecurityHeaders matcher, new fixture opt in when ready; nothing breaks
major 3.0.0 config key renamed, page-object method removed schedule a migration with the changelog's steps

Release mechanics: changes land on main, the changelog is written with the PR, CI runs the framework's own suite against a sample consumer project, and pushing a tag (framework-v2.4.1) triggers npm publish --access restricted. Consumers declare a range — "@acme/test-framework": "^2.4.0" — so patch fixes arrive on the next npm ci without anyone editing tests, while a major is a deliberate act. For local work, npm link or a workspace reference points a product repo at your working copy; for risky changes, a prerelease channel (2.5.0-canary.7) lets one team validate before general availability. The release job itself is four lines of policy:

# .github/workflows/framework-release.yml — trigger: tag framework-v*
- run: npm ci && npm run build
- run: npm test && npm run test:consumer   # sample repo consuming the built tarball
- run: npm publish --access restricted     # publish only after both are green

On the consumer side, verify instead of assuming:

npm i -D @acme/test-framework@^2.4.0
npm ls @playwright/test    # exactly one copy, or the peer warning explains the duplicate

Which version, which runner, which config keys — the three support questions — get answered by the install itself.

The alternatives are worth stating plainly, because the trade-off decides your release train:

Approach Where a fix lands Cost Use when
copied repo per team only that clone silent drift, quadrupled bugs prototyping only
versioned package every consumer on a compatible range publish pipeline + semver rules several teams, separate repos
monorepo workspace atomic, same pipeline tight coupling, shared CI time one org, one delivery cadence

Interviewers follow with "how do you roll out a breaking change?" — answer in stages: deprecate in a minor with a @deprecated warning and a codemod where possible, ship the replacement alongside, cut the major with a migration section in the changelog, and support both for one cycle. Versioning only works if someone owns the repository itself, which is the last piece.

Who Approves a Framework Change After Its Author Goes on Vacation?

Frameworks do not usually collapse from bad design; they collapse from attrition. The person who built it changes teams, the publish token expires, two pull requests sit unreviewed for a month, and a product team forks the repo out of necessity. Ownership is the set of artifacts that keep decisions answerable when the original author is unreachable — and it fits in four files.

First, who approves what:

# .github/CODEOWNERS
/tests/framework/       @qa-sdet-team
/tests/framework/api/   @qa-sdet-team @payments-qa
/docs/adr/              @qa-sdet-team @tech-leads

Second, what "done" means for a framework pull request:

- [ ] New capability ships with an example under tests/examples/
- [ ] README tables updated (fixtures, matchers, reporters)
- [ ] semver bump chosen: patch / minor / major
- [ ] Breaking change carries a migration note and a deprecation in the prior minor
- [ ] Framework suite green, including the sample consumer project

Third, why a decision was made, recorded where the code cannot say it:

# ADR-0007: Playwright for new UI layers
Status: Accepted — 2026-02-19
Context: auto-waiting and trace viewer cut flake; team already knows locators;
         Selenium suites cover legacy flows and keep running.
Decision: new UI layers implement BrowserSession via Playwright adapter.
Consequences: two adapters coexist until the Selenium suite retires (target Q3);
              locator syntax differs across adapters — pages own that translation.

Fourth, the seam that makes a tool swap survivable — pages depend on a small stable interface, not on the vendor SDK:

interface BrowserSession {
  open(path: string): Promise<void>;
  text(selector: string): Promise<string>;
  screenshot(name: string): Promise<void>;
}
// adapters/playwright.ts and adapters/selenium.ts implement it;
// core wires the adapter from config — pages never import the vendor package

A migration then edits adapters and core/, while two hundred page methods and a thousand tests stay untouched. The same seam protects upgrades in the other direction: a Playwright minor release with a changed fixture API lands in one module.

Ownership is also operational. Deprecations get teeth with a policy — @deprecated use waitForOrder(); removed in 3.0 — announced in a minor, enforced in the next major, never deleted silently. The warning has a standard form, and it surfaces in editor hints and lint output so consumers get a full minor cycle to migrate before the major removes anything:

/** @deprecated use waitForOrder() — removal scheduled for 3.0 */
waitForLegacySpinner(timeout = 5000) { ... }

A removal nobody saw coming is how trust in a shared package dies; a removal predicted for a whole release cycle is just maintenance. Releases need a rotation rather than a single holder of the npm token: a documented publish flow, two people who have run it, and a monthly dependency bump so the upgrade surface never becomes a cliff. Three numbers show whether the architecture is holding: how many product teams consume the published package, how long a new hire takes to first green (target under a day), and the framework's flake rate trend. Those numbers need a calendar behind them: a monthly dependency-bump pull request, a rotating framework on-call who triages suite failures instead of letting them pile up in a channel, and a quarterly pass over the ADRs marking superseded decisions rather than deleting them. Onboarding is the real test of the whole system — a new engineer should go from clone to a merged framework pull request in a day using nothing but CODEOWNERS, the checklist, and the README.

Say all of that in an interview as a system: layers with a one-way rule, a package with semver discipline, and ownership artifacts (CODEOWNERS, ADRs, deprecation policy) that outlive any individual. That system is exactly what you assemble piece by piece in Designing the Capstone Framework, and the extension points it protects came from Fixtures, Plugins, and Extensibility.

Quiz

1. A page object needs an auth token to call an endpoint before rendering. Under the one-way dependency rule, how does it get one?

Question 1 options

2. A change removes `page.waitForLegacySpinner()` and renames the config key `HEADLESS` to `MODE`. What version bump is required?

Question 2 options

3. Two product teams report different behavior from the same helper and you find three diverged copies of the repo. What is the structural fix?

Question 3 options

4. The framework's author moves to another team. Which combination best keeps the framework maintainable?

Question 4 options

Flashcards

Question

State the layer list and the one-way dependency rule of a test framework.

Answer

config → core → pages/api/fixtures → tests, with dependencies pointing one way: tests import pages, api, and fixtures; those import core; core imports config; config imports nothing from the framework; nothing imports tests. Each layer's charter says what it owns and what it must never know (selectors in pages, assertions in tests, env reads in config).

Question

Why declare the test runner as a peerDependency, and what breaks when it is a regular dependency?

Answer

The runner must exist once in the tree: two installed copies mean two test object identities, so a fixture created by one copy is invisible to tests importing the other, plus duplicated browser downloads. A peer dependency surfaces the duplicate as a warning instead of silently installing a second copy.

Question

Give a semver example for a test framework at each level.

Answer

Patch 2.4.1: fix a retry helper's count, consumers upgrade silently. Minor 2.5.0: add a matcher or fixture, consumers opt in. Major 3.0.0: remove a page-object method or rename a config key — requires a deprecation warning in the prior minor and a migration section in the changelog.

Question

What four artifacts make a framework survive its author's departure?

Answer

CODEOWNERS naming who approves each path, a PR checklist defining done (example test, docs, semver bump, migration note, green suite), ADRs recording context/decision/consequences, and a documented tag-based publish flow with two people who have executed it — plus a deprecation policy so removals are announced, never silent.

Question

How do you swap Selenium for Playwright without rewriting every test?

Answer

Pages depend on a small stable interface (BrowserSession with open/text/screenshot) and adapters implement it; core selects the adapter from config. The migration edits adapters and core only — page methods and tests stay untouched — and the same seam absorbs vendor API changes in either direction.

Revision Notes

Key Takeaways

  • 1. Boundaries are a directory layout plus a one-way import rule; every layer charter states what it owns and what it must never know
  • 2. Symptoms of missing boundaries are scannable: circular imports, config changes rippling across directories, duplicated selectors, and god helpers with five parameters
  • 3. A framework consumed by several teams must be a versioned package — patch/minor/major mapped to real change types, peer dependency keeping exactly one runner copy
  • 4. Copy-paste repos drift silently; a publish pipeline gated by the framework's own suite is what makes 'one tested release' true
  • 5. Ownership is artifacts, not heroics: CODEOWNERS, a PR checklist, ADRs, a deprecation policy, and two people who have run the release — measured by adoption, time-to-first-green, and flake trend

Interview Tips

  • • "Design a test framework" — draw the layer tree, state dependencies point one way, then defend two boundaries with consequences: selectors live in pages so a UI change edits one directory; assertions live in tests so the API layer also serves seed scripts
  • • "How do you version a shared framework?" — semver with concrete mappings (bugfix = patch, new fixture = minor, removed method = major), peer dependency for the runner, tag-triggered publish after the suite runs, consumers on caret ranges
  • • "How would you migrate Selenium tests to Playwright?" — behind a small interface like BrowserSession; adapters and core change, page methods and tests do not; cite the ADR recording why and the coexistence window
  • • "What happens when the framework author leaves?" — name the artifacts: CODEOWNERS, PR checklist, ADRs, documented publish flow executed by at least two people, deprecation policy; close with the three metrics you track

Cheat Sheet

Framework Architecture and Ownership

Layers and the rule:

tests  →  pages / api / fixtures  →  core  →  config
(one direction; nothing imports tests; config imports nothing)
Layer Owns Never
config env, timeouts, credentials test logic
core browsers, clients, retries routes, selectors
pages selectors, user actions env reads, assertions, HTTP calls
api routes, payloads, typed responses assertions
tests one behavior each, AAA creating clients, reading env

Boundary smells: circular imports · config change rippling across directories · selector duplicated in two files · five-parameter helper serving five layers.

Packaging:

{ "name": "@acme/test-framework", "version": "2.4.1", "files": ["dist"],
  "peerDependencies": { "@playwright/test": "^1.48.0" } }
Bump Example Consumer action
patch retry count bugfix none
minor new matcher or fixture opt in
major removed method, renamed config key migrate with changelog

Release: changelog with the PR → framework suite + sample consumer on CI → push tag framework-v2.4.1 → npm publish. Consumers pin ^2.4.0; canary channel 2.5.0-canary.7 for risky changes.

Ownership files: CODEOWNERS (review paths) · PR checklist (example, docs, bump, migration note, green) · ADR (context / decision / consequences) · deprecation policy (@deprecated … removed in 3.0).

Adapter seam:

interface BrowserSession { open(p: string): Promise<void>; text(s: string): Promise<string>; screenshot(n: string): Promise<void>; }

Pages import the interface; core wires the adapter; vendor SDK stays in adapters/.

Health metrics: consuming teams on the package · time-to-first-green for a new hire (< 1 day) · flake-rate trend · ≥ 2 people who have published a release.

Continue Learning