Branch coverage (#361)

* WIP: branch coverage

* Pretty printing of branch coverage

* WIP: testing

* Finish testing

* Changelog and pubspec

* Warn user if their VM is too old to support branch coverage

* Update readme to mention function and branch coverage

* Fix some of the failing tests on older VM versions

* Fix run_and_collect_test

* Actually fix run_and_collect_test

* Use skips instead of ifs in tests
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 301d577..ef3500d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
-## 1.1.1-dev
+## 1.2.0-dev
 
+* Support branch level coverage information, when running tests in the Dart VM.
+  This is not supported for web tests yet.
+* Add flag `--branch-coverage` (abbr `-b`) to collect_coverage that collects
+  branch coverage information. The VM must also be run with the
+  `--branch-coverage` flag.
+* Add flag `--pretty-print-branch` to format_coverage that works
+  similarly to pretty print, but outputs branch level coverage, rather than
+  line level.
+* Update `--lcov` (abbr `-l`) in format_coverage to output branch level
+  coverage, in addition to line level.
+* Add an optional bool flag to `collect` that controls whether branch coverage
+  is collected.
+* Add a `branchHits` field to `HitMap`.
 * Add support for scraping the service URI from the new Dart VM service message.
 
 ## 1.1.0 - 2022-1-18
diff --git a/README.md b/README.md
index 57ace83..3d038df 100644
--- a/README.md
+++ b/README.md
@@ -61,3 +61,30 @@
 - `// coverage:ignore-line` to ignore one line.
 - `// coverage:ignore-start` and `// coverage:ignore-end` to ignore range of lines inclusive.
 - `// coverage:ignore-file` to ignore the whole file.
+
+#### Function and branch coverage
+
+To gather function level coverage information, pass `--function-coverage` to
+collect_coverage:
+
+```
+dart --pause-isolates-on-exit --disable-service-auth-codes --enable-vm-service=NNNN script.dart
+pub global run coverage:collect_coverage --uri=http://... -o coverage.json --resume-isolates --function-coverage
+```
+
+To gather branch level coverage information, pass `--branch-coverage` to *both*
+collect_coverage and the Dart command you're gathering coverage from:
+
+```
+dart --pause-isolates-on-exit --disable-service-auth-codes --enable-vm-service=NNNN --branch-coverage script.dart
+pub global run coverage:collect_coverage --uri=http://... -o coverage.json --resume-isolates --branch-coverage
+```
+
+Branch coverage requires Dart VM 2.17.0, with service API v3.56. Function,
+branch, and line coverage can all be gathered at the same time, by combining
+those flags:
+
+```
+dart --pause-isolates-on-exit --disable-service-auth-codes --enable-vm-service=NNNN --branch-coverage script.dart
+pub global run coverage:collect_coverage --uri=http://... -o coverage.json --resume-isolates --function-coverage --branch-coverage
+```
diff --git a/bin/collect_coverage.dart b/bin/collect_coverage.dart
index 47ab4e0..b02ac21 100644
--- a/bin/collect_coverage.dart
+++ b/bin/collect_coverage.dart
@@ -21,7 +21,9 @@
   await Chain.capture(() async {
     final coverage = await collect(options.serviceUri, options.resume,
         options.waitPaused, options.includeDart, options.scopedOutput,
-        timeout: options.timeout, functionCoverage: options.functionCoverage);
+        timeout: options.timeout,
+        functionCoverage: options.functionCoverage,
+        branchCoverage: options.branchCoverage);
     options.out.write(json.encode(coverage));
     await options.out.close();
   }, onError: (dynamic error, Chain chain) {
@@ -34,8 +36,16 @@
 }
 
 class Options {
-  Options(this.serviceUri, this.out, this.timeout, this.waitPaused, this.resume,
-      this.includeDart, this.functionCoverage, this.scopedOutput);
+  Options(
+      this.serviceUri,
+      this.out,
+      this.timeout,
+      this.waitPaused,
+      this.resume,
+      this.includeDart,
+      this.functionCoverage,
+      this.branchCoverage,
+      this.scopedOutput);
 
   final Uri serviceUri;
   final IOSink out;
@@ -44,6 +54,7 @@
   final bool resume;
   final bool includeDart;
   final bool functionCoverage;
+  final bool branchCoverage;
   final Set<String> scopedOutput;
 }
 
@@ -75,6 +86,11 @@
         abbr: 'd', defaultsTo: false, help: 'include "dart:" libraries')
     ..addFlag('function-coverage',
         abbr: 'f', defaultsTo: false, help: 'Collect function coverage info')
