Skip to content

test: lay out tests by the code they test - #1110

Merged
OmarAlJarrah merged 18 commits into
mainfrom
test/one-test-file-per-source
Oct 24, 2026
Merged

OmarAlJarrah merged 18 commits into
mainfrom
test/one-test-file-per-source

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Summary

Test files were hard to navigate after #1109's move, for reasons a scan of the whole suite at main (ff3fff4) pinned down:

  • 51 source files had two test files, x_test.go outside the package and x_internal_test.go inside it;
  • 99 tests sat beside a different file from the one their name and subject belong to;
  • eight test files ran past 1,500 lines, schema/allof_test.go to 3,078;
  • helpers were written more than once, schema's lowering fixture among them, byte for byte on both sides of the package boundary.

This PR fixes each of them and writes the rules down. Every change is to test code, except the shared helpers added to the two test-helper packages, ir/irtest and compilers/openapi/internal/openapitest.

  • Internals through export_test.go. 38 in-package test files in 19 packages become external tests. Each takes its source's x_test.go, merged into that file where one already existed, and reaches what it needs through the package's export_test.go. The tests are the same code, qualified by their package. Where an alias's exported name is taken, its name says what it adds, as irtest.CompareGoldenOn (takes the stub testingT) and atomicfile.ReplaceWithChmod do. A fixture both sides of a package use is declared once there, as nodeview's YAMLDoc and reader's ResolvedDir are. auth's nil-requirement test is rewritten instead: it now goes through LowerSecurityRequirements, the exported function, and still catches the two faults the original caught.

  • _internal names. Nine in-package test files that sat beside external tests under plain names take the _internal_test.go spelling. The lint exclusion and three comments naming them follow.

  • One schema fixture. The Lowerer fixture is declared once, exported, in schema/export_test.go, and both sides of the package use it. All 459 uses were rewritten from type information, not spelling. operation's external lowerServiceSpec, which compiles a whole spec, becomes compileService, so it no longer shares a name with the in-package helper that drives LowerService.

  • Shared helpers. Comparing every test helper's body, whatever its own name and the names of its parameters and locals, found the copies. Each helper with a home now has one:

    • within a package: e2e's compileClean, which replaces two compile helpers; irverify's violationCodes and violationPaths; reader's resolved-directory fixture; and e2e's componentID, which it also wrote as namedID;
    • in openapitest, for the compiler's packages: SchemaFromYAML, ParamBySource, FirstDiag, PointersOf, PreservationClaims, HolderSpec, ClearAnchors and YAMLDocument, which five packages had each written;
    • in irtest, for the IR's: RepoRoot, NestedOverloadDoc, NestedListValue, DocOf, ModelOf, ScalarOf, RefsTo, Encoded, RefusedAsCyclic and DiagCodes.

    Two wrappers of openapitest.AssertHasCode become calls to it, four helpers that took a BigVal's address become new(ir.BigVal(v)), and the CLI tests stop writing out warnSpec a second time. RepoRoot and Encoded take their failures through an internal function, so those are tested too.

  • Restated bounds. Six tests in schema and operation spelled a bound's value, or what follows from it, instead of reading the bound, two of them with a comment that the bound was unexported. Each bound is now aliased in its package's export_test.go, as resolve.MaxRefChain already was, and the tests derive their chain lengths, components and expected messages from it, so a changed bound leaves them testing its new boundary.

  • Misfiled tests. 99 tests move to the test file of the file declaring their subject, with any helper only they use. Among them: load's 25 hold tests go to external_hold_internal_test.go, value's NumericLiteral tests to scalar_test.go, pass's collectTypeIDs tests to validate_graphql_test.go, and ir's TypeCommon and template tests to typedef_test.go.

  • Large files split by topic. The eight files over 1,500 lines split into x_<topic>_test.go files, which sort beside their source. For example, allof becomes allof_nesting, allof_required, allof_residue, allof_conflicts and allof_narrowing. Every test file is now under 1,250 lines.

  • t.Helper in conformance assertions. Ten conformance case bodies called t.Helper, so their failures were reported at TestConformance's loop line; they no longer do. Eleven sub-assertions called by other assertions now do.

  • CONTRIBUTING gains three rules under "Where a test goes", each saying what fails when it is missed:

    • a test is external unless it can't be: a package's tests stay inside when moving them out would take more than ten aliases, and a cmd program's are all inside it, since a main package cannot be imported;
    • a test is named for what it tests, or for the construct it pins, and a function a table runs as a case's body leaves t.Helper out;
    • a helper is written once.

    The root guidance's testing summary points at the section.

