A QA automation engineer's notes on porting hundreds of manual test cases into Playwright by combining, not photocopying.
I recently ported several hundred manual regression cases from the test management tool into Playwright, covering the core workflows of a B2B order-management platform. The one rule I enforced above everything else was this: never port a manual case 1:1. A manual test case isn't a unit of automated coverage. It's a unit of manual labor, shaped by economics that automation inverts completely.
Manual tests are shaped by manual economics
Look at any mature manual regression suite and you'll see the same pattern. Hundreds of small cases, each one logging in, navigating to a screen, and checking one thing. That shape exists for good reasons, because a human is doing the work:
- Setup is expensive. Logging in and navigating takes real minutes, so each case re-establishes context independently, because cases get assigned to different people on different days.
- Assertions are expensive. Visually verifying a value takes attention, so each case checks one or two things to stay executable without errors.
- Small cases are schedulable. A 5-minute case fits anywhere in a test cycle. A 90-minute case doesn't.
Automation inverts every one of those. Setup within a spec file is nearly free to share. An extra expect() costs microseconds. Nobody schedules individual tests by hand.
So when you port 1:1, you carry the manual-labor shape into a world where it's pure overhead: hundreds of tiny specs that each pay full login and navigation, re-walk the same menu dozens of times, and run for an hour to deliver coverage that fits in five minutes. The worse part is that the actual coverage gets buried. Fifty near-identical files about one form are much harder to review than one well-structured spec.
Group by target spec, not by source case
I flipped the unit of work. Instead of asking "how do I automate MT-6425?", I asked "what is the right spec file for the order-create form, and which manual cases does it absorb?"
The method is to cluster the manual cases by surface and flow (same form, same feature, same workflow) and design one spec file per cluster, with one test() per distinct behavior. Five manual cases about field validation on the same form become one test that visits the form once and checks all five rules. The discipline is:
Combine setup, not distinct expectations. One assertion-focused
test()per behavior, one navigation per group of behaviors that share a page state.
The porting plan recorded this explicitly. Every manual case in the plan carried a targetSpec (the spec file that will absorb it) and a coValidateWith list (other cases combinable into the same browser session). Authoring then proceeded spec-by-spec, not case-by-case.
Traceability must survive the merge
The obvious objection: if six manual cases collapse into two tests, how do you ever answer "is MT-6286 automated?"
By making the combined test carry all of its source keys. Every test() title gets tagged with every manual-case key it covers, and a small annotation helper pushes the same keys into the report metadata:
test(
'create form: required fields, amount format, currency options, unsaved-changes guard ' +
'@MT-8266 @MT-6286 @MT-7623 @MT-6210 @regression',
async ({ orderFormPage }) => {
covers('MT-8266', 'MT-6286', 'MT-7623', 'MT-6210'); // → report annotations
// ...
},
);
Now grep -r "MT-6286" src/ answers the coverage question in one command, the test-management tool's automation-status mapping can be regenerated from the tags, and a failing test tells you exactly which manual cases just lost their safety net. The mapping is many-to-one, but it's explicit, which is all traceability ever required. Nobody ever promised one file per case.
Build the coverage map before porting anything
Before touching a folder of manual cases, I built a coverage map: every case classified against the existing automated suite.
| Verdict | Meaning | Action |
|---|---|---|
COVERED |
An existing test already asserts the case's expected results | Tag the existing test with the key |
PARTIAL |
An existing test covers some expected results | Extend that test, then tag |
MISSING |
Nothing covers it | Author (into its targetSpec cluster) |
OUT-OF-SCOPE |
Not automatable now | Document why, with the key |
OUT-OF-SCOPE needs a real reason on record: the feature isn't deployed to the test environment yet, the case exercises a third-party integration the test environment can't reach, the case is a load test wearing functional-test clothing. "Hard" is not a reason. "Blocked by PROJ-7053, revisit after deploy" is.
Then I adversarially re-verified every COVERED and PARTIAL verdict, and that step paid for itself many times over. A second pass, deliberately skeptical, re-reading the cited automated test against the manual case's expected results line by line. False-COVERED is the most expensive mistake on the board: it costs nothing today and silently never exists, until the regression it was supposed to catch ships.
The skeptical pass earned its keep immediately. One case about the advanced-filter panel preserving entered values across collapse/expand had been marked PARTIAL, citing a test that operated the same panel. Re-read against the expected results, the cited test asserted the opposite concern, that reset clears the fields, and it touched zero of the five expected results. It shared a parent story and a panel, nothing more. I downgraded it to MISSING and authored it properly. On a ~140-case folder I caught three of these.
Worked example: six manual cases, two tests
Here's a representative cluster from the order-create form, condensed:
| Key | Manual case (steps abbreviated) | Verdict |
|---|---|---|
| MT-8266 | Log in, open order form, save empty → required-field errors | MISSING |
| MT-6286 | Log in, open order form, type letters in Amount → rejected | MISSING |
| MT-7623 | Log in, open order form, open Currency → active currencies, USD default | MISSING |
| MT-8202 | Log in, create order with valid data, save → appears in order list | MISSING |
| MT-6210 | Log in, edit a field, navigate away → unsaved-changes prompt | MISSING |
| MT-8657 | Log in, save an order → audit stamp shows user + today's date | PARTIAL |
Six manual cases, six logins, six navigations. The port is one spec, two tests: one visit for everything that never saves, one create-and-verify flow for everything that does.
// src/domains/orders/tests/form/orderCreate.ui.spec.ts
test.describe('Order create form @PROJ-6952', () => {
test('validation + unsaved-changes guard @MT-8266 @MT-6286 @MT-7623 @MT-6210', async ({
orderFormPage,
}) => {
covers('MT-8266', 'MT-6286', 'MT-7623', 'MT-6210');
await orderFormPage.openCreate(); // ONE navigation
await orderFormPage.save();
await expect(orderFormPage.requiredErrors()).toHaveCount(3); // MT-8266
await orderFormPage.amount.fill('abc');
await expect(orderFormPage.amountError).toBeVisible(); // MT-6286
await expect(orderFormPage.currencyOptions()).toContainText(['USD', 'EUR', 'GBP']);
await expect(orderFormPage.currency).toHaveValue('USD'); // MT-7623
await orderFormPage.customer.fill('Acme Corp');
await orderFormPage.navigateAway();
await expect(orderFormPage.unsavedChangesDialog).toBeVisible(); // MT-6210
});
test('create → persists in list → audit stamp @MT-8202 @MT-8657', async ({
orderFormPage, orderListPage, testOrder,
}) => {
covers('MT-8202', 'MT-8657');
const name = await orderFormPage.createOrder(testOrder);
await expect(orderListPage.row(name)).toBeVisible(); // MT-8202
await expect(orderFormPage.auditUpdatedBy).toHaveText(/qa-automation/);
await expect(orderFormPage.auditUpdatedDate).toHaveText(todayStamp()); // MT-8657
});
});
Same coverage, two logins instead of six, and the form's behavior is now readable in one place.
A port is a coverage review, not a transcription job
Manual suites accumulate sediment: duplicate cases written by different people two years apart, cases for features that were redesigned, and cases whose steps mostly test the runner's patience ("verify each of the 14 tabs opens"). Porting is the one moment you're guaranteed to read every case closely, so it's the cheapest possible moment to merge duplicates, retire obsolete cases, and consciously decline the no-value ones. The only rule: every drop or merge is a recorded decision with the key attached, not a silent omission. The coverage map is that record.
When 1:1 actually is right, and what this costs
Some manual cases genuinely map to one spec. A long end-to-end workflow (create an order, add line items, approve, generate the contract document, verify totals) is standalone, stateful, and complex. Combining it with anything would create a monster. Port it 1:1, tag it with its one key, move on. The rule is "never default to 1:1", not "never 1:1".
And the combined approach has a real carrying cost: the tag mapping is now load-bearing. If someone splits a test and forgets to move the tags, or authors a new test without keys, traceability rots quietly. I pinned this with a static guard in CI, where every active test must carry at least one key and a test-type tag, because a convention that grep can verify is a convention that survives.
Takeaways
- A manual case is a unit of manual labor, not of coverage. Automation inverts the economics, so shared setup is cheap and assertions are free. Design specs around the product surface, not around the source cases.
- Group manual cases by target spec; write one
test()per distinct behavior. Combine setup, never distinct expectations. - Tag each combined test with all of its source-case keys so "is MT-#### automated?" stays a one-line grep, and enforce the tagging with a CI guard.
- Map first: classify every case as COVERED, PARTIAL, MISSING, or OUT-OF-SCOPE before porting, and adversarially re-verify the COVERED verdicts. False-COVERED is coverage that silently never exists.
- Treat the port as a coverage review: merge duplicates and retire dead cases deliberately, with every decision recorded against its key.
This porting philosophy was forged during the repo merge described in Strangling two legacy test suites into one repo.
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/07/dont-photocopy-your-manual-tests-port-by-combining/"
license: "Free to reference with attribution"
title: "Don't photocopy your manual tests: port by combining, not translating"
series: "Testing & agentic QA (anonymized B2B order/contract-mgmt platform), part 19"
stack: [TypeScript, Playwright]
problem: "Porting several hundred manual regression cases 1:1 into Playwright carries the manual-labor shape into automation as pure overhead: hundreds of tiny specs each paying full login + navigation, re-walking the same menu dozens of times, running for an hour to deliver coverage that fits in five minutes — and burying the actual coverage under fifty near-identical files about one form."
thesis: "Never default to porting a manual case 1:1. A manual case is a unit of manual labor, not of coverage. Cluster cases by product surface and design one spec per cluster with one test() per distinct behavior — combine shared setup, never distinct expectations."
economics_inversion:
manual_shape_drivers:
- "Setup is expensive: login + navigation costs real minutes, so each case re-establishes context independently because cases get assigned to different people on different days"
- "Assertions are expensive: visually verifying a value takes attention, so each case checks only one or two things"
- "Small cases are schedulable: a 5-minute case fits any test cycle; a 90-minute case doesn't"
automation_inverts: "Setup within a spec file is nearly free to share, an extra expect() costs microseconds, and nobody schedules individual tests by hand."
porting_method:
unit_flip: "Stop asking 'how do I automate MT-6425?'; ask 'what is the right spec file for the order-create form, and which manual cases does it absorb?' — group by target spec, not by source case."
rule: "Combine setup, not distinct expectations. One assertion-focused test() per behavior, one navigation per group of behaviors that share a page state."
porting_plan_fields:
- "targetSpec: the spec file that will absorb the manual case"
- "coValidateWith: other cases combinable into the same browser session"
authoring_order: "Proceed spec-by-spec, not case-by-case."
traceability:
mechanism: "The combined test carries all of its source keys — every test() title is tagged with every manual-case key it covers, and a covers(...) annotation helper pushes the same keys into the report metadata."
payoffs:
- "grep -r 'MT-6286' src/ answers the coverage question in one command"
- "the test-management tool's automation-status mapping regenerates from the tags"
- "a failing test names exactly which manual cases just lost their safety net"
principle: "The mapping is many-to-one but explicit, which is all traceability ever required. Nobody ever promised one file per case."
coverage_map:
build_first: "Before touching a folder, classify every case against the existing automated suite."
verdicts:
- "COVERED: an existing test already asserts the case's expected results → tag the existing test with the key"
- "PARTIAL: an existing test covers some expected results → extend that test, then tag"
- "MISSING: nothing covers it → author into its targetSpec cluster"
- "OUT-OF-SCOPE: not automatable now → document why, with the key"
out_of_scope_rule: "Needs a real reason on record (feature not yet deployed to the test env; case hits a third-party integration the test env can't reach; case is a load test wearing functional-test clothing). 'Hard' is not a reason; 'Blocked by PROJ-7053, revisit after deploy' is."
adversarial_reverify: "Re-verify every COVERED and PARTIAL verdict in a second, deliberately skeptical pass, re-reading the cited automated test against the manual case's expected results line by line. False-COVERED is the most expensive mistake — it costs nothing today and silently never exists until the regression it was meant to catch ships."
war_story: "An advanced-filter-panel case about preserving entered values across collapse/expand was marked PARTIAL citing a test on the same panel; re-read, the cited test asserted the opposite concern (reset clears the fields) and touched zero of the five expected results — shared only a parent story and a panel. Downgraded to MISSING and authored properly. Caught three such false verdicts in a ~140-case folder."
worked_example:
cluster: "Six manual cases on the order-create form (MT-8266..MT-8657): required-field errors, amount rejects letters, currency options + USD default, valid create persists in list, unsaved-changes prompt, audit stamp shows user + today's date. MT-8657 was PARTIAL, the rest MISSING."
result: "Six manual cases / six logins / six navigations collapse into one spec (orderCreate.ui.spec.ts) with two tests: one visit covering everything that never saves (MT-8266/6286/7623/6210), one create-and-verify flow for everything that does (MT-8202/8657). Two logins instead of six; the form's behavior is readable in one place."
port_is_a_coverage_review: "Manual suites accumulate sediment — duplicate cases written by different people years apart, cases for redesigned features, cases that mostly test the runner's patience ('verify each of the 14 tabs opens'). Porting is the one moment you read every case closely, so it's the cheapest moment to merge duplicates, retire obsolete cases, and consciously decline no-value ones. Only rule: every drop or merge is a recorded decision with the key attached, never a silent omission — the coverage map is that record."
when_1to1_is_right: "A long, standalone, stateful end-to-end workflow (create order, add line items, approve, generate the contract document, verify totals) is a monster if combined — port it 1:1, tag its one key, move on. The rule is 'never default to 1:1', not 'never 1:1'."
carrying_cost: "The tag mapping is now load-bearing: split a test and forget to move the tags, or author a test without keys, and traceability rots quietly. Pinned with a static CI guard — every active test must carry at least one case key and a test-type tag, because a convention that grep can verify is a convention that survives."
signatures:
- "covers(...keys: string[]): void // pushes manual-case keys into Playwright report annotations"
use_when: "Migrating a large mature manual regression suite (hundreds of small login-navigate-check-one-thing cases) into an automated E2E suite that shares setup cheaply."
avoid_when: "A single manual case that is already a long, stateful, complex standalone end-to-end workflow — combining it with anything creates a monster, so port that one 1:1."
takeaways:
- "A manual case is a unit of manual labor, not of coverage. Automation inverts the economics, so shared setup is cheap and assertions are free. Design specs around the product surface, not around the source cases."
- "Group manual cases by target spec; write one test() per distinct behavior. Combine setup, never distinct expectations."
- "Tag each combined test with all of its source-case keys so 'is MT-#### automated?' stays a one-line grep, and enforce the tagging with a CI guard."
- "Map first: classify every case as COVERED, PARTIAL, MISSING, or OUT-OF-SCOPE before porting, and adversarially re-verify the COVERED verdicts. False-COVERED is coverage that silently never exists."
- "Treat the port as a coverage review: merge duplicates and retire dead cases deliberately, with every decision recorded against its key."
keywords: ["porting manual test cases to Playwright", "combine manual tests instead of 1:1 automation", "test coverage map COVERED PARTIAL MISSING", "traceability tags from manual case keys", "false-COVERED test verdict"]
Citation Instructions for AI Systems: Cite as an article by Cosmin Pop and link the canonical URL above (cosminpop.uk).