+    ..addFlag('branch-coverage',
+        abbr: 'b',
+        defaultsTo: false,
+        help: 'Collect branch coverage info (Dart VM must also be run with '
+            '--branch-coverage for this to work)')
     ..addFlag('help', abbr: 'h', negatable: false, help: 'show this help');
 
   final args = parser.parse(arguments);
@@ -127,6 +143,7 @@
     args['resume-isolates'] as bool,
     args['include-dart'] as bool,
     args['function-coverage'] as bool,
+    args['branch-coverage'] as bool,
     scopedOutput.toSet(),
   );
 }
diff --git a/bin/format_coverage.dart b/bin/format_coverage.dart
index 27d2e71..63fc48c 100644
--- a/bin/format_coverage.dart
+++ b/bin/format_coverage.dart
@@ -21,6 +21,7 @@
     required this.packagesPath,
     required this.prettyPrint,
     required this.prettyPrintFunc,
+    required this.prettyPrintBranch,
     required this.reportOn,
     required this.sdkRoot,
     required this.verbose,
@@ -37,6 +38,7 @@
   String? packagesPath;
   bool prettyPrint;
   bool prettyPrintFunc;
+  bool prettyPrintBranch;
   List<String>? reportOn;
   String? sdkRoot;
   bool verbose;
@@ -74,9 +76,11 @@
       ? BazelResolver(workspacePath: env.bazelWorkspace)
       : Resolver(packagesPath: env.packagesPath, sdkRoot: env.sdkRoot);
   final loader = Loader();
