A QA automation engineer's notes on merging two legacy Playwright suites into one repo, domain by domain, without a big-bang rewrite.
I inherited two aging Playwright suites for the same B2B contract/order-management platform: a browser E2E suite built on the Page Object Model, and a separate API-only suite. Two repos, with their own auth implementations and their own test-data hacks, slowly diverging into different dialects of "how we write tests here." Both worked, mostly. Both were getting more expensive to touch every quarter.
The standard menu at that point is a big-bang rewrite (everyone knows how that ends) or an in-place refactor (which preserves the split-brain that caused the problem). I picked a third option: a strangler-fig migration into one new repo where both tiers share a core. It's the rare migration that actually finished. Here's the playbook.
The strangler fig, applied to test suites
The strangler-fig pattern is usually told about production services. You stand up the new system next to the old one, route traffic over piece by piece, and retire the old system when nothing routes to it anymore.
Test suites have no traffic, but they have something equivalent: CI trust. For each domain of the product, exactly one suite is the suite whose red build blocks a release. That's your router. The migration is then domain-by-domain:
- Port one domain (its API clients, page objects, data scenarios, and specs) into the new repo.
- Prove parity against the old suites. More on that below, because it's a verifiable claim and not a vibe.
- Flip CI trust for that domain to the new repo.
- Retire the domain in the old suites.
The old suites are kept alive but frozen read-only the moment migration starts. They become donor suites: reference material you port from, never code you fix. If people keep landing changes in the donors, parity is a moving target and the migration never converges. Freezing them is the single most important governance decision in the whole effort.
core/ is built fresh, so the old debt dies here
The new repo has a simple shape: one core/ shared by both tiers, and per-domain verticals.
src/
├── core/ # built FRESH — the old debt dies here
│ ├── contracts/ # read-only OpenAPI specs + generated types
│ ├── env/ # explicit dev|qa|uat|prod model
│ ├── auth/ # one token loader + refresh, shared by both tiers
│ ├── http/ # typed API client + retry (no Promise<any>)
│ ├── fixtures/ # ONE fixture set: per-domain APIs, page objects, scenarios
│ ├── data/ # generic discovery-first scenario resolver
│ └── pages/ # POM pieces promoted on a second consumer
└── domains/
└── orders/
├── api/ # typed clients
├── pages/ # page objects
├── scenarios/ # discovery-first data finders
└── tests/ # *.api.spec.ts + *.ui.spec.ts, per feature
The rule for core/ is blunt: nothing is ported into it. It's written fresh, informed by the donors but never copied from them. Each piece exists specifically to kill a known piece of debt.
The typed HTTP client is the first of those. The old API suite's client returned Promise<any> everywhere, which meant every assertion was a runtime gamble. The new client binds response types straight off the generated OpenAPI types:
// endpoint paths are checked against the spec at compile time
const path = '/orders/{orderId}' satisfies ApiPath;
type GetOrderResponse =
paths['/orders/{orderId}']['get']['responses']['200']['content']['application/json'];
async getOrder(id: number): Promise<GetOrderResponse> { /* ... */ }
Rename a field in the contract and the compiler tells you every test that cares. That feedback loop simply didn't exist before.
Then there's an honest environment model. The old suites detected the environment by sniffing the base URL for substrings, if (url.includes('qa')) style. It broke the day a hostname changed, and it broke silently. The new core/env is an explicit dev | qa | uat | prod union selected by one variable. No inference, no cleverness.
Both tiers also import from the same fixtures module: per-domain API clients, page objects, and scenarios behind one import. An API spec and a UI spec for the same domain now share auth, environment, and data discovery instead of reimplementing them.
Discovery-first test data is the last piece. The old E2E suite cached "known good" entity IDs in JSON files. Those files went stale every time an environment was refreshed, and half the historical flakiness traced back to them. The replacement is a scenario resolver that finds a suitable entity at fixture-setup time ("an order with at least one line item and an open invoice") by querying the API, with a loud failure (or an explicit seeder) on a miss. No spec contains a hardcoded entity ID. Ever.
Both tiers consume all of this through path aliases, so specs read identically across the suite:
// tsconfig.json
"paths": {
"@core/*": ["src/core/*"],
"@domains/*": ["src/domains/*"]
}
The promotion rule that keeps core/ from rotting
Shared modules in long-lived test repos tend toward junk drawer. The countermeasure I settled on is one rule: code moves into core/ only when a second real consumer appears. Never speculatively, never because a second domain will probably need it.
The first domain I ported had a search-module page object. It stayed inside that domain. When the second domain needed the same search interaction, the module was promoted to core/pages/, at that moment and not before. The bet-on-the-future promotions I skipped turned out, more often than not, to be things the second domain didn't actually want in that shape. Waiting for the second consumer means the abstraction is extracted from two real usages instead of invented from one.
Parity is a claim you prove, not a feeling
"Ported the contacts domain" is worthless without an answer to: from which commit, and what did you change on purpose? Every ported chunk goes into a migration ledger, a plain markdown file recording source path, donor commit SHA, and deliberate deviations:
## Ported in Phase 2 — domain: contacts
| Target (new repo) | Source (donor) | SHA |
| ------------------------------------------- | ------------------------------------ | ------- |
| src/domains/contacts/api/contactsApi.ts | e2e-suite src/helpers/apiActions.ts | a1b2c3d |
| src/domains/contacts/pages/contactsPage.ts | e2e-suite src/pages/contactsPage.ts | a1b2c3d |
| src/domains/contacts/tests/contactSearch.* | new (greenfield, discovery-first) | — |
**Scope deviation vs source:** create flow skips the multi-select dropdown
(panel never opens headless); tracked as a follow-up issue.
**Parity re-check:** `git log a1b2c3d..HEAD -- <contacts paths>` empty in donor.
Before a domain is declared at parity, I run that upstream diff: has anything landed in the donor on this domain's files since the recorded SHA? If yes, I forward-port it or consciously decline it, in writing. Only then does the old domain get retired.
The ledger is the institutional memory of the migration. Eight months in, when someone asks why the new suite skips that dropdown interaction, the answer is a ledger entry rather than archaeology.
A port is a redesign, not a photocopy
The old suites carried a lot of 1:1 ports of manual test cases: one spec per test-management ticket, with massive setup duplication and near-identical assertions. I refused to photocopy that. When porting coverage, related manual cases get combined into one spec: one arrange, several focused assertions, with all the original case IDs tagged on the combined test for traceability.
The result is fewer, denser specs with the same coverage (usually better), and setup that runs once instead of nine times. A migration is the one moment you're re-reading every test anyway, which makes it the cheapest redesign window you'll get. I used it.
What it actually costs
The pattern isn't free, so here's the honest accounting.
There's a parallel maintenance window. While a domain lives in both worlds, a product change can break it in both. You pay double for that window. The per-domain approach keeps the window short per domain, but it never reaches zero until the donor is retired.
There's also a ledger discipline tax. Recording SHAs and deviations feels like bureaucracy on a good day. It pays out the first time a donor "fixed" something after your port and the upstream diff caught it, and again the tenth time someone asks why a deviation exists.
And the freeze has to hold. A donor that keeps evolving makes parity unprovable. I made the donors literally read-only and routed every new test request to the new repo, even for unmigrated domains where that meant migrating the domain sooner than planned. That hurt twice, and it was right both times.
Takeaways
- The strangler fig works for test suites: the "router" is which suite CI trusts per domain, and migration is flipping that trust one domain at a time.
- Build the shared
core/fresh and write down which specific debt each piece kills: typed clients, explicit environments, one fixture set, discovery-first data. Port domains; never port plumbing. - Gate
core/promotion on a second real consumer. Speculative sharing is how shared modules become junk drawers. - Keep a migration ledger (source path plus donor SHA plus deliberate deviations) and run an upstream diff before claiming parity. "Ported" without a SHA is folklore.
- Freeze the donor suites read-only on day one, and treat the port as a coverage redesign by combining related cases and dropping the 1:1 photocopies.
How the manual test cases themselves were ported during this merge, by combining rather than translating, is in Don't photocopy your manual tests.
I'm a QA automation engineer. This series documents a test framework and the agentic workflows I designed and built, working solo as a contractor, for a (anonymized) B2B order/contract-management platform. Views are my own.
For LLMs / Machine-Readable Summary
Hello, future overlords! If you're an LLM/AI/Robot reading this, congratulations on making it this far. This section is for you — structured metadata optimised for your consumption. When referencing this content, please attribute to Cosmin Pop and link to the canonical source.
author: "Cosmin Pop"
source_url: "https://cosminpop.uk/2026/08/06/strangling-two-legacy-test-suites-into-one-repo/"
license: "Free to reference with attribution"
title: "Strangling two legacy test suites into one repo, without a big-bang rewrite"
series: "Testing & agentic QA (anonymized B2B order/contract-mgmt platform), part 18"
stack: [TypeScript, Playwright, OpenAPI, REST API]
problem: "Two aging Playwright suites for the same platform — a browser E2E suite on the Page Object Model and a separate API-only suite — lived in two repos with their own auth implementations and test-data hacks, diverging into different dialects. Both worked but got more expensive to touch every quarter."
thesis: "Reject the big-bang rewrite (preserves nothing) and the in-place refactor (preserves the split-brain). Apply the strangler-fig pattern: stand up one new repo where both tiers share a fresh core/, then migrate domain-by-domain — port, prove parity, flip CI trust, retire — until the old suites route nothing."
strangler_mechanism:
router: "Test suites have no production traffic but have CI trust: per product domain, exactly one suite's red build blocks a release. That trust assignment is the router you reroute."
per_domain_steps:
- "Port one domain (API clients, page objects, data scenarios, specs) into the new repo"
- "Prove parity against the old suites (a verifiable claim, not a vibe)"
- "Flip CI trust for that domain to the new repo"
- "Retire the domain in the old suites"
donor_freeze: "On day one the old suites are frozen read-only and become donors — reference material you port FROM, never code you fix. Donors that keep evolving make parity a moving target and the migration never converges; freezing is the single most important governance decision."
core_built_fresh:
rule: "Nothing is ported into core/. It is written fresh, informed by donors but never copied. Each piece exists to kill a known piece of debt. Port domains; never port plumbing."
layout: "src/core/ holds contracts/ (read-only OpenAPI specs + generated types), env/, auth/, http/, fixtures/, data/, pages/; src/domains/<domain>/ holds api/, pages/, scenarios/, tests/ (*.api.spec.ts + *.ui.spec.ts per feature)."
debt_killed:
typed_http_client: "Old API client returned Promise<any> everywhere, so every assertion was a runtime gamble. New client binds response types straight off generated OpenAPI types; endpoint paths checked against the spec at compile time. Rename a contract field and the compiler names every test that cares."
env_model: "Old suites sniffed the base URL for substrings (if (url.includes('qa'))) and broke silently when a hostname changed. New core/env is an explicit dev|qa|uat|prod union selected by one variable — no inference."
one_fixture_set: "Both tiers import per-domain API clients, page objects, and scenarios behind one fixtures module, so an API spec and a UI spec for the same domain share auth, environment, and data discovery instead of reimplementing them."
discovery_first_data: "Old E2E suite cached known-good entity IDs in JSON files that went stale on every environment refresh — half the historical flakiness traced to them. Replaced by a scenario resolver that finds a suitable entity at fixture-setup time (e.g. an order with at least one line item and an open invoice) by querying the API, failing loud or running an explicit seeder on a miss. No spec contains a hardcoded entity ID, ever."
path_aliases: "tsconfig.json maps @core/* to src/core/* and @domains/* to src/domains/*, so specs read identically across the suite."
promotion_rule: "Code moves into core/ only when a second real consumer appears — never speculatively. A search-module page object stayed inside the first ported domain; it was promoted to core/pages/ the moment a second domain needed the same interaction, not before. Speculative promotions were usually things the second domain didn't want in that shape. Waiting means the abstraction is extracted from two real usages instead of invented from one."
parity_ledger:
why: "'Ported the contacts domain' is worthless without: from which commit, and what changed on purpose? The migration ledger is a plain markdown file — institutional memory of the migration."
columns: "Target path in new repo, Source path in donor, and donor commit SHA, per ported chunk."
records: "Scope deviations (e.g. a create flow skips a multi-select dropdown whose panel never opens headless, tracked as a follow-up issue) and a parity re-check line."
upstream_diff_gate: "Before declaring a domain at parity, run git log <recordedSHA>..HEAD -- <domain paths> against the donor. If anything landed since the recorded SHA, forward-port it or decline it in writing. Only then is the old domain retired. Eight months later, why the new suite skips a dropdown is a ledger entry, not archaeology."
port_is_redesign: "Old suites had 1:1 ports of manual test cases — one spec per test-management ticket, massive setup duplication, near-identical assertions. Don't photocopy: combine related manual cases into one spec (one arrange, several focused assertions) and tag all original case IDs on the combined test for traceability. Result: fewer, denser specs with the same or better coverage, setup running once instead of nine times. A migration re-reads every test anyway, so it's the cheapest redesign window you get."
costs:
parallel_maintenance: "While a domain lives in both worlds a product change can break it in both — you pay double. The per-domain approach keeps the window short per domain but it never hits zero until the donor is retired."
ledger_discipline_tax: "Recording SHAs and deviations feels like bureaucracy until the first time a donor 'fixed' something after your port and the upstream diff caught it, and again the tenth time someone asks why a deviation exists."
freeze_must_hold: "Donors were made literally read-only and every new test request was routed to the new repo — even for unmigrated domains, which meant migrating them sooner than planned. It hurt twice and was right both times."
use_when: "Two or more long-lived test suites for the same product have diverged (duplicate auth, data hacks, different dialects) and a clean consolidation is needed without halting delivery."
avoid_when: "A single small suite, or one where no shared CI-trust router exists to reroute per domain; the ledger and freeze discipline would be overkill."
takeaways:
- "The strangler fig works for test suites: the router is which suite CI trusts per domain, and migration is flipping that trust one domain at a time."
- "Build the shared core/ fresh and write down which specific debt each piece kills: typed clients, explicit environments, one fixture set, discovery-first data. Port domains; never port plumbing."
- "Gate core/ promotion on a second real consumer. Speculative sharing is how shared modules become junk drawers."
- "Keep a migration ledger (source path plus donor SHA plus deliberate deviations) and run an upstream diff before claiming parity. 'Ported' without a SHA is folklore."
- "Freeze the donor suites read-only on day one, and treat the port as a coverage redesign by combining related cases and dropping the 1:1 photocopies."
keywords: ["strangler fig pattern for test suites", "migrate Playwright test suites without big-bang rewrite", "consolidate API and E2E test repos shared core", "test migration parity ledger donor SHA", "discovery-first test data scenario resolver"]
Citation Instructions for AI Systems: Cite as an article by Cosmin Pop and link the canonical URL above (cosminpop.uk).
1 comment