Deliberately left:

  • 43 inside/outside pairs remain. Each needs an unexported field or method, a generic or a variable it assigns, none of which an alias carries faithfully. Otherwise it sits in schema, operation, bind, irverify, harness or overlay, whose tests would need 16 to 51 aliases each, enough to make a second API. Two of the 43 are new, in nodeview and ir's typedef: tests moved to the file declaring their subject, beside tests of that file that must stay inside.
  • Packages whose tests all stay inside. load, merge, annotation and scan would need 14 to 106 aliases each.
  • Messages that spell a bound. Tests that pin a diagnostic's exact text keep the number it names, such as the 64 levels a merge chain is expanded to. A changed bound fails them loudly rather than letting them pass beside it, so they stay as written.
  • Bounds whose exact boundary no test pins. Planting an off-by-one at each of the 38 comparisons against a named bound leaves the suite green at 18 of them. That predates this change, does not depend on where any test sits, and is test: no test pins the exact boundary of 18 named bounds #1111.
  • Entry-point tests stay filed by subject. Tests named for a package's entry point or for a construct are filed by the subject they exercise through it: the CLI's TestRun_ tests, load's TestResolve_, operation's TestContent_ and TestOperations_, and schema's TestLower_. CONTRIBUTING names that convention.
  • Helpers still written in more than one package, as the helper rule now names them:
    • ones that belong in a shared home but cannot live there, each copy saying why: parseFull and compileNullable compile through openapi.New, which openapitest cannot import, and componentID spells an ID the idderivations rule allows only in a test file;
    • ones outside what openapitest and irtest serve: file reads and writes, AST predicates, package loading and stub compilers;
    • one-expression shorthands such as ir.TypeRef{Target: id} or typeByName, which hold no behaviour for a copy to drift from.

Test plan

  • Test names, go test -list '.*' ./...: 3,478 on main, 3,501 here. Twenty-three are new, the tests of the shared helpers. One is renamed: TestLowerSecurityRequirement_Nil is now TestLowerSecurityRequirements_DropsANilEntrySilently.
  • Full suite under go test -json -race ./...: 12,271 passing tests and subtests on main, fuzz seeds included, against 12,294 here, differing only in those 24 names. Four TestLoad_AFileURIReadsTheFileItNames subtests are named after a random temp directory, so they differ in name only.
  • go run ./cmd/morphic-covergate over that run's profile: all 15,233 statements covered. The 64 new ones are the shared helpers'.
  • Per package, each move, split and rewrite left the package's tests and subtests identical to main's, compared with go test -json before and after.
  • Declaration dumps (every top-level declaration with its comment) are byte-identical before and after for the splits and the moves. The exceptions are the references qualified by a move out of the package, a comment that no longer calls the visibility cases "below", and one that no longer calls the round-trip tests "above".
  • Planted faults:
    • the rewritten auth test fails on a nil entry kept as an empty option, and on a nil entry reported;
    • a failure planted in assertSelfURI was reported at conformance_test.go:51 before the t.Helper change and is now reported at the line that fails;
    • planting a restated bound at another value leaves its tests passing, and planting its comparison one off fails them;
    • a fault planted in each shared helper fails that helper's test. Three at first did not, and their tests were fixed: DiagCodes' test used a list that reads the same reversed, no fixture reached SchemaFromYAML's validation check, and nothing counted encoded's and rootFrom's Helper calls.
  • make fmt build vet lint lint-planted fuzz bench-smoke exit 0, with golangci-lint at the pinned version and FUZZTIME=2s.
  • go test -shuffle=on ./... passes in two runs, so no moved test leans on another's order.
  • Each commit's tree vets and passes the tests of the packages it touches on its own.
  • A trial merge with the one open PR, chore: add a review agent built from past PR fixes #888, is clean.

Four test helpers had two copies with the same body, and one spec was
written out twice:

- e2e's compilePromotionSpec and compileStreamingSpec, now one
  compileClean in its helpers_test.go;
- annotation's jsonSchemaFromYAML and resolve's schemaFromYAML, now
  openapitest.SchemaFromYAML, which both packages' tests can import;