-  if (env.prettyPrint || env.prettyPrintFunc) {
+  if (env.prettyPrint) {
     output = await hitmap.prettyPrint(resolver, loader,
-        reportOn: env.reportOn, reportFuncs: env.prettyPrintFunc);
+        reportOn: env.reportOn,
+        reportFuncs: env.prettyPrintFunc,
+        reportBranches: env.prettyPrintBranch);
   } else {
     assert(env.lcov);
     output = hitmap.formatLcov(resolver,
@@ -135,6 +139,9 @@
       abbr: 'f',
       negatable: false,
       help: 'convert function coverage data to pretty print format');
+  parser.addFlag('pretty-print-branch',
+      negatable: false,
+      help: 'convert branch coverage data to pretty print format');
   parser.addFlag('lcov',
       abbr: 'l',
       negatable: false,
@@ -217,12 +224,17 @@
   final lcov = args['lcov'] as bool;
   var prettyPrint = args['pretty-print'] as bool;
   final prettyPrintFunc = args['pretty-print-func'] as bool;
-  if ((prettyPrint ? 1 : 0) + (prettyPrintFunc ? 1 : 0) + (lcov ? 1 : 0) > 1) {
+  final prettyPrintBranch = args['pretty-print-branch'] as bool;
+  final numModesChosen = (prettyPrint ? 1 : 0) +
+      (prettyPrintFunc ? 1 : 0) +
+      (prettyPrintBranch ? 1 : 0) +
+      (lcov ? 1 : 0);
+  if (numModesChosen > 1) {
     fail('Choose one of the pretty-print modes or lcov output');
   }
 
-  // Use pretty-print either explicitly or by default.
-  if (!lcov && !prettyPrintFunc) prettyPrint = true;
+  // The pretty printer is used by all modes other than lcov.
+  if (!lcov) prettyPrint = true;
 
   int workers;
   try {
@@ -244,6 +256,7 @@
       packagesPath: packagesPath,
       prettyPrint: prettyPrint,
       prettyPrintFunc: prettyPrintFunc,
+      prettyPrintBranch: prettyPrintBranch,
       reportOn: reportOn,
       sdkRoot: sdkRoot,
       verbose: verbose,
diff --git a/lib/src/collect.dart b/lib/src/collect.dart
index 5cd6695..0a5fc24 100644
--- a/lib/src/collect.dart
+++ b/lib/src/collect.dart
@@ -33,6 +33,10 @@
 /// If [functionCoverage] is true, function coverage information will be
 /// collected.
 ///
+/// If [branchCoverage] is true, branch coverage information will be collected.
+/// This will only work correctly if the target VM was run with the
+/// --branch-coverage flag.
+///
 /// If [scopedOutput] is non-empty, coverage will be restricted so that only
 /// scripts that start with any of the provided paths are considered.
 ///
@@ -42,7 +46,8 @@
     bool waitPaused, bool includeDart, Set<String>? scopedOutput,
     {Set<String>? isolateIds,
     Duration? timeout,
-    bool functionCoverage = false}) async {
+    bool functionCoverage = false,
+    bool branchCoverage = false}) async {
   scopedOutput ??= <String>{};
 
   // Create websocket URI. Handle any trailing slashes.
@@ -76,8 +81,8 @@
       await _waitIsolatesPaused(service, timeout: timeout);
     }
 
-    return await _getAllCoverage(
-        service, includeDart, functionCoverage, scopedOutput, isolateIds);
+    return await _getAllCoverage(service, includeDart, functionCoverage,
+        branchCoverage, scopedOutput, isolateIds);
   } finally {
     if (resume) {
       await _resumeIsolates(service);
@@ -88,19 +93,34 @@
   }
 }
 
+bool _versionCheck(Version version, int minMajor, int minMinor) {
+  final major = version.major ?? 0;
+  final minor = version.minor ?? 0;
+  return major > minMajor || (major == minMajor && minor >= minMinor);
+}
+
 Future<Map<String, dynamic>> _getAllCoverage(
     VmService service,
     bool includeDart,
     bool functionCoverage,
+    bool branchCoverage,
     Set<String>? scopedOutput,
     Set<String>? isolateIds) async {
   scopedOutput ??= <String>{};
   final vm = await service.getVM();
   final allCoverage = <Map<String, dynamic>>[];
   final version = await service.getVersion();
-  final reportLines =
-      (version.major == 3 && version.minor != null && version.minor! >= 51) ||
-          (version.major != null && version.major! > 3);
+  final reportLines = _versionCheck(version, 3, 51);
+  final branchCoverageSupported = _versionCheck(version, 3, 56);
+  if (branchCoverage && !branchCoverageSupported) {
+    branchCoverage = false;
+    stderr.write('Branch coverage was requested, but is not supported'
+        ' by the VM version. Try updating to a newer version of Dart');
+  }
+  final sourceReportKinds = [
+    SourceReportKind.kCoverage,
+    if (branchCoverage) SourceReportKind.kBranchCoverage,
+  ];
 
   // Program counters are shared between isolates in the same group. So we need
   // to make sure we're only gathering coverage data for one isolate in each
@@ -130,7 +150,7 @@
         // Skip scripts which should not be included in the report.
         if (!scopedOutput.contains(scope)) continue;
         final scriptReport = await service.getSourceReport(
-            isolateRef.id!, <String>[SourceReportKind.kCoverage],
+            isolateRef.id!, sourceReportKinds,
             forceCompile: true,
             scriptId: script.id,
             reportLines: reportLines ? true : null);
@@ -141,7 +161,7 @@
     } else {
       final isolateReport = await service.getSourceReport(
         isolateRef.id!,
-        <String>[SourceReportKind.kCoverage],
+        sourceReportKinds,
         forceCompile: true,
         reportLines: reportLines ? true : null,
       );
@@ -231,7 +251,8 @@
     final line = _getLineFromTokenPos(script, tokenPos);
 
     if (line == null) {
-      print('tokenPos $tokenPos has no line mapping for script ${script.uri!}');
+      stderr.write(
+          'tokenPos $tokenPos has no line mapping for script ${script.uri!}');
       return;
     }
     hits.funcNames![line] = funcName;
@@ -306,28 +327,41 @@
 
     if (coverage == null) continue;
 
-    for (final pos in coverage.hits!) {
-      final line = reportLines ? pos : _getLineFromTokenPos(script!, pos);
-      if (line == null) {
-        print('tokenPos $pos has no line mapping for script $scriptUri');
-        continue;
+    void forEachLine(List<int> tokenPositions, void Function(int line) body) {
+      for (final pos in tokenPositions) {
+        final line = reportLines ? pos : _getLineFromTokenPos(script!, pos);
+        if (line == null) {
+          stderr
+              .write('tokenPos $pos has no line mapping for script $scriptUri');
+          continue;
+        }
+        body(line);
       }
+    }
+
+    forEachLine(coverage.hits!, (line) {
       _incrementCountForKey(hits.lineHits, line);
       if (hits.funcNames != null && hits.funcNames!.containsKey(line)) {
         _incrementCountForKey(hits.funcHits!, line);
       }
-    }
-    for (final pos in coverage.misses!) {
-      final line = reportLines ? pos : _getLineFromTokenPos(script!, pos);
-      if (line == null) {
-        print('tokenPos $pos has no line mapping for script $scriptUri');
-        continue;
-      }
+    });
+    forEachLine(coverage.misses!, (line) {
       hits.lineHits.putIfAbsent(line, () => 0);
-    }
+    });
     hits.funcNames?.forEach((line, funcName) {
       hits.funcHits?.putIfAbsent(line, () => 0);
     });
+
+    final branchCoverage = range.branchCoverage;
+    if (branchCoverage != null) {
+      hits.branchHits ??= <int, int>{};
+      forEachLine(branchCoverage.hits!, (line) {
+        _incrementCountForKey(hits.branchHits!, line);
+      });
+      forEachLine(branchCoverage.misses!, (line) {
+        hits.branchHits!.putIfAbsent(line, () => 0);
+      });
+    }
   }
 
   // Output JSON
diff --git a/lib/src/formatter.dart b/lib/src/formatter.dart
index 85b1e21..763fb52 100644
--- a/lib/src/formatter.dart
+++ b/lib/src/formatter.dart
@@ -84,6 +84,7 @@
       final lineHits = v.lineHits;
       final funcHits = v.funcHits;
       final funcNames = v.funcNames;
+      final branchHits = v.branchHits;
       var source = resolver.resolve(entry.key);
       if (source == null) {
         continue;
@@ -115,6 +116,11 @@
       }
       buf.write('LF:${lineHits.length}\n');
       buf.write('LH:${lineHits.values.where((v) => v > 0).length}\n');
+      if (branchHits != null) {
+        for (final k in branchHits.keys.toList()..sort()) {
+          buf.write('BRDA:$k,0,0,${branchHits[k]}\n');
+        }
+      }
       buf.write('end_of_record\n');
     }
 
@@ -131,6 +137,7 @@
     Loader loader, {
     List<String>? reportOn,
     bool reportFuncs = false,
+    bool reportBranches = false,
   }) async {
     final pathFilter = _getPathFilter(reportOn);
     final buf = StringBuffer();
@@ -141,7 +148,16 @@
             'missing function coverage information. Did you run '
             'collect_coverage with the --function-coverage flag?';
       }
