One PHP file contained two test bodies and eleven migration cases. Counting
it as one file would hide ten of them. Counting its two it(...) declarations
would still miss most of the work. Copying the first assertion into a Go test
would look plausible and preserve almost nothing of the original matrix.
The file covered customer lifecycle states in the pricing application. One body tested eight transitions; another tested three sets of state predicates. It was small enough to follow completely, which makes it a useful way into the much larger problem: using an existing PHP suite to constrain a Go port without pretending that translating test syntax proves equivalent behavior.
The migration tooling separated source files, expanded cases, extracted expectations and executable target tests. Those layers had different jobs. The interesting part is how a particular assertion crossed between them—and where the machinery had to stop and admit it didn’t know enough.
First decide which application the tests describe
The port used an immutable PHP revision as its behavior oracle. Source files, database migrations, fixtures and tests came from that revision rather than whatever happened to be in the PHP checkout on a particular afternoon. The inventory read the Git object tree directly:
for line in git("ls-tree", "-r", PINNED_COMMIT).splitlines():
metadata, path = line.split("\t", 1)
mode, kind, object_id = metadata.split()
if kind != "blob":
continue
rows.append({
"path": path,
"git_blob": object_id,
"mode": mode,
})That gave each source artifact a path and content identity. It also avoided a quietly moving target: an uncommitted PHP fix couldn’t become the expected behavior just because an extractor read the working directory after lunch. Updating the oracle would be a deliberate change to the comparison, not an incidental consequence of another checkout changing.
The product boundary mattered just as much. The migration was required to preserve the backend, physical database design, API behavior and operational semantics. It wasn’t permission to redesign a pricing algorithm while changing languages. Otherwise a faster result might come from removing work or changing behavior rather than replacing the runtime, which would make the runtime comparison a different experiment.
The browser UI was deliberately retired. Its rendering tests didn’t need a
Go equivalent, but a backend behavior didn’t become disposable because a
screen happened to call it. The internal API tester was a specific exception:
its browser adapter would disappear while its scenario execution moved to a
CLI. Scenario order, real HTTP requests, state extraction, persisted runs and
cleanup still mattered. That exception needed to survive classification, not
get swept away with every path containing Marketing or Runner.
The first file classifier used path and content signals to propose categories:
behavioral oracle, portable fixture, UI-only, or Laravel implementation detail.
It also had explicit tester exceptions. Source-owner suggestions were much
less sophisticated: strip Test from a filename and look for matching source
stems, with a cap on candidates. Useful for finding things; nowhere near enough
to prove ownership or justify deleting a test.
For a dozen straightforward tests, I’d translate them by hand and review the mapping. A source extractor has a maintenance cost of its own; building a partial PHP recognizer isn’t free just because it runs in Python. Here the suite was large enough for repeatable enumeration and dataset expansion to be useful. The payoff depended on those operations being reliable, not on avoiding the work of reading the tests.
Expand the dataset before deciding what survived
The lifecycle test’s first body was an ordinary Pest parameterized test:
it('allows only valid customer lifecycle transitions', function (
CustomerState $from,
CustomerState $to,
bool $allowed,
): void {
expect($from->canTransitionTo($to))->toBe($allowed);
})->with([
'active to suspended' => [
CustomerState::Active, CustomerState::Suspended, true,
],
'active to closed' => [
CustomerState::Active, CustomerState::Closed, true,
],
'active to active' => [
CustomerState::Active, CustomerState::Active, false,
],
// Five further transition rows.
]);The excerpt wraps the original rows and omits five, rather than inventing a smaller lifecycle. The full source also covered suspended→active, suspended→closed, suspended→suspended, closed→active and closed→closed.
That last part is worth reading instead of assuming “all pairs.” Three states have nine possible ordered pairs, but this dataset specified eight. It didn’t include closed→suspended. The PHP implementation rejected that pair too; that is an implementation fact, not a ninth assertion secretly present in the test. An inventory should preserve what the suite says without pretending the suite is exhaustive.
The extractor found the test closure and its parameters, then read the chained
with(...) calls after the closing brace. A delimiter scanner tracked nested
brackets, quoted strings and comments so an inner array or brace didn’t end the
test early. Dataset rows bound argument expressions to parameter names. For the
first row, those bindings were:
{
"from": {"symbol": "CustomerState.Active"},
"to": {"symbol": "CustomerState.Suspended"},
"allowed": true
}The source expressions were substituted into the body before extracting its
action and assertions. Now toBe($allowed) had a concrete expected boolean
instead of an unresolved parameter shared by eight unrelated cases.
The second body used three dataset rows, one per state, and chained three predicate expectations:
expect($state->isActive())->toBe($active)
->and($state->isSuspended())->toBe($suspended)
->and($state->isClosed())->toBe($closed);It added three cases, not nine. Each case carried three observations. Losing two of those observations would leave the case count unchanged, so case counts couldn’t be the only thing compared either.
Dataset expansion also had to handle products. The actual extractor supported chained named datasets by resolving each set, taking their Cartesian product, combining bindings, and assigning a label and ordinal to each combination:
for combined_ordinal, combination in enumerate(
itertools.product(*resolved_sets),
start=1,
):
bindings_source = {
name: value
for row in combination
for name, value in row["bindings_source"].items()
}
label = " / ".join(row["label"] for row in combination)
expanded_block = prune_known_boolean_branches(
substitute_dataset_bindings(
source[opening + 1 : closing],
bindings_source,
)
)
# Record this block with its bindings and source anchor.Two rows chained with another two produced four cases, in product order—not two cases paired by position. The extractor’s own test exercised that exact distinction. Literal arrays, named datasets, some callable fixture shapes and explicitly supported enum/provider forms had resolution paths. This wasn’t a general interpreter for arbitrary PHP dataset providers.
Extract an observation, not just a test title
After expansion, the first transition case described this operation and value expectation. These are the relevant portions of the actual extracted record; empty HTTP/persistence fields and other metadata are omitted:
{
"source_anchor": {
"test_ordinal": 1,
"source_line": 12,
"dataset_ordinal": 1
},
"actions": [
{
"kind": "source-expression-evaluation",
"operations": [
{
"target_type": "CustomerState",
"target_member": "Active",
"operation": "can_transition_to",
"input": [{"symbol": "CustomerState.Suspended"}]
}
]
}
],
"expectations": {
"values": [
{
"subject": "CustomerState.Active.canTransitionTo(CustomerState.Suspended)",
"expected": true
}
]
}
}The action wasn’t “run the customer-state test.” It identified the receiver, operation and argument, with the expected result attached separately. A Go test could implement that contract without reproducing Pest or PHP enum objects. The source anchor still let a reviewer find the original body and dataset row instead of trusting a generated sentence about what it tested.
The general scenario extractor preferred HTTP actions when present, then
application inputs or invocations, and gathered response paths, statuses,
headers, value expectations and database assertions. A supplemental pass
recognized additional source operations and assertion chains. It tracked
negation and changed subjects when Pest’s and(...) moved to another value.
not->toBeNull() couldn’t become a positive null assertion merely because
the same matcher name appeared in both.
That structure still had limits. An HTTP case with an asserted status and two JSON paths described those observations, not every byte of the response. A case containing a fake HTTP client or queue proved the seam inside that fake, not an actual external delivery. The generator retained those limits separately from unresolved extraction gaps. Making all JSON fields nonempty wouldn’t turn either kind of case into an end-to-end test.
The same distinction applied inside the engine. Another real PHP file had three bodies for requested-service criteria: two positive examples and eight negative dataset rows. Expanding it produced ten cases. One negative row required an air service while the resolved service was road; others varied cargo, pickup, battery, service-level and selector constraints. Replacing those eight rows with one generic “doesn’t match” test would discard eight different reasons for rejection while leaving a reassuring test name behind.
Unknown setup had to stay unknown
Expressions such as enum members were meaningful symbols, not strings to compare literally. Fixture factories, application services, database setup and provider methods could require more source resolution or native execution. The extractor represented unsupported expressions rather than evaluating arbitrary PHP as part of a text scan.
For example, an unrecognized externalDatasetProvider() left the explicit
gap “parameterized Pest dataset is not expanded into individual cases.” It
didn’t silently generate one representative row. With unresolved input symbols,
there was still no concrete case ready to execute, even if an expected true
was easy to extract from the body.
This is where I’d be particularly careful with AI-assisted migration. A model can make an unresolved symbol look very tidy by replacing it with a believable fixture. It can also replace a troublesome assertion with one that matches the new implementation. Both make the translation easier. Neither preserves the old test. Resolve the source helper, run the old behavior where appropriate, or keep the case open; don’t let plausible test data become the oracle.
The implementation was a bounded recognizer, not a PHP parser with full language semantics. It substituted known bindings textually and pruned simple known boolean branches. Those operations helped recover concrete cases from the suite’s actual patterns. They didn’t prove that every closure, setup side effect or dynamically constructed provider had been understood. The parser needed its own negative cases, and extracted records still needed inspection.
Give the Go test the same question
The Go test for the lifecycle file used table rows with the source case identity, starting state, target state and expected boolean. Its loop called the real domain implementation and compared the result. Here is that loop with the local result split onto its own line to keep the excerpt readable:
for _, transition := range transitions {
transition := transition
t.Run(transition.caseID, func(t *testing.T) {
got := transition.from.CanTransitionTo(transition.to)
if got != transition.allowed {
t.Fatalf(
"customer transition %s -> %s = %t, want %t",
transition.from,
transition.to,
got,
transition.allowed,
)
}
})
}Its eight transition rows matched the PHP rows, including same-state refusals and terminal-state refusals. A second table covered all three predicate cases and checked all three booleans in each. That gave the eleven extracted cases an executable target boundary, rather than treating the existence of a generated record as a passing test.
The target implementation was an ordinary Go string-backed state type and switch, not a port of PHP’s enum machinery:
func (state CustomerState) CanTransitionTo(
target CustomerState,
) bool {
switch state {
case CustomerStateActive:
return target == CustomerStateSuspended ||
target == CustomerStateClosed
case CustomerStateSuspended:
return target == CustomerStateActive ||
target == CustomerStateClosed
default:
return false
}
}For the supported states, that answered the same application question as the
PHP enum’s match. It wasn’t an obligation to emulate every possible PHP
object operation or to claim that arbitrary invalid Go strings were accepted
public inputs. Application parity needed a reachable contract, not a growing
collection of language-runtime differences with no connection to the product.
The case identity came from a hash of source path, Git blob, test ordinal and dataset ordinal. The source line was retained for navigation but wasn’t part of that key. The link was stable for that source snapshot, not forever: changing the file content changed its blob and therefore its case identities. That would force reconciliation rather than allow old test mappings to appear current against a different source file.
I checked this small chain directly: the actual extractor produced eleven lifecycle cases and ten requested-service matcher cases; the frozen PHP enum produced its nine-pair truth table; the corresponding Go lifecycle test passed its eight transition and three predicate subtests. That establishes these examples, not the entire port or the behavior of a database-backed command which happens to use the same enum.
Keep the large number attached to what it counts
That source snapshot contained 3,060 test files. It recognized 8,604 test bodies and indexed 10,949 cases after expansion, including 10,522 retained cases and 427 retired cases. Those are different units, not successive claims of migration progress. The retired cases needed a reason tied to the chosen product boundary; an inconvenient backend test wasn’t a UI test just because retaining it made the denominator larger.
There was a further separation between resolving a source case and executing its Go equivalent. A file could be fully inventoried while none of its target tests existed. A case could have concrete input and expected output while its new implementation still returned the wrong result. A passing domain test could say nothing about controller validation, transaction failure or stored history around it. The model-cast disagreement is an example of why preserving a result and preserving persistence aren’t the same task.
The broader Go state test went further and looped over all nine pairs. Its closed→suspended check added coverage supported by the PHP implementation; it wasn’t a ninth case recovered from that eight-row dataset. Keep that distinction even when the suite grows into thousands of cases. Otherwise it’s far too easy to substitute tests of the implementation you just wrote for the application you meant to preserve.