- archtest's and irtest's repoRoot, now irtest.RepoRoot;
- irverify's and pass's opDocNested, now irtest.NestedOverloadDoc;
- the CLI's warning spec, which compile_test.go wrote out beside
  validate_test.go's warnSpec, which it now uses.

irtest's RepoRoot takes the working directory's lookup through rootFrom,
so its two failures, an unreadable working directory and a walk that
reaches the filesystem root, are tested with the package's recording
stub; openapitest's SchemaFromYAML is tested with its own.
Fifty-one source files had two test files, x_test.go outside the package
and x_internal_test.go inside it, because one file can hold only one
package clause. Where the inside half reached a few unexported
functions, constants or types, it needed only to see them.

In thirteen packages those files are now external tests, merged into the
x_test.go beside them or renamed to it, and reach what they need through
the package's export_test.go, which names each alias once: twenty files
in all. The tests are the same code, qualified by their package; where
an alias's exported name is taken, it says what it adds (irtest's
CompareGoldenOn and FillOn take the stub testingT, atomicfile's
ReplaceWithChmod the injected chmod).

auth's nil-requirement test is the one rewritten rather than moved: it
reached lowerSecurityRequirement to hand it a nil entry, and
LowerSecurityRequirements, which callers use, takes the same entry
through its list. Both catch keeping the entry as an empty option and
reporting it.

A file stays inside its package where it needs an unexported field or
method, a generic or a value variable, which no alias carries, or a file
that does, and in the six packages whose external tests would need a
second API's worth of aliases: schema, operation, bind, irverify,
harness and overlay.
Nine test files inside their package sat beside external tests under a
plain x_test.go name, so a reader could not tell from the name which
side of the package boundary a test was on: the root's budgets and
helpers files, harness's order tests, and six in irverify. They take the
_internal_test.go spelling the rest of the repository uses for that.

The lint exclusion naming budgets_test.go, and the comments that named
three of the files, follow. One of those comments is in a conformance
spec, whose golden records the spec's hash, so the golden's hash moves
with it; the comment now names the function it means, reverseMappings,
instead of a file.
schema's tests drove one lowering through a fixture, the lowerer with
loweredFor, lowerSpec, lowererOver, newRawLowerer and its document
method, and declared it twice, byte for byte, once on each side of the
package boundary: in-package tests cannot see helpers an external test
file declares, and external tests cannot see unexported ones.

It is now declared once, exported, in schema's export_test.go, which
both sides compile with: schema.LowerSpec from outside the package,
LowerSpec from inside it. Its fields are exported for the same reason.
Every use was rewritten by its type information, not its spelling, and
the package's tests and subtests are the same before and after.

operation had two helpers named lowerServiceSpec doing different things:
the in-package one drives LowerService through the fixture, the external
one compiles the whole spec. The external one is now compileService.
Sixty-two tests sat in a file named for one source file while their names,
and their subjects, were declarations of another. load's
source_internal_test.go held twenty tests of hold, which external.go
declares, and spellings_internal_test.go five more; pass's validate_refs
tests held collectTypeIDs's, which #1102 moved to validate_graphql.go;
bind's documents tests held prefix, which defs.go declares.

Each moves to the test file of the file declaring its subject, created
where none existed: hold's twenty-five tests to
external_hold_internal_test.go, with the helpers only they use, and the
rest to the bindings, models, unprepared, source, typejudge, losers,
nullability, visibility, constraints, owners, code, format, resolve and
amplification test files of their packages. Two helpers several load
files share, diagLines and response200, go to load's
helpers_internal_test.go.

What stays is a test named for its package's entry point or for a
construct and filed by the subject it exercises through it: the CLI's
TestRun_ tests, load's TestResolve_, operation's TestContent_ and
TestOperations_, and schema's TestLower_, TestSchema_, TestModel_ and
TestAllOf_. Every package's tests and subtests are the same before and
after.
Eight test files ran past 1,500 lines, schema's allof_test.go to 3,079,
so a reader looking for one construct's tests scrolled past every
other's. Each splits into topic files that keep its name as their prefix
and so sort beside it: allof into nesting, required, residue, conflicts
and narrowing; discriminator into beside_union, mapping and allof; union
into oneof and combinators; operation's pathitemref into unmounted,
collisions, references and fields, and params into querystring, schema
and identity; merge's fold cases into fold_docs, fold_visibility,
fold_narrowing and fold_types; and load's unprepared into external and
reads, and reached's resolve tests into reached_resolve. Every test file
in the repository is now under 1,250 lines.