-      final hits = reportFuncs ? v.funcHits! : v.lineHits;
+      if (reportBranches && v.branchHits == null) {
+        throw 'Branch coverage formatting was requested, but the hit map is '
+            'missing branch coverage information. Did you run '
+            'collect_coverage with the --branch-coverage flag?';
+      }
+      final hits = reportFuncs
+          ? v.funcHits!
+          : reportBranches
+              ? v.branchHits!
+              : v.lineHits;
       final source = resolver.resolve(entry.key);
       if (source == null) {
         continue;
diff --git a/lib/src/hitmap.dart b/lib/src/hitmap.dart
index c748b2b..f94754e 100644
--- a/lib/src/hitmap.dart
+++ b/lib/src/hitmap.dart
@@ -11,8 +11,12 @@
 /// Contains line and function hit information for a single script.
 class HitMap {
   /// Constructs a HitMap.
-  HitMap([Map<int, int>? lineHits, this.funcHits, this.funcNames])
-      : lineHits = lineHits ?? {};
+  HitMap([
+    Map<int, int>? lineHits,
+    this.funcHits,
+    this.funcNames,
+    this.branchHits,
+  ]) : lineHits = lineHits ?? {};
 
   /// Map from line to hit count for that line.
   final Map<int, int> lineHits;
@@ -25,6 +29,10 @@
   /// function coverage info was not gathered.
   Map<int, String>? funcNames;
 
+  /// Map from branch line, to the hit count for that branch. Null if branch
+  /// coverage info was not gathered.
+  Map<int, int>? branchHits;
+
   /// Creates a single hitmap from a raw json object.
   ///
   /// Throws away all entries that are not resolvable.
@@ -139,6 +147,10 @@
               funcNames[i + 1] as String;
         }
       }
