Skip to content

Commit 8665469

Browse files
committed
feat: add --collect-coverage-from option to include untested files in coverage
Closes #418 Adds a new --collect-coverage-from option to the very_good test command that allows users to choose between: - imports (default): collect coverage from imported files only (current behavior) - all: collect coverage from all Dart files in the project, showing 0% for untested files This enables stricter coverage requirements that account for all project files, not just those exercised by tests. Works with both Flutter and Dart projects. Implementation details: - Added CoverageCollectionMode enum with imports/all modes - Implemented _discoverDartFilesForCoverage() to find all .dart files - Implemented _enhanceLcovWithUntestedFiles() to add untested files to LCOV - Updated command-line parsing in test.dart and dart_test_command.dart - Threaded the option through the entire call chain - Respects existing --exclude-coverage and --report-on options Usage examples: very_good test --coverage very_good test --coverage --collect-coverage-from all very_good test --min-coverage 100 --collect-coverage-from all very_good test --coverage --collect-coverage-from all --exclude-coverage '**/*.g.dart' Files modified: - lib/src/cli/test_cli_runner.dart: Core implementation with enum, helpers, and logic - lib/src/commands/test/test.dart: Added option to Flutter test command - lib/src/commands/dart/commands/dart_test_command.dart: Added option to Dart test command - lib/src/cli/flutter_cli.dart: Thread parameter through Flutter CLI - lib/src/cli/dart_cli.dart: Thread parameter through Dart CLI - test/src/commands/test/test_test.dart: Updated Flutter test command tests - test/src/commands/dart/commands/dart_test_test.dart: Updated Dart test command tests
1 parent 04e0847 commit 8665469

File tree

7 files changed

+186
-1
lines changed

7 files changed

+186
-1
lines changed