A test goes to the topic its name places it in, and a helper only the
tests of one topic use goes with them. Helpers several files use now sit
in their package's helpers file, which merge gains for its visibility
fixtures, whose comment no longer calls the visibility cases "below".
Every declaration is otherwise byte-identical, and the packages' tests
and subtests are the same before and after.
A conformance case's assertion is the body TestConformance runs for it,
so a failure inside it is most use reported where it happens. Ten case
bodies called t.Helper, which sent every failure in them to
TestConformance's loop instead: a failure planted in assertSelfURI was
reported at conformance_test.go:51, and is now at the line that fails.
Eleven assertions that another assertion calls did not call it, so their
failures pointed inside the helper rather than at the case using it;
they now do.
CONTRIBUTING's "Where a test goes" gains the three rules this change
applies. A test is external and reaches what it must of its package's
internals through export_test.go, staying inside the package only for an
unexported field or method, a generic, a variable it assigns, or a
package whose aliases would be a second API. A test is named for the
function or type it tests, or for the construct or rule it pins, and
asserting helpers call t.Helper except a conformance case's own body. A
helper several files share has one home, in the package's helpers file
or, across packages, in openapitest or irtest. The root guidance's
testing summary points at the section.
Some in-package test files reach nothing that an export_test.go alias
cannot carry: all of detect's, spelling's and value's, oas's code and
format tests, and every nodeview and sourceindex test file except the
one that reads a View's or a Measure's fields. They become external
tests that reach what they need through their package's export_test.go,
as the rest of the suite does.

Three fixtures are used on both sides of their package's boundary, so
each is declared once, exported, in export_test.go: nodeview's YAMLDoc
and ComponentsDoc, and sourceindex's Decode.

Each package's tests and subtests are identical before and after,
compared with go test -json, and each package keeps full statement
coverage.
Thirty-seven tests sat in the test file of one source file while their
names test a declaration of another: value's TestNumericLiteral tests,
for one, sat in value_test.go though NumericLiteral is declared in
scalar.go. Each moves to the test file of the file that declares its
subject:

- load: the Open test to external, the Beside and Writes tests to
  siblings;
- value: the NumericLiteral tests to scalar;
- nodeview: the IsMergeKey and Repeat tests to a new nodeview_test.go;
- oas: the Kinds test to types_table;
- lowering: the New test to lowering;
- irtest: the Fill test to fill;
- census: the Report tests to a new report_test.go;
- sourceindex: the Nesting tests, with the two helpers only they use,
  to sourceindex_test.go, outside the package as that file is;
- merge: the PropIndex, WireNameIndex and MergeProperty tests to merge,
  and the Merger tests to fold;
- ir: the TypeCommon, TypeDef and template tests to typedef, and the
  marker test to a new typedef_internal_test.go, since it calls the
  unexported marker.

e2e's assertOnlyErrorDiags, which helpers_test.go also calls, moves
there, and irtest's TestMain comment names golden_test.go, which
declares runCompare.

Every moved declaration is byte-identical, comment included, except
the Nesting tests' qualified references and one comment that no longer
calls the round-trip tests "above". Each package's tests and subtests
are unchanged, compared with go test -json.
Comparing every test helper's body, whatever its name, finds helpers
written more than once. Each one with a home now has one copy:

- within a package: irverify's violationCodes and violationPaths, each
  also written under another name, and reader's resolved-directory
  fixture, written on both sides of the package boundary and now
  declared once in export_test.go;
- in openapitest, for the compiler's packages: ParamBySource,
  FirstDiag, PointersOf, HolderSpec and YAMLDocument, which also
  replaces sourceindex's Decode fixture;
- in irtest, for the IR's: DocOf, ModelOf, ScalarOf and RefsTo, the
  builders both ir's and irfacts' tests wrote, NestedListValue,
  Encoded, RefusedAsCyclic and DiagCodes.

The two wrappers that asserted an error-severity code become calls to
openapitest.AssertHasCode, as the rest of the suite makes, and the
three helpers that took a BigVal's address become new(ir.BigVal(v)).
e2e's namedID, a second copy of its componentID, becomes componentID,
the name openapitest's package comment gives that per-package copy.