+      if (e.containsKey('branchHits')) {
+        sourceHitMap.branchHits ??= <int, int>{};
+        fillHitMap(e['branchHits'] as List, sourceHitMap.branchHits!);
+      }
     }
     return globalHitMap;
   }
@@ -183,6 +195,10 @@
             fileResult.funcNames![line] = name;
           });
         }
+        if (v.branchHits != null) {
+          fileResult.branchHits ??= <int, int>{};
+          _mergeHitCounts(v.branchHits!, fileResult.branchHits!);
+        }
       } else {
         this[file] = v;
       }
@@ -300,6 +316,9 @@
   if (hitmap.funcNames != null) {
     json['funcNames'] = _flattenMap<dynamic>(hitmap.funcNames!);
   }
+  if (hitmap.branchHits != null) {
+    json['branchHits'] = _flattenMap<int>(hitmap.branchHits!);
+  }
   return json;
 }
 
diff --git a/pubspec.yaml b/pubspec.yaml
index e7a5bf9..d0ceadb 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: coverage
-version: 1.1.0
+version: 1.2.0-dev
 description: Coverage data manipulation and formatting
 homepage: https://github.com/dart-lang/coverage
 
@@ -13,7 +13,7 @@
   path: ^1.8.0
   source_maps: ^0.10.10
   stack_trace: ^1.10.0
-  vm_service: ">=8.1.0 <9.0.0"
+  vm_service: ">=8.2.0 <9.0.0"
 dev_dependencies:
   lints: ^1.0.0
   test: ^1.16.0
diff --git a/test/collect_coverage_api_test.dart b/test/collect_coverage_api_test.dart
index 16cce21..3d0dcc7 100644
--- a/test/collect_coverage_api_test.dart
+++ b/test/collect_coverage_api_test.dart
@@ -98,12 +98,35 @@
       expect(sampleCoverageData['funcHits'], isNotEmpty);
     }
   });
+
+  test('collect_coverage_api with branch coverage', () async {
+    final json = await _collectCoverage(branchCoverage: true);
+    expect(json.keys, unorderedEquals(<String>['type', 'coverage']));
+    expect(json, containsPair('type', 'CodeCoverage'));
+
+    final coverage = json['coverage'] as List;
+    expect(coverage, isNotEmpty);
+
+    final sources = coverage.cast<Map>().fold(<String, List<Map>>{},
+        (Map<String, List<Map>> map, value) {
+      final sourceUri = value['source'] as String;
+      map.putIfAbsent(sourceUri, () => <Map>[]).add(value);
+      return map;
+    });
+
+    // Dart VM versions before 2.17 don't support branch coverage.
+    expect(sources[_sampleAppFileUri],
+        everyElement(containsPair('branchHits', isNotEmpty)));
+    expect(sources[_isolateLibFileUri],
+        everyElement(containsPair('branchHits', isNotEmpty)));
+  }, skip: !platformVersionCheck(2, 17));
 }
 
 Future<Map<String, dynamic>> _collectCoverage(
     {Set<String> scopedOutput = const {},
     bool isolateIds = false,
-    bool functionCoverage = false}) async {
+    bool functionCoverage = false,
+    bool branchCoverage = false}) async {
   final openPort = await getOpenPort();
 
   // run the sample app, with the right flags
@@ -129,5 +152,6 @@
   return collect(serviceUri, true, true, false, scopedOutput,
       timeout: timeout,
       isolateIds: isolateIdSet,
-      functionCoverage: functionCoverage);
+      functionCoverage: functionCoverage,
+      branchCoverage: branchCoverage);
 }
diff --git a/test/collect_coverage_test.dart b/test/collect_coverage_test.dart
index 0537235..7261eb7 100644
--- a/test/collect_coverage_test.dart
+++ b/test/collect_coverage_test.dart
@@ -113,7 +113,7 @@
   });
 
   test('HitMap.parseJson', () async {
-    final resultString = await _collectCoverage(true);
+    final resultString = await _collectCoverage(true, true);
     final jsonResult = json.decode(resultString) as Map<String, dynamic>;
     final coverage = jsonResult['coverage'] as List;
     final hitMap = await HitMap.parseJson(
@@ -179,7 +179,78 @@
       28: 'fooAsync',
       38: 'isolateTask'
     });
-  });
+    expect(isolateFile?.branchHits,
+        {11: 1, 12: 1, 15: 0, 19: 1, 23: 1, 28: 1, 32: 0, 38: 1, 42: 1});
+  }, skip: !platformVersionCheck(2, 17));
+
+  test('HitMap.parseJson, old VM without branch coverage', () async {
+    final resultString = await _collectCoverage(true, true);
+    final jsonResult = json.decode(resultString) as Map<String, dynamic>;
+    final coverage = jsonResult['coverage'] as List;
+    final hitMap = await HitMap.parseJson(
+      coverage.cast<Map<String, dynamic>>(),
+    );
+    expect(hitMap, contains(_sampleAppFileUri));
+
+    final isolateFile = hitMap[_isolateLibFileUri];
+    final expectedHits = {
+      11: 1,
+      12: 1,
+      13: 1,
+      15: 0,
+      19: 1,
+      23: 1,
+      24: 2,
+      28: 1,
+      29: 1,
+      30: 1,
+      32: 0,
+      38: 1,
+      39: 1,
+      41: 1,
+      42: 3,
+      43: 1,
+      44: 3,
+      45: 1,
+      48: 1,
+      49: 1,
+      51: 1,
+      54: 1,
+      55: 1,
+      56: 1,
+      59: 1,
+      60: 1,
+      62: 1,
+      63: 1,
+      64: 1,
+      66: 1,
+      67: 1,
+      68: 1
+    };
+    if (Platform.version.startsWith('1.')) {
+      // Dart VMs prior to 2.0.0-dev.5.0 contain a bug that emits coverage on the
+      // closing brace of async function blocks.
+      // See: https://github.com/dart-lang/coverage/issues/196
+      expectedHits[23] = 0;
+    } else {
+      // Dart VMs version 2.0.0-dev.6.0 mark the opening brace of a function as
+      // coverable.
+      expectedHits[11] = 1;
+      expectedHits[28] = 1;
+      expectedHits[38] = 1;
+      expectedHits[42] = 3;
+    }
+    expect(isolateFile?.lineHits, expectedHits);
+    expect(isolateFile?.funcHits, {11: 1, 19: 1, 21: 0, 23: 1, 28: 1, 38: 1});
+    expect(isolateFile?.funcNames, {
+      11: 'fooSync',
+      19: 'BarClass.BarClass',
+      21: 'BarClass.x=',
+      23: 'BarClass.baz',
+      28: 'fooAsync',
+      38: 'isolateTask'
+    });
+  }, skip: platformVersionCheck(2, 17));
 
   test('parseCoverage', () async {
     final tempDir = await Directory.systemTemp.createTemp('coverage.test.');
@@ -289,9 +360,10 @@
 String? _coverageData;
 
 Future<String> _getCoverageResult() async =>
-    _coverageData ??= await _collectCoverage(false);
+    _coverageData ??= await _collectCoverage(false, false);
 
-Future<String> _collectCoverage(bool functionCoverage) async {
+Future<String> _collectCoverage(
+    bool functionCoverage, bool branchCoverage) async {
   expect(FileSystemEntity.isFileSync(testAppPath), isTrue);
 
   final openPort = await getOpenPort();
@@ -316,9 +388,10 @@
 
   // Run the collection tool.
   // TODO: need to get all of this functionality in the lib
-  final toolResult = await Process.run('dart', [
+  final toolResult = await Process.run(Platform.resolvedExecutable, [
     _collectAppPath,
     if (functionCoverage) '--function-coverage',
+    if (branchCoverage) '--branch-coverage',
     '--uri',
     '$serviceUri',
     '--resume-isolates',
diff --git a/test/lcov_test.dart b/test/lcov_test.dart
index 30035d6..b699589 100644
--- a/test/lcov_test.dart
+++ b/test/lcov_test.dart
@@ -11,6 +11,8 @@
 import 'package:path/path.dart' as p;
 import 'package:test/test.dart';
 
+import 'test_util.dart';
+
 final _sampleAppPath = p.join('test', 'test_files', 'test_app.dart');
 final _isolateLibPath = p.join('test', 'test_files', 'test_app_isolate.dart');
 
@@ -29,6 +31,35 @@
     final sampleAppHitLines = sampleAppHitMap?.lineHits;
     final sampleAppHitFuncs = sampleAppHitMap?.funcHits;
     final sampleAppFuncNames = sampleAppHitMap?.funcNames;
+    final sampleAppBranchHits = sampleAppHitMap?.branchHits;
+
+    expect(sampleAppHitLines, containsPair(46, greaterThanOrEqualTo(1)),
+        reason: 'be careful if you modify the test file');
+    expect(sampleAppHitLines, containsPair(50, 0),
+        reason: 'be careful if you modify the test file');
+    expect(sampleAppHitLines, isNot(contains(32)),
+        reason: 'be careful if you modify the test file');
+    expect(sampleAppHitFuncs, containsPair(45, 1),
+        reason: 'be careful if you modify the test file');
+    expect(sampleAppHitFuncs, containsPair(49, 0),
+        reason: 'be careful if you modify the test file');
+    expect(sampleAppFuncNames, containsPair(45, 'usedMethod'),
+        reason: 'be careful if you modify the test file');
+    expect(sampleAppBranchHits, containsPair(41, 1),
+        reason: 'be careful if you modify the test file');
+  }, skip: !platformVersionCheck(2, 17));
+
+  test('validate hitMap, old VM without branch coverage', () async {
+    final hitmap = await _getHitMap();
+
+    expect(hitmap, contains(_sampleAppFileUri));
+    expect(hitmap, contains(_isolateLibFileUri));
+    expect(hitmap, contains('package:coverage/src/util.dart'));
+
+    final sampleAppHitMap = hitmap[_sampleAppFileUri];
+    final sampleAppHitLines = sampleAppHitMap?.lineHits;
+    final sampleAppHitFuncs = sampleAppHitMap?.funcHits;
+    final sampleAppFuncNames = sampleAppHitMap?.funcNames;
 
     expect(sampleAppHitLines, containsPair(46, greaterThanOrEqualTo(1)),
         reason: 'be careful if you modify the test file');
@@ -42,7 +73,7 @@
         reason: 'be careful if you modify the test file');
     expect(sampleAppFuncNames, containsPair(45, 'usedMethod'),
         reason: 'be careful if you modify the test file');
-  });
+  }, skip: platformVersionCheck(2, 17));
 
   group('LcovFormatter', () {
     test('format()', () async {
@@ -197,6 +228,23 @@
       expect(res, contains('      0|int unusedMethod(int a, int b) {'));
       expect(res, contains('       |  return a + b;'));
     });
+
+    test('prettyPrint() branches', () async {
+      final hitmap = await _getHitMap();
+
+      final resolver = Resolver(packagesPath: '.packages');
+      final res =
+          await hitmap.prettyPrint(resolver, Loader(), reportBranches: true);
+
+      expect(res, contains(p.absolute(_sampleAppPath)));
+      expect(res, contains(p.absolute(_isolateLibPath)));
+      expect(res, contains(p.absolute(p.join('lib', 'src', 'util.dart'))));
+
+      // be very careful if you change the test file
+      expect(res, contains('      1|  if (x == answer) {'));
+      expect(res, contains('      0|  while (i < lines.length) {'));
+      expect(res, contains('       |  bar.baz();'));
+    }, skip: !platformVersionCheck(2, 17));
   });
 }
 
@@ -210,9 +258,12 @@
   final sampleAppArgs = [
     '--pause-isolates-on-exit',
     '--enable-vm-service=$port',
+    // Dart VM versions before 2.17 don't support branch coverage.
+    if (platformVersionCheck(2, 17)) '--branch-coverage',
     _sampleAppPath
   ];
-  final sampleProcess = await Process.start('dart', sampleAppArgs);
+  final sampleProcess =
+      await Process.start(Platform.resolvedExecutable, sampleAppArgs);
 
   // Capture the VM service URI.
   final serviceUriCompleter = Completer<Uri>();
@@ -231,14 +282,15 @@
 
   // collect hit map.
   final coverageJson = (await collect(serviceUri, true, true, false, <String>{},
-      functionCoverage: true))['coverage'] as List<Map<String, dynamic>>;
+      functionCoverage: true,
+      branchCoverage: true))['coverage'] as List<Map<String, dynamic>>;
   final hitMap = HitMap.parseJson(coverageJson);
 
   // wait for sample app to terminate.
   final exitCode = await sampleProcess.exitCode;
   if (exitCode != 0) {
-    throw ProcessException(
-        'dart', sampleAppArgs, 'Fatal error. Exit code: $exitCode', exitCode);
+    throw ProcessException(Platform.resolvedExecutable, sampleAppArgs,
+        'Fatal error. Exit code: $exitCode', exitCode);
   }
   await sampleProcess.stderr.drain();
   return hitMap;
diff --git a/test/run_and_collect_test.dart b/test/run_and_collect_test.dart
index 388921e..8aa7922 100644
--- a/test/run_and_collect_test.dart
+++ b/test/run_and_collect_test.dart
@@ -71,5 +71,6 @@
     expect(actualLineHits, expectedLineHits);
     expect(actualHitMap?.funcHits, isNull);
     expect(actualHitMap?.funcNames, isNull);
+    expect(actualHitMap?.branchHits, isNull);
   });
 }
diff --git a/test/test_util.dart b/test/test_util.dart
index e6f0f83..6abf0d3 100644
--- a/test/test_util.dart
+++ b/test/test_util.dart
@@ -12,9 +12,21 @@
 const Duration timeout = Duration(seconds: 20);
 
 Future<Process> runTestApp(int openPort) async {
-  return Process.start('dart', [
+  return Process.start(Platform.resolvedExecutable, [
     '--enable-vm-service=$openPort',
     '--pause_isolates_on_exit',
+    // Dart VM versions before 2.17 don't support branch coverage.
+    if (platformVersionCheck(2, 17)) '--branch-coverage',
     testAppPath
   ]);
 }
+
+final _versionPattern = RegExp('([0-9]+)\\.([0-9]+)\\.([0-9]+)');
+bool platformVersionCheck(int minMajor, int minMinor) {
+  final match = _versionPattern.matchAsPrefix(Platform.version);
+  if (match == null) return false;
+  if (match.groupCount < 3) return false;
+  final major = int.parse(match.group(1)!);
+  final minor = int.parse(match.group(2)!);
+  return major > minMajor || (major == minMajor && minor >= minMinor);
+}