Skip to content

Commit

Permalink
feat(test): add --coverage and --min coverage options (#305)
Browse files Browse the repository at this point in the history
  • Loading branch information
erickzanardo authored Mar 15, 2022
1 parent 8445ed8 commit e542532
Show file tree
Hide file tree
Showing 7 changed files with 468 additions and 13 deletions.
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,10 @@ very_good test -r
Run tests in a Dart or Flutter project.

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

Run "very_good help" to see global options.
```
Expand Down
1 change: 1 addition & 0 deletions lib/src/cli/cli.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:async';

import 'package:lcov_parser/lcov_parser.dart';
import 'package:mason/mason.dart';
import 'package:path/path.dart' as p;
import 'package:universal_io/io.dart';
Expand Down
88 changes: 85 additions & 3 deletions lib/src/cli/flutter_cli.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,49 @@ part of 'cli.dart';
/// is executed without a `pubspec.yaml`.
class PubspecNotFound implements Exception {}

/// {@template coverage_not_met}
/// Thrown when `flutter test ---coverage --min-coverage`
/// does not meet the provided minimum coverage threshold.
/// {@endtemplate}
class MinCoverageNotMet implements Exception {
/// {@macro coverage_not_met}
const MinCoverageNotMet(this.coverage);

/// The measured coverage percentage (total hits / total found * 100).
final double coverage;
}

/// Thrown when `flutter test ---coverage --min-coverage value`
/// does not generate the coverage file within the timeout threshold.
class GenerateCoverageTimeout implements Exception {
@override
String toString() => 'Timed out waiting for coverage to be generated.';
}

class _CoverageMetrics {
const _CoverageMetrics._({this.totalHits = 0, this.totalFound = 0});

/// Generate coverage metrics from a list of lcov records.
factory _CoverageMetrics.fromLcovRecords(List<Record> records) {
return records.fold<_CoverageMetrics>(
const _CoverageMetrics._(),
(current, record) {
final found = record.lines?.found ?? 0;
final hit = record.lines?.hit ?? 0;
return _CoverageMetrics._(
totalFound: current.totalFound + found,
totalHits: current.totalHits + hit,
);
},
);
}

final int totalHits;
final int totalFound;

double get percentage => totalFound < 1 ? 0 : (totalHits / totalFound * 100);
}

/// Flutter CLI
class Flutter {
/// Determine whether flutter is installed.
Expand Down Expand Up @@ -62,22 +105,40 @@ class Flutter {
static Future<void> test({
String cwd = '.',
bool recursive = false,
bool collectCoverage = false,
double? minCoverage,
void Function(String)? stdout,
void Function(String)? stderr,
}) {
return _runCommand(
}) async {
final lcovPath = p.join(cwd, 'coverage', 'lcov.info');
final lcovFile = File(lcovPath);

if (collectCoverage && lcovFile.existsSync()) {
await lcovFile.delete();
}

await _runCommand(
cmd: (cwd) {
void noop(String? _) {}
stdout?.call('Running "flutter test" in $cwd...\n');
return _flutterTest(
cwd: cwd,
collectCoverage: collectCoverage,
stdout: stdout ?? noop,
stderr: stderr ?? noop,
);
},
cwd: cwd,
recursive: recursive,
);

if (collectCoverage) await lcovFile.ensureCreated();
if (minCoverage != null) {
final records = await Parser.parse(lcovPath);
final coverageMetrics = _CoverageMetrics.fromLcovRecords(records);
final coverage = coverageMetrics.percentage;
if (coverage < minCoverage) throw MinCoverageNotMet(coverage);
}
}
}

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

Future<void> _flutterTest({
String cwd = '.',
bool collectCoverage = false,
required void Function(String) stdout,
required void Function(String) stderr,
}) {
Expand Down Expand Up @@ -142,7 +204,13 @@ Future<void> _flutterTest({
},
);

flutterTest(workingDirectory: cwd, runInShell: true).listen(
flutterTest(
workingDirectory: cwd,
arguments: [
if (collectCoverage) '--coverage',
],
runInShell: true,
).listen(
(event) {
if (event.shouldCancelTimer()) timerSubscription.cancel();
if (event is SuiteTestEvent) suites[event.suite.id] = event.suite;
Expand Down Expand Up @@ -217,6 +285,20 @@ final int _lineLength = () {
}
}();

extension on File {
Future<void> ensureCreated({
Duration timeout = const Duration(seconds: 1),
Duration interval = const Duration(milliseconds: 50),
}) async {
var elapsedTime = Duration.zero;
while (!existsSync()) {
await Future<void>.delayed(interval);
elapsedTime += interval;
if (elapsedTime >= timeout) throw GenerateCoverageTimeout();
}
}
}

extension on TestEvent {
bool shouldCancelTimer() {
final event = this;
Expand Down
33 changes: 27 additions & 6 deletions lib/src/commands/test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,22 @@ import 'package:very_good_cli/src/cli/cli.dart';
class TestCommand extends Command<int> {
/// {@macro test_command}
TestCommand({Logger? logger}) : _logger = logger ?? Logger() {
argParser.addFlag(
'recursive',
abbr: 'r',
help: 'Run tests recursively for all nested packages.',
negatable: false,
);
argParser
..addFlag(
'recursive',
abbr: 'r',
help: 'Run tests recursively for all nested packages.',
negatable: false,
)
..addFlag(
'coverage',
help: 'Whether to collect coverage information.',
negatable: false,
)
..addOption(
'min-coverage',
help: 'Whether to enforce a minimum coverage percentage.',
);
}

final Logger _logger;
Expand All @@ -43,6 +53,10 @@ class TestCommand extends Command<int> {
final recursive = _argResults['recursive'] as bool;
final target = _argResults.rest.length == 1 ? _argResults.rest[0] : '.';
final targetPath = path.normalize(Directory(target).absolute.path);
final collectCoverage = _argResults['coverage'] as bool;
final minCoverage = double.tryParse(
_argResults['min-coverage'] as String? ?? '',
);
final isFlutterInstalled = await Flutter.installed();
if (isFlutterInstalled) {
try {
Expand All @@ -51,10 +65,17 @@ class TestCommand extends Command<int> {
recursive: recursive,
stdout: _logger.write,
stderr: _logger.err,
collectCoverage: collectCoverage,
minCoverage: minCoverage,
);
} on PubspecNotFound catch (_) {
_logger.err('Could not find a pubspec.yaml in $targetPath');
return ExitCode.noInput.code;
} on MinCoverageNotMet catch (e) {
_logger.err(
'''Expected coverage >= ${minCoverage!.toStringAsFixed(2)}% but actual is ${e.coverage.toStringAsFixed(2)}%.''',
);
return ExitCode.unavailable.code;
} catch (error) {
_logger.err('$error');
return ExitCode.unavailable.code;
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ environment:

dependencies:
args: ^2.1.0
lcov_parser: ^0.1.2
mason: ">=0.1.0-dev.9 <0.1.0-dev.10"
mason_logger: ^0.1.0-dev.6
meta: ^1.3.0
Expand Down
Loading

0 comments on commit e542532

Please sign in to comment.