lib/src/cli/dart_cli.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ class Dart {
104104
Set<String> ignore = const {},
105105
double? minCoverage,
106106
String? excludeFromCoverage,
107+
CoverageCollectionMode collectCoverageFrom = CoverageCollectionMode.imports,
107108
String? randomSeed,
108109
bool? forceAnsi,
109110
List<String>? arguments,
@@ -122,6 +123,7 @@ class Dart {
122123
ignore: ignore,
123124
minCoverage: minCoverage,
124125
excludeFromCoverage: excludeFromCoverage,
126+
collectCoverageFrom: collectCoverageFrom,
125127
randomSeed: randomSeed,
126128
forceAnsi: forceAnsi,
127129
arguments: arguments,

lib/src/cli/flutter_cli.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ class Flutter {
159159
Set<String> ignore = const {},
160160
double? minCoverage,
161161
String? excludeFromCoverage,
162+
CoverageCollectionMode collectCoverageFrom = CoverageCollectionMode.imports,
162163
String? randomSeed,
163164
bool? forceAnsi,
164165
List<String>? arguments,
@@ -176,6 +177,7 @@ class Flutter {
176177
ignore: ignore,
177178
minCoverage: minCoverage,
178179
excludeFromCoverage: excludeFromCoverage,
180+
collectCoverageFrom: collectCoverageFrom,
179181
randomSeed: randomSeed,
180182
forceAnsi: forceAnsi,
181183
arguments: arguments,

lib/src/cli/test_cli_runner.dart

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,23 @@ enum TestRunType {
1919
dart,
2020
}
2121

22+
/// How to collect coverage.
23+
enum CoverageCollectionMode {
24+
/// Collect coverage from imported files only (default behavior).
25+
imports,
26+
27+
/// Collect coverage from all files in the project.
28+
all;
29+
30+
/// Parses a string value into a [CoverageCollectionMode].
31+
static CoverageCollectionMode fromString(String value) {
32+
return CoverageCollectionMode.values.firstWhere(
33+
(mode) => mode.name == value,
34+
orElse: () => CoverageCollectionMode.imports,
35+
);
36+
}
37+
}
38+
2239
/// A method which returns a [Future<MasonGenerator>] given a [MasonBundle].
2340
typedef GeneratorBuilder = Future<MasonGenerator> Function(MasonBundle);
2441

@@ -72,6 +89,7 @@ class TestCLIRunner {
7289
Set<String> ignore = const {},
7390
double? minCoverage,
7491
String? excludeFromCoverage,
92+
CoverageCollectionMode collectCoverageFrom = CoverageCollectionMode.imports,
7593
String? randomSeed,
7694
bool? forceAnsi,
7795
List<String>? arguments,
@@ -197,13 +215,36 @@ class TestCLIRunner {
197215
// Write the lcov output to the file.
198216
await lcovFile.create(recursive: true);
199217
await lcovFile.writeAsString(output);
218+
219+
// If collectCoverageFrom is 'all', enhance with untested
220+
// files
221+
if (collectCoverageFrom == CoverageCollectionMode.all) {
222+
await _enhanceLcovWithUntestedFiles(
223+
lcovPath: lcovPath,
224+
cwd: cwd,
225+
reportOn: reportOn ?? 'lib',
226+
excludeFromCoverage: excludeFromCoverage,
227+
);
228+
}
200229
}
201230

202231
if (collectCoverage) {
203232
assert(
204233
lcovFile.existsSync(),
205234
'coverage/lcov.info must exist',
206235
);
236+
237+
// For Flutter tests with collectCoverageFrom = all, enhance
238+
// lcov
239+
if (testType == TestRunType.flutter &&
240+
collectCoverageFrom == CoverageCollectionMode.all) {
241+
await _enhanceLcovWithUntestedFiles(
242+
lcovPath: lcovPath,
243+
cwd: cwd,
244+
reportOn: 'lib',
245+
excludeFromCoverage: excludeFromCoverage,
246+
);
247+
}
207248
}
208249

209250
if (minCoverage != null) {
@@ -256,6 +297,87 @@ class TestCLIRunner {
256297
);
257298
}
258299

300+
/// Discovers all Dart files in the specified directory for coverage.
301+
static List<String> _discoverDartFilesForCoverage({
302+
required String cwd,
303+
required String reportOn,
304+
String? excludeFromCoverage,
305+
}) {
306+
final reportOnPath = p.join(cwd, reportOn);
307+
final directory = Directory(reportOnPath);
308+
309+
if (!directory.existsSync()) return [];
310+
311+
final glob = excludeFromCoverage != null ? Glob(excludeFromCoverage) : null;
312+
313+
return directory
314+
.listSync(recursive: true)
315+
.whereType<File>()
316+
.where((file) => file.path.endsWith('.dart'))
317+
.where((file) => glob == null || !glob.matches(file.path))
318+
.map((file) => p.relative(file.path, from: cwd))
319+
.toList();
320+
}
321+
322+
/// Enhances an existing lcov file by adding uncovered files with 0% coverage.
323+
static Future<void> _enhanceLcovWithUntestedFiles({
324+
required String lcovPath,
325+
required String cwd,
326+
required String reportOn,
327+
String? excludeFromCoverage,
328+
}) async {
329+
final lcovFile = File(lcovPath);
330+
331+
final allDartFiles = _discoverDartFilesForCoverage(
332+
cwd: cwd,
333+
reportOn: reportOn,
334+
excludeFromCoverage: excludeFromCoverage,
335+
);
336+
337+
// Parse existing lcov to find covered files
338+
final existingRecords = await Parser.parse(lcovPath);
339+
final coveredFiles = existingRecords
340+
.where((r) => r.file != null)
341+
.map((r) => r.file!)
342+
.toSet();
343+
344+
// Find uncovered files
345+
final uncoveredFiles = allDartFiles.where((file) {
346+
return !coveredFiles.any(
347+
(covered) => p.normalize(covered).endsWith(p.normalize(file)),
348+
);
349+
}).toList();
350+
351+
if (uncoveredFiles.isEmpty) return;
352+
353+
// Append uncovered files to lcov
354+
final lcovContent = await lcovFile.readAsString();
355+
final buffer = StringBuffer(lcovContent);
356+
357+
for (final file in uncoveredFiles) {
358+
final absolutePath = p.join(cwd, file);
359+
final dartFile = File(absolutePath);
360+
if (dartFile.existsSync()) {
361+
final lines = await dartFile.readAsLines();
362+
buffer.writeln('SF:$file');
363+
// Mark non-trivial lines as uncovered
364+
for (var i = 1; i <= lines.length; i++) {
365+
final line = lines[i - 1].trim();
366+
if (line.isNotEmpty &&
367+
!line.startsWith('//') &&
368+
!line.startsWith('import') &&
369+
!line.startsWith('export') &&
370+
!line.startsWith('part')) {
371+
buffer.writeln('DA:$i,0');
372+
}
373+
}
374+
buffer.writeln('end_of_record');
375+
}
376+
}
377+
378+
await lcovFile.writeAsString(buffer.toString());
379+
}
380+
259381
static List<File> _dartCoverageFilesToProcess(String absPath) {
260382
return Directory(absPath)
261383
.listSync(recursive: true)

lib/src/commands/dart/commands/dart_test_command.dart

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class DartTestOptions {
1717
required this.excludeTags,
1818
required this.tags,
1919
required this.excludeFromCoverage,
20+
required this.collectCoverageFrom,
2021
required this.randomSeed,
2122
required this.optimizePerformance,
2223
required this.failFast,
@@ -36,6 +37,10 @@ class DartTestOptions {
3637
final excludeTags = argResults['exclude-tags'] as String?;
3738
final tags = argResults['tags'] as String?;
3839
final excludeFromCoverage = argResults['exclude-coverage'] as String?;
40+
final collectCoverageFromString =
41+
argResults['collect-coverage-from'] as String? ?? 'imports';
42+
final collectCoverageFrom =
43+
CoverageCollectionMode.fromString(collectCoverageFromString);
3944
final randomOrderingSeed =
4045
argResults['test-randomize-ordering-seed'] as String?;
4146
final randomSeed = randomOrderingSeed == 'random'
@@ -55,6 +60,7 @@ class DartTestOptions {
5560
excludeTags: excludeTags,
5661
tags: tags,
5762
excludeFromCoverage: excludeFromCoverage,
63+
collectCoverageFrom: collectCoverageFrom,
5864
randomSeed: randomSeed,
5965
optimizePerformance: optimizePerformance,
6066
failFast: failFast,
@@ -83,6 +89,9 @@ class DartTestOptions {
8389
/// A glob which will be used to exclude files that match from the coverage.
8490
final String? excludeFromCoverage;
8591

92+
/// How to collect coverage.
93+
final CoverageCollectionMode collectCoverageFrom;
94+
8695
/// The seed to randomize the execution order of test cases within test files.
8796
final String? randomSeed;
8897

@@ -119,6 +128,7 @@ typedef DartTestCommandCall =
119128
bool optimizePerformance,
120129
double? minCoverage,
121130
String? excludeFromCoverage,
131+
CoverageCollectionMode collectCoverageFrom,
122132
String? randomSeed,
123133
bool? forceAnsi,
124134
List<String>? arguments,
@@ -188,6 +198,14 @@ class DartTestCommand extends Command<int> {
188198
'min-coverage',
189199
help: 'Whether to enforce a minimum coverage percentage.',
190200
)
201+
..addOption(
202+
'collect-coverage-from',
203+
help: 'Whether to collect coverage from imported files only or all '
204+
'files.',
205+
allowed: ['imports', 'all'],
206+
defaultsTo: 'imports',
207+
valueHelp: 'imports|all',
208+
)
191209
..addOption(
192210
'test-randomize-ordering-seed',
193211
help:
@@ -271,6 +289,7 @@ This command should be run from the root of your Dart project.''');
271289
options.collectCoverage || options.minCoverage != null,
272290
minCoverage: options.minCoverage,
273291
excludeFromCoverage: options.excludeFromCoverage,
292+
collectCoverageFrom: options.collectCoverageFrom,
274293
randomSeed: options.randomSeed,
275294
forceAnsi: options.forceAnsi,
276295
arguments: [

lib/src/commands/test/test.dart

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class FlutterTestOptions {
1717
required this.excludeTags,
1818
required this.tags,
1919
required this.excludeFromCoverage,
20+
required this.collectCoverageFrom,
2021
required this.randomSeed,
2122
required this.optimizePerformance,
2223
required this.updateGoldens,
@@ -38,6 +39,10 @@ class FlutterTestOptions {
3839
final excludeTags = argResults['exclude-tags'] as String?;
3940
final tags = argResults['tags'] as String?;
4041
final excludeFromCoverage = argResults['exclude-coverage'] as String?;
42+
final collectCoverageFromString =
43+
argResults['collect-coverage-from'] as String? ?? 'imports';
44+
final collectCoverageFrom =
45+
CoverageCollectionMode.fromString(collectCoverageFromString);
4146
final randomOrderingSeed =
4247
argResults['test-randomize-ordering-seed'] as String?;
4348
final randomSeed = randomOrderingSeed == 'random'
@@ -60,6 +65,7 @@ class FlutterTestOptions {
6065
excludeTags: excludeTags,
6166
tags: tags,
6267
excludeFromCoverage: excludeFromCoverage,
68+
collectCoverageFrom: collectCoverageFrom,
6369
randomSeed: randomSeed,
6470
optimizePerformance: optimizePerformance,
6571
updateGoldens: updateGoldens,
@@ -90,6 +96,9 @@ class FlutterTestOptions {
9096
/// A glob which will be used to exclude files that match from the coverage.
9197
final String? excludeFromCoverage;
9298

99+
/// How to collect coverage.
100+
final CoverageCollectionMode collectCoverageFrom;
101+
93102
/// The seed to randomize the execution order of test cases within test files.
94103
final String? randomSeed;
95104

@@ -134,6 +143,7 @@ typedef FlutterTestCommand =
134143
bool optimizePerformance,
135144
double? minCoverage,
136145
String? excludeFromCoverage,
146+
CoverageCollectionMode collectCoverageFrom,
137147
String? randomSeed,
138148
bool? forceAnsi,
139149
List<String>? arguments,
@@ -202,6 +212,14 @@ class TestCommand extends Command<int> {
202212
'min-coverage',
203213
help: 'Whether to enforce a minimum coverage percentage.',
204214
)
215+
..addOption(
216+
'collect-coverage-from',
217+
help: 'Whether to collect coverage from imported files only or all '
218+
'files.',
219+
allowed: ['imports', 'all'],
220+
defaultsTo: 'imports',
221+
valueHelp: 'imports|all',
222+
)
205223
..addOption(
206224
'test-randomize-ordering-seed',
207225
help:
@@ -311,6 +329,7 @@ This command should be run from the root of your Flutter project.''');
311329
options.collectCoverage || options.minCoverage != null,
312330
minCoverage: options.minCoverage,
313331
excludeFromCoverage: options.excludeFromCoverage,
332+
collectCoverageFrom: options.collectCoverageFrom,
314333
randomSeed: options.randomSeed,
315334
forceAnsi: options.forceAnsi,
316335
arguments: [

test/src/commands/dart/commands/dart_test_test.dart

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ const expectedTestUsage = [
3636
'-t, --tags Run only tests associated with the specified tags.\n'
3737
" --exclude-coverage A glob which will be used to exclude files that match from the coverage (e.g. '**/*.g.dart').\n"
3838
'-x, --exclude-tags Run only tests that do not have the specified tags.\n'
39-
' --min-coverage Whether to enforce a minimum coverage percentage.\n'
39+
' --min-coverage Whether to enforce a minimum coverage percentage.\n'
40+
' --collect-coverage-from=<imports|all> Whether to collect coverage from imported files only or all files.\n'
41+
' [imports (default), all]\n'
4042
' --test-randomize-ordering-seed The seed to randomize the execution order of test cases within test files.\n'
4143
' --fail-fast Stop running tests after the first failure.\n'
4244
' --force-ansi Whether to force ansi output. If not specified, it will maintain the default behavior based on stdout and stderr.\n'
@@ -57,6 +59,7 @@ abstract class DartTestCommandCall {
5759
bool optimizePerformance = false,
5860
double? minCoverage,
5961
String? excludeFromCoverage,
62+
CoverageCollectionMode collectCoverageFrom = CoverageCollectionMode.imports,
6063
String? randomSeed,
6164
List<String>? arguments,
6265
Logger? logger,
@@ -79,6 +82,10 @@ void main() {
7982
late DartTestCommandCall dartTest;
8083
late DartTestCommand testCommand;
8184

85+
setUpAll(() {
86+
registerFallbackValue(CoverageCollectionMode.imports);
87+
});
88+
8289
setUp(() {
8390
logger = _MockLogger();
8491
isFlutterInstalled = true;
@@ -97,6 +104,7 @@ void main() {
97104
optimizePerformance: any(named: 'optimizePerformance'),
98105
minCoverage: any(named: 'minCoverage'),
99106
excludeFromCoverage: any(named: 'excludeFromCoverage'),
107+
collectCoverageFrom: any(named: 'collectCoverageFrom'),
100108
randomSeed: any(named: 'randomSeed'),
101109
arguments: any(named: 'arguments'),
102110
logger: any(named: 'logger'),
@@ -112,6 +120,9 @@ void main() {
112120
when<dynamic>(() => argResults['fail-fast']).thenReturn(false);
113121
when<dynamic>(() => argResults['optimization']).thenReturn(true);
114122
when<dynamic>(() => argResults['platform']).thenReturn(null);
123+
when<dynamic>(() => argResults['collect-coverage-from'])
124+
.thenReturn('imports');
125+
when<dynamic>(() => argResults['report-on']).thenReturn(null);
115126
when(() => argResults.rest).thenReturn([]);
116127
});
117128

0 commit comments

Comments
 (0)