Skip to content

Commit e542532

Browse files
authored
feat(test): add --coverage and --min coverage options (#305)
1 parent 8445ed8 commit e542532

File tree

7 files changed

+468
-13
lines changed

7 files changed

+468
-13
lines changed

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,10 @@ very_good test -r
155155
Run tests in a Dart or Flutter project.
156156

157157
Usage: very_good test [arguments]
158-
-h, --help Print this usage information.
159-
-r, --recursive Run tests recursively for all nested packages.
158+
-h, --help Print this usage information.
159+
-r, --recursive Run tests recursively for all nested packages.
160+
--coverage Whether to collect coverage information.
161+
--min-coverage Whether to enforce a minimum coverage percentage.
160162

161163
Run "very_good help" to see global options.
162164
```

lib/src/cli/cli.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'dart:async';
22

3+
import 'package:lcov_parser/lcov_parser.dart';
34
import 'package:mason/mason.dart';
45
import 'package:path/path.dart' as p;
56
import 'package:universal_io/io.dart';

lib/src/cli/flutter_cli.dart

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,49 @@ part of 'cli.dart';
44
/// is executed without a `pubspec.yaml`.
55
class PubspecNotFound implements Exception {}
66

7+
/// {@template coverage_not_met}
8+
/// Thrown when `flutter test ---coverage --min-coverage`
9+
/// does not meet the provided minimum coverage threshold.
10+
/// {@endtemplate}
11+
class MinCoverageNotMet implements Exception {
12+
/// {@macro coverage_not_met}
13+
const MinCoverageNotMet(this.coverage);
14+
15+
/// The measured coverage percentage (total hits / total found * 100).
16+
final double coverage;
17+
}
18+
19+
/// Thrown when `flutter test ---coverage --min-coverage value`
20+
/// does not generate the coverage file within the timeout threshold.
21+
class GenerateCoverageTimeout implements Exception {
22+
@override
23+
String toString() => 'Timed out waiting for coverage to be generated.';
24+
}
25+
26+
class _CoverageMetrics {
27+
const _CoverageMetrics._({this.totalHits = 0, this.totalFound = 0});
28+
29+
/// Generate coverage metrics from a list of lcov records.
30+
factory _CoverageMetrics.fromLcovRecords(List<Record> records) {
31+
return records.fold<_CoverageMetrics>(
32+
const _CoverageMetrics._(),
33+
(current, record) {
34+
final found = record.lines?.found ?? 0;
35+
final hit = record.lines?.hit ?? 0;
36+
return _CoverageMetrics._(
37+
totalFound: current.totalFound + found,
38+
totalHits: current.totalHits + hit,
39+
);
40+
},
41+
);
42+
}
43+
44+
final int totalHits;
45+
final int totalFound;
46+
47+
double get percentage => totalFound < 1 ? 0 : (totalHits / totalFound * 100);
48+
}
49+
750
/// Flutter CLI
851
class Flutter {
952
/// Determine whether flutter is installed.
@@ -62,22 +105,40 @@ class Flutter {
62105
static Future<void> test({
63106
String cwd = '.',
64107
bool recursive = false,
108+
bool collectCoverage = false,
109+
double? minCoverage,
65110
void Function(String)? stdout,
66111
void Function(String)? stderr,
67-
}) {
68-
return _runCommand(
112+
}) async {
113+
final lcovPath = p.join(cwd, 'coverage', 'lcov.info');
114+
final lcovFile = File(lcovPath);
115+
116+
if (collectCoverage && lcovFile.existsSync()) {
117+
await lcovFile.delete();
118+
}
119+
120+
await _runCommand(
69121
cmd: (cwd) {
70122
void noop(String? _) {}
71123
stdout?.call('Running "flutter test" in $cwd...\n');
72124
return _flutterTest(
73125
cwd: cwd,
126+
collectCoverage: collectCoverage,
74127
stdout: stdout ?? noop,
75128
stderr: stderr ?? noop,
76129
);
77130
},
78131
cwd: cwd,
79132
recursive: recursive,
80133
);
134+
135+
if (collectCoverage) await lcovFile.ensureCreated();
136+
if (minCoverage != null) {
137+
final records = await Parser.parse(lcovPath);
138+
final coverageMetrics = _CoverageMetrics.fromLcovRecords(records);
139+
final coverage = coverageMetrics.percentage;
140+
if (coverage < minCoverage) throw MinCoverageNotMet(coverage);
141+
}
81142
}
82143
}
83144

@@ -110,6 +171,7 @@ Future<void> _runCommand<T>({
110171

111172
Future<void> _flutterTest({
112173
String cwd = '.',
174+
bool collectCoverage = false,
113175
required void Function(String) stdout,
114176
required void Function(String) stderr,
115177
}) {
@@ -142,7 +204,13 @@ Future<void> _flutterTest({
142204
},
143205
);
144206

145-
flutterTest(workingDirectory: cwd, runInShell: true).listen(
207+
flutterTest(
208+
workingDirectory: cwd,
209+
arguments: [
210+
if (collectCoverage) '--coverage',
211+
],
212+
runInShell: true,
213+
).listen(
146214
(event) {
147215
if (event.shouldCancelTimer()) timerSubscription.cancel();
148216
if (event is SuiteTestEvent) suites[event.suite.id] = event.suite;
@@ -217,6 +285,20 @@ final int _lineLength = () {
217285
}
218286
}();
219287

288+
extension on File {
289+
Future<void> ensureCreated({
290+
Duration timeout = const Duration(seconds: 1),
291+
Duration interval = const Duration(milliseconds: 50),
292+
}) async {
293+
var elapsedTime = Duration.zero;
294+
while (!existsSync()) {
295+
await Future<void>.delayed(interval);
296+
elapsedTime += interval;
297+
if (elapsedTime >= timeout) throw GenerateCoverageTimeout();
298+
}
299+
}
300+
}
301+
220302
extension on TestEvent {
221303
bool shouldCancelTimer() {
222304
final event = this;

lib/src/commands/test.dart

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,22 @@ import 'package:very_good_cli/src/cli/cli.dart';
1212
class TestCommand extends Command<int> {
1313
/// {@macro test_command}
1414
TestCommand({Logger? logger}) : _logger = logger ?? Logger() {
15-
argParser.addFlag(
16-
'recursive',
17-
abbr: 'r',
18-
help: 'Run tests recursively for all nested packages.',
19-
negatable: false,
20-
);
15+
argParser
16+
..addFlag(
17+
'recursive',
18+
abbr: 'r',
19+
help: 'Run tests recursively for all nested packages.',
20+
negatable: false,
21+
)
22+
..addFlag(
23+
'coverage',
24+
help: 'Whether to collect coverage information.',
25+
negatable: false,
26+
)
27+
..addOption(
28+
'min-coverage',
29+
help: 'Whether to enforce a minimum coverage percentage.',
30+
);
2131
}
2232

2333
final Logger _logger;
@@ -43,6 +53,10 @@ class TestCommand extends Command<int> {
4353
final recursive = _argResults['recursive'] as bool;
4454
final target = _argResults.rest.length == 1 ? _argResults.rest[0] : '.';
4555
final targetPath = path.normalize(Directory(target).absolute.path);
56+
final collectCoverage = _argResults['coverage'] as bool;
57+
final minCoverage = double.tryParse(
58+
_argResults['min-coverage'] as String? ?? '',
59+
);
4660
final isFlutterInstalled = await Flutter.installed();
4761
if (isFlutterInstalled) {
4862
try {
@@ -51,10 +65,17 @@ class TestCommand extends Command<int> {
5165
recursive: recursive,
5266
stdout: _logger.write,
5367
stderr: _logger.err,
68+
collectCoverage: collectCoverage,
69+
minCoverage: minCoverage,
5470
);
5571
} on PubspecNotFound catch (_) {
5672
_logger.err('Could not find a pubspec.yaml in $targetPath');
5773
return ExitCode.noInput.code;
74+
} on MinCoverageNotMet catch (e) {
75+
_logger.err(
76+
'''Expected coverage >= ${minCoverage!.toStringAsFixed(2)}% but actual is ${e.coverage.toStringAsFixed(2)}%.''',
77+
);
78+
return ExitCode.unavailable.code;
5879
} catch (error) {
5980
_logger.err('$error');
6081
return ExitCode.unavailable.code;

pubspec.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ environment:
88

99
dependencies:
1010
args: ^2.1.0
11+
lcov_parser: ^0.1.2
1112
mason: ">=0.1.0-dev.9 <0.1.0-dev.10"
1213
mason_logger: ^0.1.0-dev.6
1314
meta: ^1.3.0

0 commit comments

Comments
 (0)