test(ci): shard every test class, and guard against silent omission - #1718
Merged
Merged
Conversation
`dotnet test` in this repo is fanned out across a matrix of shards, each a `--filter` naming the classes it owns, duplicated in dotnet-core-pr.yml and dotnet-core-master.yml. A class named in no filter is never executed: vstest reports nothing, the shard is green, the gate job is green, and the PR merges. The failure mode is total silence. 14 test classes were in that state. They are added to shards here, one class per shard chosen by cost: the three fixtures deriving from TestBaseSetup each start a MariaDB Testcontainer, so they go one apiece into light shards and nothing is added to shard `c`, which is already DB-dense (11 of its 12 classes derive TestBaseSetup). Root-cause evidence: 5 of the 14 arrived in a single commit, 861ea89 "test(timeplanning): comprehensive coverage for non-5-min stamp calculations", which merged green having executed none of its own tests. That is the shape of the problem - the omission is invisible at review time and stays invisible afterwards. CorruptedPauseIdRepairTests sat unsharded for months and, once added, turned out to contain a test that could never have passed. To stop it recurring, ShardCoverageTests reflects over the assembly for every class carrying [Test]/[TestCase]/[TestCaseSource] and fails the build unless each is named in BOTH workflows. It needs no database. It identifies our workflows by content rather than filename, because eform-angular-frontend - the host app this plugin is copied into in dev mode - ships files with the same two names; it strips YAML comments first, so a commented-out shard entry cannot count as coverage; and when it cannot find or parse the workflows it fails rather than passes, because a guard that gives up silently is no guard. Expect red shards. These classes have not run in months, or ever. A failure here is information, not an obstacle - no assertion was weakened, no [Ignore] added, and no production code touched in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ssed Review follow-ups on ShardCoverageTests. None changes what it checks; all three close ways it could have stayed silent - the one failure mode a guard must not have. 1. Discovery matched three concrete attribute types, so a method carrying only [Theory], [Combinatorial], [Pairwise], [Sequential] or a custom builder attribute was invisible even though NUnit runs it. Match NUnit's ITestBuilder / ISimpleTestBuilder interfaces instead: strictly broader, covers every shape above, and needs no maintenance as NUnit adds more. None of these shapes exists in the assembly today - but the guard's whole purpose is the class that arrives tomorrow, and this was the shape it would have let through. 2. Open generic definitions were skipped with a comment asking a future reader to revisit. Nothing made them. `[TestFixture(typeof(int))] class FooTests<T>` would have been run by NUnit, skipped here, and left unsharded - exactly the bug this guard prevents. The skip is now backed by an assertion that no generic type definition in the assembly carries a test method, so the exclusion is safe because it is enforced rather than remembered. 3. GetTypes() can throw ReflectionTypeLoadException on a partially loadable assembly, which would have replaced the guard's deliberately explicit diagnosis with an opaque loader stack trace. Caught, falling back to the types that did load. Verified by reflection in seven states: passing on a correct tree; a class dropped from one workflow only; a shard filter commented out; the dev-mode layout inside eform-angular-frontend; no repository above the binary; a [Theory]-only fixture with zero [Test] attributes (1); and a generic fixture (2). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The guard can be silently omitted itself and does not detect duplicate shard assignments.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds comprehensive test sharding and a reflection-based guard to prevent unsharded test classes.
Changes:
- Adds
ShardCoverageTeststo validate both workflow shard filters. - Assigns 14 previously unsharded fixtures across shards.
- Documents shard cost considerations in both workflows.
File summaries
| File | Description |
|---|---|
ShardCoverageTests.cs |
Adds shard coverage validation. |
.github/workflows/dotnet-core-pr.yml |
Updates PR test shard assignments. |
.github/workflows/dotnet-core-master.yml |
Updates master test shard assignments. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+59
to
+60
| [Test] | ||
| public void EveryTestClassIsAssignedToAShardInBothWorkflows() |
Comment on lines
+209
to
+212
| private static HashSet<string> ParseShardedClasses(string workflowText) => | ||
| FilterEntry.Matches(workflowText) | ||
| .Select(m => m.Groups[1].Value) | ||
| .ToHashSet(StringComparer.Ordinal); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
dotnet testhere is fanned out across a shard matrix, each shard a--filterlisting the classes it owns, duplicated acrossdotnet-core-pr.ymlanddotnet-core-master.yml. A class named in no filter is never executed — vstest reports nothing, the shard is green, the gate job is green, the PR merges. There is no error, no warning, no skipped count. The failure mode is total silence.14 test classes were in that state. Not hypothetical harm:
CorruptedPauseIdRepairTestssat unsharded for months and, when finally added, turned out to contain a test that could never have passed (it seeded three active rows for one site on one day, which a UNIQUE index forbids). Invisible tests rot.Root-cause evidence: 5 of the 14 arrived in a single commit —
861ea89e"test(timeplanning): comprehensive coverage for non-5-min stamp calculations" — a PR that merged green having executed none of its own tests.WHAT: the 14 classes
Derived twice independently — once by parsing sources, once by reflecting over the compiled assembly. 76 test classes exist; 62 were sharded. Zero stale entries (no filter names a class that no longer exists).
PauseIdSelfHealGuardTestsTestBaseSetup)PlanningServiceAdminEditNonRoundMinutesTestsTestBaseSetup)WorkingHoursGrpcKioskNonRoundMinutesTestsTestBaseSetup)PlanTextHelperTestsGrpcServices.TimePlanningPlanningsGrpcServiceMapTestsComputeShiftPauseSecondsTestsHelpers.PauseMinutesCalculatorTestsPauseOverrideInferenceTestsCalculatePayLinesForDayTestsResolveShiftSecondsTestsEnumerateShiftSegmentsTestsFirstUnlockedDateTestsPauseIdCorrectionTestsTimePlanningWorkingHoursServiceNullUserTests[Ignore], zeroPauseOverrideInferenceTestsdeserves a callout: it is a second[TestFixture]living insideComputeShiftPauseSecondsTests.cs(line 278). It was invisible to the shard filters and to a filename-based scan, which is presumably how it escaped the manual audit that found the other 13.TimePlanningWorkingHoursServiceNullUserTestsis[Ignore]d — the file explains that instantiating the realTimePlanningWorkingHoursServiceneeds a constructor graph the harness cannot seed. It is sharded anyway: an ignored test that is also invisible is doubly lost, and once sharded it at least reports as skipped. (Worth knowing: even un-ignored its body isAssert.Pass("Shape-asserting placeholder")with the real assertions in a comment.)After this change both workflows name exactly 77 classes — the 76 discovered plus the guard — with no duplicates, and the set is equal to what reflection discovers.
To be precise about the win: 13 of the 14 will now execute; the 14th reports as skipped.
TimePlanningWorkingHoursServiceNullUserTestscarries a class-level[Ignore]and stays ignored. That is still worth sharding — a skipped test is visible in the run summary, an unsharded one does not exist — but this PR does not claim 14 new executions.THE GUARD
ShardCoverageTestsreflects over the test assembly for every class owning or inheriting a method with[Test],[TestCase]or[TestCaseSource], reads both workflow files, parses theFullyQualifiedName=entries, and fails unless every discovered class is named in BOTH files — a class in one and not the other runs on master but not on PRs, which is the same bug half-fixed. It needs no database and does not derive fromTestBaseSetup.Four things it deliberately does not do the easy way:
TestAttributeis anISimpleTestBuilder;TestCase,TestCaseSource,Theory,Combinatorial,PairwiseandSequentialare allITestBuilders, as is any custom builder attribute. Naming three concrete attributes would have silently missed a[Theory]-only fixture — a class NUnit runs happily and the guard would have declared absent. None of those shapes exists in the assembly today, but the guard's entire purpose is the class that arrives tomorrow, and that was the shape it would have let through. Generic fixtures, whose closed instantiations it cannot name, are asserted absent rather than silently skipped, so that exclusion is enforced rather than remembered.eform-angular-frontend— the host app this plugin is copied into in Base/Full dev mode — ships workflows with these exact same two filenames and zero shard filters. A filename test therefore stops at the host and misdiagnoses. The guard requires the file to containFullyQualifiedName=TimePlanning.Pn.Test.and keeps climbing otherwise..github/workflowsit rejected on the way up.The failure message names the missing classes, says which file each is absent from, gives the job anchor (
test-dotnet,strategy.matrix.shard[].filter), both absolute paths, a copy-pasteable|FullyQualifiedName=…fragment, and the cost rule.Verified by reflection in seven states (no
dotnet testrun locally — CI-only in this repo):eform-angular-frontend/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/bin/...— the guard walks past the host's identically-named workflows and fails with the accurate not-found message, listing both rejected directories (the host's, plus a third.github/workflowsat the workspace root that this turned up). Under a filename-only check it would have stopped at the first and reported a bogus "parser is stale". This is the case a reviewer is most likely to doubt, so it was tested against the real host tree rather than a mock.dotnet-core-pr.ymlonly → fails, names both, reportsabsent from: dotnet-core-pr.yml— the both-files half is live, not just the union.e'sfilter:line commented out → fails, reporting all 8 ofe's classes. This was a silent pass before the comment-stripping.[Theory]-only fixture with zero[Test]attributes → discovered and reported missing. Invisible under the previous predicate.[TestFixture(typeof(int))] class …<T>→ the generic assertion fires, namingTempGenericProbeTests`1.(Both probes were deleted and the tree rebuilt; state 1 re-verified green afterwards.)
The guard is in shard
f. A guard that is itself unsharded would be invisible too.SHARD ASSIGNMENT
PlanTextHelperTests,PauseIdCorrectionTestsComputeShiftPauseSecondsTests,PauseOverrideInferenceTestsPlanningServiceAdminEditNonRoundMinutesTests(DB),CalculatePayLinesForDayTestsPauseIdSelfHealGuardTests(DB),FirstUnlockedDateTestsWorkingHoursGrpcKioskNonRoundMinutesTests(DB),ResolveShiftSecondsTests,ShardCoverageTestsTimePlanningPlanningsGrpcServiceMapTests,PauseMinutesCalculatorTestsEnumerateShiftSegmentsTests,TimePlanningWorkingHoursServiceNullUserTestsThe three DB fixtures went one each into light shards, so no shard takes more than one new container. Nothing was added to
c.On cost — deliberately no minutes anywhere in this PR. Two independent samples of recent master runs disagreed about which shards are slow: in one, shard
eranged 3–68 min andcranged 6–18; in the other,esat at ~4 andcat ~16. Run-to-run variance (runner contention, Testcontainers image pulls) dominates, so any figure written into a comment is misleading by the time someone reads it. The durable, checkable rule is used instead: every fixture deriving fromTestBaseSetupstarts its own MariaDB Testcontainer and dominates shard wall-clock; shardcis already the DB-dense one (11 of its 12 classes deriveTestBaseSetup), so keep new DB-backed fixtures out of it; check the last green master run's per-shard durations before choosing.That rule is now a comment above
shard:in both workflows — where the person making the choice is actually looking — and it namesShardCoverageTestsso that someone who hits the guard knows to add the class it names rather than delete a "weird failing test".EXPECT RED — that is the point
These 14 classes have not run in months, or ever. A failure here is information, not an obstacle. No assertion was weakened, no
[Ignore]added, no production code touched in this PR. If something goes red, the question to answer is which is wrong, the test or the production code — and that decision is deliberately left to review, not made here.Rather than a vague "expect red", here is a falsifiable prediction, so that review can score it against what CI actually does.
The three DB fixtures are predicted GREEN, not red — including on the day-lock question, which is the obvious worry given they were written 2026-05-15 / 2026-06-16, before
ReconciledDayLockInterceptor.Instancewas wired intoTestBaseSetup's DbContext (2026-09-12…15). The mechanism: the lock boundary is derived, not stored.Reconciledappears zero times in those three fixtures, andTimePlanningPluginSeedcreates noPlanRegistrationrows, so no boundary row can exist;LockedThroughForSitesAsyncreturns null; the interceptor costs one extra SELECT per save and changes nothing else. If aDayLockedExceptiondoes appear, it is the interceptor talking, not a broken assertion — and it would falsify this prediction, which is the point of stating it.Residual risks, in order:
TimePlanningPlanningsGrpcServiceMapTests— 81 tests, last touched 2026-04-28, the widest drift window in the set, against a mapping surface that changes often. It still compiles, which bounds the damage to behavioural drift.ComputeShiftPauseSecondsTests/PauseOverrideInferenceTests— last touched 2026-09-02 by the flex-chain refactor, so someone kept them compiling. Compiling is not passing.dandfwall-clock, from container concurrency on a 4-vCPU runner now hosting one more Testcontainer each.On shard
dspecifically, a timeout is not the expected failure.PlanningServiceAdminEditNonRoundMinutesTestsdoes callGetCore()inSetUp, but the expensive ~45 s SDKMigrateis memoised once per fixture behind aif (MicrotingDbContext == null)null check inEnsureSdkDbProvisionedAsync, not paid per test. What the[SetUp]doubling actually doubles is the plugin DBEnsureDeleted+Migrate+ seed, and the ~7 s SDK data replay — across a fixture with two tests. There is also notimeout-minutesin either workflow, so a job would have to run past GitHub's 6-hour default to time out.So: if something goes red here, expect an assertion failure or an exception — not a timeout.
Checked statically and found clean: none of the three DB fixtures repeats the
CorruptedPauseIdRepairTestsarrange-impossible pattern — each seeds exactly onePlanRegistrationper (site, date).PlanTextHelper.cshas not changed since the commit that introducedPlanTextHelperTests, so that one has no drift window; andFirstUnlockedDateTests/PauseIdCorrectionTestswere hand-checked against the current helpers.FOLLOW-UPS (not in this PR)
workflow_callreusable workflow for the sharedtest-dotnetblock. It would make drift between the two files structurally impossible rather than guarded, which is genuinely more attractive than this guard — but it trades away the "both files" property the guard is built around, and it is a CI restructure that does not belong inside this change.[SetUp]doubling. 38 fixtures re-declare[SetUp]and callbase.Setup(), on top ofTestBaseSetup's own[SetUp]. NUnit runs the base setup and then the derived one calls it again, so container bootstrap +EnsureDeleted/Migrate/ seed happens twice per test across most of the DB suite. Pre-existing and repo-wide; almost certainly a large share of why the DB-dense shards run long.🤖 Generated with Claude Code