feat(test): support sharding tests across CI runners - #1707
Conversation
Add `--shard-index` and `--total-shards` to `very_good test` and `very_good dart test`, so a test suite can be split across multiple CI runners with a `strategy.matrix`. The test optimizer already discovers every test file, so sharding is a partition of that list rather than new machinery. Files are sorted and dealt out round-robin, which keeps shards balanced by file count and makes the partition deterministic across machines — `Directory.listSync` order is filesystem dependent, so without sorting two runners could disagree and either skip or duplicate tests. Tests tagged `skip_very_good_optimization` are sharded as well. They run as standalone files alongside the optimizer entrypoint, so leaving them unsharded would re-run all of them on every runner. Each runner generates its own `.test_optimizer.dart` containing only its slice, so no shard-specific filenames are needed. A shard with no test files succeeds instead of failing with "No tests were found", so an oversized matrix does not break the build. Sharding is rejected with a usage error when combined with `--min-coverage`, since each shard only exercises a subset of the codebase and its coverage is not representative of the whole suite, and when the optimizer is disabled, which sharding depends on. Closes VeryGoodOpenSource#1538 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Normalize path separators in getNotOptimizedTests so tagged tests in subdirectories are excluded from the optimized set on Windows. Without this the relative paths compare unequal to the forward-slash normalized paths built in run(), causing those tests to be both inlined into the optimizer entrypoint and run standalone. Parse the shard values inside validateSharding instead of passing both the raw and parsed forms, and shorten the --min-coverage error. Also cover the shard that only contains non optimized tests, and document how sharding interacts with --recursive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks for the contribution @peter-trost ! ❤️ You can undraft it, since I actually havent started much on the sharding tests and this actually on first glance does it how i would do it. I will give it a test and bigger review later today/tomorrow but so far it looks good to me |
|
@ryzizub nice, glad to hear! Let me know if I can support in any way :) |
|
the flame e2e failing seems unrelated 🤔 |
Correct, there seems to be issue unrelated to this PR |
Sharding the optimized and skip_very_good_optimization lists separately restarted the round-robin at shard 1 for each list, so the first shards received a file from both while later shards could stay empty. Deal out the single sorted list once and split it afterwards, which also drops one shardOf call. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
a505315 adds a fix to put optimized and |
The bundle also embeds the hook test file, which gained a case in the previous commit after the bundle had last been generated, so the verify_bundle check failed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ryzizub
left a comment
There was a problem hiding this comment.
Overall LGTM, great job! Just a small findings
Route the raw --shard-index and --total-shards values through the test options classes instead of reading argResults in run(), make shardOf private now that the sharding behaviour is covered through run(), and name the flag values (<index>, <count>) while stating the default in their help text. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Thanks for the review @ryzizub! Latest commit addresses the feedback. |
# Conflicts: # lib/src/cli/dart_cli.dart # lib/src/cli/flutter_cli.dart # lib/src/cli/templates/test_optimizer_bundle.dart # lib/src/cli/test_cli_runner.dart # test/src/commands/dart/commands/dart_test_test.dart Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
@ryzizub merge conflicts fixed. |
|
Adding @marcossevilla as additional reviewer for this PR |
| final shardingError = validateSharding( | ||
| rawShardIndex: options.shardIndex, | ||
| rawTotalShards: options.totalShards, | ||
| optimizePerformance: options.optimizePerformance, |
There was a problem hiding this comment.
Blocking: this passes the raw --optimization flag, but the effective value at L564-570 is also switched off by --platform, --update-goldens, and an explicit test path.
So very_good test --platform chrome --shard-index 1 --total-shards 3 passes the check at L275-277, then runs with the optimizer off and every shard runs the whole suite.
Compute the effective value once above this call and pass it to both validateSharding and _flutterTest.
There was a problem hiding this comment.
Fixed. The effective value is now computed once and shared.
| final shardingError = validateSharding( | ||
| rawShardIndex: options.shardIndex, | ||
| rawTotalShards: options.totalShards, | ||
| optimizePerformance: options.optimizePerformance, |
There was a problem hiding this comment.
Blocking: same as in test.dart. This is the raw flag, but L431-437 also switches it off for --platform and for explicit test paths.
very_good dart test --platform chrome --shard-index 1 --total-shards 4 then runs the full suite on all four shards instead of a quarter each.
Compute the effective value once and pass it to both.
There was a problem hiding this comment.
Fixed. The effective value is now computed once and shared.
| // than test files. That is not a failure: the shard has nothing to do, | ||
| // so report success instead of letting the test runner exit with | ||
| // "No tests were found", which would fail the CI job. | ||
| if (shardIndex != null && |
There was a problem hiding this comment.
Bug: L123-127 deletes coverage/lcov.info, and returning here skips the block that rewrites it, which ends by asserting the file exists (L266-267).
very_good test --coverage --shard-index 4 --total-shards 4 on a three-file suite then succeeds with no lcov file, so the merge step has nothing to read.
Write an empty lcov file before returning.
There was a problem hiding this comment.
Fixed. An empty lcov.info is written.
| // Each shard only exercises a fraction of the codebase, so its coverage is | ||
| // not representative of the whole suite. Merge the lcov files from every | ||
| // shard and enforce the threshold in a separate job instead. | ||
| if (minCoverage != null) { |
There was a problem hiding this comment.
Bug: minCoverage is resolved from very_good.yaml (L56-60), so this rejects sharding in any repo that sets min_coverage:, even when the flag was never passed.
There is no --no-min-coverage to opt out, only --min-coverage="".
Check argResults.wasParsed('min-coverage') instead, the same way rawShardIndex and rawTotalShards stay strings so "not provided" can be detected.
There was a problem hiding this comment.
Agreed on the problem. One tweak: wasParsed alone would let a config threshold through to per-shard enforcement and fail spuriously. So we reject only an explicit flag, and drop the config value while sharding.
| /// Files are dealt out round-robin (index modulo [totalShards]) over the | ||
| /// already sorted [paths], which keeps shards balanced in file count and makes | ||
| /// the partition stable for a given test suite. | ||
| List<String> _shardOf( |
There was a problem hiding this comment.
Bug: the loop below has no bounds checks. totalShards: 0 never advances i, so it appends paths[0] until memory runs out, and shardIndex: 0 reads paths[-1] and throws.
validateSharding blocks both from the CLI, but brick.yaml now prompts for these two vars, so mason make test_optimizer reaches this code directly.
Add a guard here, since this is the only place that can protect that caller.
| totalShards: totalShards, | ||
| ); | ||
| final optimizedTestPaths = shardPaths | ||
| .where((p) => !notOptimizedTests.contains(p)) |
There was a problem hiding this comment.
Nit: notOptimizedTests is a List, so each contains is a full scan, run once per path here and again on L73.
That is quadratic in the number of test files, on every run of the command.
Build a Set once before both where calls.
| /// equal to the ones built in [run] on Windows too. | ||
| final relativePaths = testWithVeryGoodTest | ||
| .map((e) => path.relative(e, from: testDir)) | ||
| .map((e) => path.relative(e, from: testDir).replaceAll(r'\', '/')) |
There was a problem hiding this comment.
Nit: this normalizes to forward slashes, but the consumer at test_cli_runner.dart L209 still calls p.join('test', e.toString()), so on Windows the argument becomes test\sub/foo_test.dart.
Windows accepts mixed separators, so it most likely works.
Use p.joinAll(p.posix.split(e)) there if you want the emitted path to stay native.
| import 'package:meta/meta.dart'; | ||
| import 'package:path/path.dart' as path; | ||
| import 'package:very_good_cli/src/cli/cli.dart'; | ||
| import 'package:very_good_cli/src/commands/test/test.dart'; |
There was a problem hiding this comment.
Nit: this import exists only to reach validateSharding, which sits next to FlutterTestOptions in the Flutter command.
CLAUDE.md rules out sibling dependencies in the same layer, and the function has no Flutter specifics.
Move it to lib/src/cli/, next to TestCLIRunner.
Validate sharding against the effective optimizer setting, so --platform, --update-goldens and explicit test paths are rejected instead of silently running the whole suite on every shard. Reject only an explicit --min-coverage and ignore a threshold inherited from very_good.yaml while sharding, since a single shard cannot meet it. Write an empty lcov.info for an empty shard so the merge step still finds a report per shard. Guard the hook against out of range shard values reached through `mason make`, look non optimized tests up in a set, keep the emitted test paths native, and move validateSharding next to TestCLIRunner so the dart command no longer depends on the flutter one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Thanks for the thorough review @marcossevilla. Addressed all comments. 🙂 |
Description
Adds
--shard-indexand--total-shardstovery_good testandvery_good dart test, so a test suite can be split across multiple CI runners with astrategy.matrix.Opening as a draft because @ryzizub is assigned to #1538 — happy to hand this over, close it, or adapt to a different design if work is already underway or a different approach is preferred.
Why this approach
The test optimizer already discovers every test file, so sharding is a partition of that list rather than new machinery.
Directory.listSyncorder is filesystem dependent, so without sorting two runners could disagree on the partition and either skip or duplicate tests. Sorting first makes the partition deterministic across machines; dealing out round-robin keeps shards balanced by file count.skip_very_good_optimizationrun as standalone files alongside the optimizer entrypoint. Leaving them unsharded would re-run all of them on every runner, partly defeating the purpose..test_optimizer_1_of_3.dart. That turned out to be unnecessary — each runner generates its own.test_optimizer.dartcontaining only its slice, and the file is cleaned up afterwards anyway. Same result, no filename plumbing.No tests were found, breaking builds on oversized matrices. It now reports success, matching the existing "no test folder" behaviour.Interaction with coverage
--min-coverageis rejected with a usage error when sharding. Each shard only exercises a subset of the codebase, so its coverage is not representative of the whole suite and would fail the build spuriously. The error explains the alternative: collect per-shard coverage with--coverage, merge the lcov reports, and enforce the threshold once in a separate job.Sharding also requires the optimizer, so it is rejected with
--no-optimizationand with--platform(which disables the optimizer). Both cases exit withExitCode.usageand an actionable message.This is the part I'd most like a maintainer opinion on — a documented merge-then-check workflow is the other reasonable option, and #804 may change what's possible here.
Testing
testanddart test.dart analyze --fatal-infos --fatal-warnings .is clean, and the touchedlib/files are at 100% line coverage.Note
The full suite passes with
-j 1(529 tests). At-j 8a handful of unrelated tests fail due to a pre-existing working-directory race between test files — I confirmed the same failures on a cleanmaincheckout, so it is not introduced here.Closes #1538
🤖 Generated with Claude Code