What is still written more than once has no common home. parseFull
and compileNullable compile through openapi.New, which openapitest
cannot import. componentID, and typeByName over it, spell an ID the
idderivations rule allows only in a test file. The remaining file,
tree and AST helpers sit in packages that share no test-helper package.

Each package's tests and subtests are unchanged except for the
fourteen tests of the new shared helpers, and every package keeps full
statement coverage.
resolve's locate tests and reader's disk_resolve tests need nothing an
alias cannot carry. They stayed inside their packages only because they
share fixtures with tests that must stay there. Each shared fixture is
now declared once where both sides reach it: resolve's WholeModelAt and
reader's VanishWorkingDirectory in export_test.go. locate reaches the
walk through the At alias export_test.go already holds, and resolve's
indexedDoc, which only resolve_internal_test.go uses, moves there.

Each package's tests and subtests are unchanged, compared with go test
-json, and each package keeps full statement coverage.
The rules under "Where a test goes" now say what the tree does:

- a package's tests stay inside it when moving them out would take
  more than ten aliases, and a cmd program's tests are all inside it,
  under plain names, since a main package cannot be imported;
- a function a table runs as a case's body leaves t.Helper out, as the
  canaries do as well as the conformance assertions;
- a helper is written once, sitting in one of the test files that share
  it or, when new, in the package's helpers file, with openapitest and
  irtest holding what several packages share. A helper neither can
  hold is written in each package that needs it and says why, as
  compileNullable's copies now do too.
Planting a fault in each shared helper this change adds found three
that its tests let through:

- DiagCodes' test listed b, a, b, which reads the same reversed, so it
  could not tell report order from its reverse; it now lists c, a, b, a;
- SchemaFromYAML's check that the library reports no validation error
  could be deleted unnoticed, since no fixture parses and then fails to
  validate; a required written as a scalar now does;
- encoded and rootFrom could stop marking themselves helpers unnoticed;
  their failure tests now count the Helper calls, as compareGolden's
  test already does.
Comparing helpers' bodies up to the names of their parameters and
locals, not only as written, finds copies the exact comparison missed:

- annotation's parsedMapping, bind's tree and oas's parse each wrote
  openapitest.YAMLDocument again, and now call it;
- e2e's and schema's preservationClaims become
  openapitest.PreservationClaims;
- canary's releaseAnchorNames and sourceindex's clearAnchors, the
  load phase's anchor clearing restated for tests, become
  openapitest.ClearAnchors;
- irverify's bigVal, a fourth helper taking a BigVal's address, becomes
  new(ir.BigVal(v)) as the other three did.

Each package's tests and subtests are unchanged except for the tests of
the two new helpers, and every package keeps full statement coverage.
The helper rule now names what a shared home does not take: a helper
that belongs in openapitest or irtest but cannot live there, which is
written in each package that needs it with each copy saying why; one
outside what either serves, such as a file read or a stub compiler;
and one whose body is a single expression, which holds no behaviour
for a copy to drift from. Those are the copies a name-blind comparison
of every test helper still finds.
Three external tests spelled an unexported bound's value instead of
reading it, two of them with a comment that the bound was unexported:
schema's discriminator ancestor depth (256) and $ref position hops
(64), and operation's multipart $ref hops (16). Each is now aliased in
its package's export_test.go, as resolve's MaxRefChain already is, and
the tests read the alias.

The multipart tests also spelled what follows from the bounds: the
chain lengths, the component one past the cut and the warnings' text,
for both the hop bound and schema.MaxAllOfFlattenDepth. They derive
those too, so a changed bound leaves them testing its new boundary
rather than failing on, or quietly passing beside, the old one. The
re-entry test kept a 16-link chain that only stayed within the bound
while the bound was 16.

Each package's tests and subtests are unchanged. With a bound's value
planted at another number the tests still pass, and with its
comparison planted one off they fail.
The control for the forbidden querystring keywords called itself the
control for "both cases above" and spoke of "neither keyword", but
three cases precede it, style, explode and allowReserved, and it
checks all three.
@OmarAlJarrah
OmarAlJarrah merged commit 5b9876f into main Oct 24, 2026
2 checks passed
@OmarAlJarrah
OmarAlJarrah deleted the test/one-test-file-per-source branch October 24, 2026 13:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant