[flutter_tools] Make flutter test -v print timing of different phases (#108864)

diff --git a/packages/flutter_tools/lib/executable.dart b/packages/flutter_tools/lib/executable.dart
index 3d69b03..944ec4a 100644
--- a/packages/flutter_tools/lib/executable.dart
+++ b/packages/flutter_tools/lib/executable.dart
@@ -193,7 +193,7 @@
   RunCommand(verboseHelp: verboseHelp),
   ScreenshotCommand(),
   ShellCompletionCommand(),
-  TestCommand(verboseHelp: verboseHelp),
+  TestCommand(verboseHelp: verboseHelp, verbose: verbose),
   UpgradeCommand(verboseHelp: verboseHelp),
   SymbolizeCommand(
     stdio: globals.stdio,
diff --git a/packages/flutter_tools/lib/src/commands/test.dart b/packages/flutter_tools/lib/src/commands/test.dart
index 8473989..6e01be1 100644
--- a/packages/flutter_tools/lib/src/commands/test.dart
+++ b/packages/flutter_tools/lib/src/commands/test.dart
@@ -21,6 +21,7 @@
 import '../test/coverage_collector.dart';
 import '../test/event_printer.dart';
 import '../test/runner.dart';
+import '../test/test_time_recorder.dart';
 import '../test/test_wrapper.dart';
 import '../test/watcher.dart';
 
@@ -64,6 +65,7 @@
     bool verboseHelp = false,
     this.testWrapper = const TestWrapper(),
     this.testRunner = const FlutterTestRunner(),
+    this.verbose = false,
   }) : assert(testWrapper != null) {
     requiresPubspecYaml();
     usesPubOption();
@@ -220,6 +222,8 @@
   /// Interface for running the tester process.
   final FlutterTestRunner testRunner;
 
+  final bool verbose;
+
   @visibleForTesting
   bool get isIntegrationTest => _isIntegrationTest;
   bool _isIntegrationTest = false;
@@ -302,6 +306,11 @@
     final String? excludeTags = stringArgDeprecated('exclude-tags');
     final BuildInfo buildInfo = await getBuildInfo(forcedBuildMode: BuildMode.debug);
 
+    TestTimeRecorder? testTimeRecorder;
+    if (verbose) {
+      testTimeRecorder = TestTimeRecorder(globals.logger);
+    }
+
     if (buildInfo.packageConfig['test_api'] == null) {
       throwToolExit(
         'Error: cannot run without a dependency on either "package:flutter_test" or "package:test". '
@@ -375,7 +384,8 @@
         verbose: !machine,
         libraryNames: <String>{projectName},
         packagesPath: buildInfo.packagesPath,
-        resolver: await CoverageCollector.getResolver(buildInfo.packagesPath)
+        resolver: await CoverageCollector.getResolver(buildInfo.packagesPath),
+        testTimeRecorder: testTimeRecorder,
       );
     }
 
@@ -427,6 +437,7 @@
       }
     }
 
+    final Stopwatch? testRunnerTimeRecorderStopwatch = testTimeRecorder?.start(TestTimePhases.TestRunner);
     final int result = await testRunner.runTests(
       testWrapper,
       _testFiles,
@@ -452,18 +463,25 @@
       totalShards: totalShards,
       integrationTestDevice: integrationTestDevice,
       integrationTestUserIdentifier: stringArgDeprecated(FlutterOptions.kDeviceUser),
+      testTimeRecorder: testTimeRecorder,
     );
+    testTimeRecorder?.stop(TestTimePhases.TestRunner, testRunnerTimeRecorderStopwatch!);
 
     if (collector != null) {
+      final Stopwatch? collectTimeRecorderStopwatch = testTimeRecorder?.start(TestTimePhases.CoverageDataCollect);
       final bool collectionResult = await collector.collectCoverageData(
         stringArgDeprecated('coverage-path'),
         mergeCoverageData: boolArgDeprecated('merge-coverage'),
       );
+      testTimeRecorder?.stop(TestTimePhases.CoverageDataCollect, collectTimeRecorderStopwatch!);
       if (!collectionResult) {
+        testTimeRecorder?.print();
         throwToolExit(null);
       }
     }
 
+    testTimeRecorder?.print();
+
     if (result != 0) {
       throwToolExit(null);
     }
diff --git a/packages/flutter_tools/lib/src/test/coverage_collector.dart b/packages/flutter_tools/lib/src/test/coverage_collector.dart
index 5319f4a..c0c448d 100644
--- a/packages/flutter_tools/lib/src/test/coverage_collector.dart
+++ b/packages/flutter_tools/lib/src/test/coverage_collector.dart
@@ -15,11 +15,12 @@
 import '../vmservice.dart';
 
 import 'test_device.dart';
+import 'test_time_recorder.dart';
 import 'watcher.dart';
 
 /// A class that collects code coverage data during test runs.
 class CoverageCollector extends TestWatcher {
-  CoverageCollector({this.libraryNames, this.verbose = true, required this.packagesPath, this.resolver});
+  CoverageCollector({this.libraryNames, this.verbose = true, required this.packagesPath, this.resolver, this.testTimeRecorder});
 
   /// True when log messages should be emitted.
   final bool verbose;
@@ -38,6 +39,8 @@
   final coverage.Resolver? resolver;
   final Map<String, List<List<int>>> _ignoredLinesInFilesCache = <String, List<List<int>>>{};
 
+  final TestTimeRecorder? testTimeRecorder;
+
   static Future<coverage.Resolver> getResolver(String? packagesPath) async {
     try {
       return await coverage.Resolver.create(packagesPath: packagesPath);
@@ -66,11 +69,13 @@
   }
 
   void _addHitmap(Map<String, coverage.HitMap> hitmap) {
+    final Stopwatch? stopwatch = testTimeRecorder?.start(TestTimePhases.CoverageAddHitmap);
     if (_globalHitmap == null) {
       _globalHitmap = hitmap;
     } else {
       _globalHitmap!.merge(hitmap);
     }
+    testTimeRecorder?.stop(TestTimePhases.CoverageAddHitmap, stopwatch!);
   }
 
   /// The directory of the package for which coverage is being collected.
@@ -120,8 +125,12 @@
   Future<void> collectCoverage(TestDevice testDevice, {@visibleForTesting Future<FlutterVmService> Function(Uri?)? connector}) async {
     assert(testDevice != null);
 
+    final Stopwatch? totalTestTimeRecorderStopwatch = testTimeRecorder?.start(TestTimePhases.CoverageTotal);
+
     Map<String, dynamic>? data;
 
+    final Stopwatch? collectTestTimeRecorderStopwatch = testTimeRecorder?.start(TestTimePhases.CoverageCollect);
+
     final Future<void> processComplete = testDevice.finished.catchError(
       (Object error) => throw Exception(
           'Failed to collect coverage, test device terminated prematurely with '
@@ -144,15 +153,20 @@
 
     await Future.any<void>(<Future<void>>[ processComplete, collectionComplete ]);
     assert(data != null);
+    testTimeRecorder?.stop(TestTimePhases.CoverageCollect, collectTestTimeRecorderStopwatch!);
 
     _logMessage('Merging coverage data...');
+    final Stopwatch? parseTestTimeRecorderStopwatch = testTimeRecorder?.start(TestTimePhases.CoverageParseJson);
     final Map<String, coverage.HitMap> hitmap = coverage.HitMap.parseJsonSync(
         data!['coverage'] as List<Map<String, dynamic>>,
         checkIgnoredLines: true,
         resolver: resolver ?? await CoverageCollector.getResolver(packageDirectory),
         ignoredLinesInFilesCache: _ignoredLinesInFilesCache);
+    testTimeRecorder?.stop(TestTimePhases.CoverageParseJson, parseTestTimeRecorderStopwatch!);
+
     _addHitmap(hitmap);
     _logMessage('Done merging coverage data into global coverage map.');
+    testTimeRecorder?.stop(TestTimePhases.CoverageTotal, totalTestTimeRecorderStopwatch!);
   }
 
   /// Returns formatted coverage data once all coverage data has been collected.
diff --git a/packages/flutter_tools/lib/src/test/flutter_platform.dart b/packages/flutter_tools/lib/src/test/flutter_platform.dart
index 7734ff4..f8804d6 100644
--- a/packages/flutter_tools/lib/src/test/flutter_platform.dart
+++ b/packages/flutter_tools/lib/src/test/flutter_platform.dart
@@ -29,6 +29,7 @@
 import 'test_compiler.dart';
 import 'test_config.dart';
 import 'test_device.dart';
+import 'test_time_recorder.dart';
 import 'watcher.dart';
 
 /// The address at which our WebSocket server resides and at which the sky_shell
@@ -63,6 +64,7 @@
   PlatformPluginRegistration? platformPluginRegistration,
   Device? integrationTestDevice,
   String? integrationTestUserIdentifier,
+  TestTimeRecorder? testTimeRecorder,
 }) {
   assert(testWrapper != null);
   assert(enableObservatory || (!debuggingOptions.startPaused && debuggingOptions.hostVmServicePort == null));
@@ -92,6 +94,7 @@
     icudtlPath: icudtlPath,
     integrationTestDevice: integrationTestDevice,
     integrationTestUserIdentifier: integrationTestUserIdentifier,
+    testTimeRecorder: testTimeRecorder,
   );
   platformPluginRegistration(platform);
   return platform;
@@ -286,6 +289,7 @@
     this.icudtlPath,
     this.integrationTestDevice,
     this.integrationTestUserIdentifier,
+    this.testTimeRecorder,
   }) : assert(shellPath != null);
 
   final String shellPath;
@@ -301,6 +305,7 @@
   final Uri? projectRootDirectory;
   final FlutterProject? flutterProject;
   final String? icudtlPath;
+  final TestTimeRecorder? testTimeRecorder;
 
   /// The device to run the test on for Integration Tests.
   ///
@@ -454,7 +459,7 @@
         // running this with a debugger attached. Initialize the resident
         // compiler in this case.
         if (debuggingOptions.startPaused) {
-          compiler ??= TestCompiler(debuggingOptions.buildInfo, flutterProject, precompiledDillPath: precompiledDillPath);
+          compiler ??= TestCompiler(debuggingOptions.buildInfo, flutterProject, precompiledDillPath: precompiledDillPath, testTimeRecorder: testTimeRecorder);
           final Uri testUri = globals.fs.file(testPath).uri;
           // Trigger a compilation to initialize the resident compiler.
           unawaited(compiler!.compile(testUri));
@@ -467,7 +472,7 @@
         // Integration test device takes care of the compilation.
         if (integrationTestDevice == null) {
           // Lazily instantiate compiler so it is built only if it is actually used.
-          compiler ??= TestCompiler(debuggingOptions.buildInfo, flutterProject);
+          compiler ??= TestCompiler(debuggingOptions.buildInfo, flutterProject, testTimeRecorder: testTimeRecorder);
           mainDart = await compiler!.compile(globals.fs.file(mainDart).uri);
 
           if (mainDart == null) {
@@ -479,6 +484,7 @@
 
       globals.printTrace('test $ourTestCount: starting test device');
       final TestDevice testDevice = _createTestDevice(ourTestCount);
+      final Stopwatch? testTimeRecorderStopwatch = testTimeRecorder?.start(TestTimePhases.Run);
       final Future<StreamChannel<String>> remoteChannelFuture = testDevice.start(mainDart!);
       finalizers.add(() async {
         globals.printTrace('test $ourTestCount: ensuring test device is terminated.');
@@ -512,7 +518,10 @@
           );
 
           globals.printTrace('test $ourTestCount: finished');
+          testTimeRecorder?.stop(TestTimePhases.Run, testTimeRecorderStopwatch!);
+          final Stopwatch? watchTestTimeRecorderStopwatch = testTimeRecorder?.start(TestTimePhases.WatcherFinishedTest);
           await watcher?.handleFinishedTest(testDevice);
+          testTimeRecorder?.stop(TestTimePhases.WatcherFinishedTest, watchTestTimeRecorderStopwatch!);
         }()
       ]);
     } on Exception catch (error, stackTrace) {
diff --git a/packages/flutter_tools/lib/src/test/flutter_web_platform.dart b/packages/flutter_tools/lib/src/test/flutter_web_platform.dart
index 4157538..15ef568 100644
--- a/packages/flutter_tools/lib/src/test/flutter_web_platform.dart
+++ b/packages/flutter_tools/lib/src/test/flutter_web_platform.dart
@@ -35,6 +35,7 @@
 import '../web/memory_fs.dart';
 import 'flutter_web_goldens.dart';
 import 'test_compiler.dart';
+import 'test_time_recorder.dart';
 
 class FlutterWebPlatform extends PlatformPlugin {
   FlutterWebPlatform._(this._server, this._config, this._root, {
@@ -51,6 +52,7 @@
     required Artifacts? artifacts,
     required ProcessManager processManager,
     required Cache cache,
+    TestTimeRecorder? testTimeRecorder,
   }) : _fileSystem = fileSystem,
       _flutterToolPackageConfig = flutterToolPackageConfig,
       _chromiumLauncher = chromiumLauncher,
@@ -76,7 +78,7 @@
     _server.mount(cascade.handler);
     _testGoldenComparator = TestGoldenComparator(
       shellPath,
-      () => TestCompiler(buildInfo, flutterProject),
+      () => TestCompiler(buildInfo, flutterProject, testTimeRecorder: testTimeRecorder),
       fileSystem: _fileSystem,
       logger: _logger,
       processManager: processManager,
@@ -120,6 +122,7 @@
     required Artifacts? artifacts,
     required ProcessManager processManager,
     required Cache cache,
+    TestTimeRecorder? testTimeRecorder,
   }) async {
     final shelf_io.IOServer server = shelf_io.IOServer(await HttpMultiServer.loopback(0));
     final PackageConfig packageConfig = await loadPackageConfigWithLogging(
@@ -149,6 +152,7 @@
       nullAssertions: nullAssertions,
       processManager: processManager,
       cache: cache,
+      testTimeRecorder: testTimeRecorder,
     );
   }
 
diff --git a/packages/flutter_tools/lib/src/test/runner.dart b/packages/flutter_tools/lib/src/test/runner.dart
index 0ebf9e9..0bd675f 100644
--- a/packages/flutter_tools/lib/src/test/runner.dart
+++ b/packages/flutter_tools/lib/src/test/runner.dart
@@ -13,6 +13,7 @@
 import '../web/memory_fs.dart';
 import 'flutter_platform.dart' as loader;
 import 'flutter_web_platform.dart';
+import 'test_time_recorder.dart';
 import 'test_wrapper.dart';
 import 'watcher.dart';
 import 'web_test_compiler.dart';
@@ -51,6 +52,7 @@
     int? totalShards,
     Device? integrationTestDevice,
     String? integrationTestUserIdentifier,
+    TestTimeRecorder? testTimeRecorder,
   });
 }
 
@@ -87,6 +89,7 @@
     int? totalShards,
     Device? integrationTestDevice,
     String? integrationTestUserIdentifier,
+    TestTimeRecorder? testTimeRecorder,
   }) async {
     // Configure package:test to use the Flutter engine for child processes.
     final String shellPath = globals.artifacts!.getArtifactPath(Artifact.flutterTester);
@@ -173,6 +176,7 @@
               logger: globals.logger,
             ),
             cache: globals.cache,
+            testTimeRecorder: testTimeRecorder,
           );
         },
       );
@@ -204,6 +208,7 @@
       icudtlPath: icudtlPath,
       integrationTestDevice: integrationTestDevice,
       integrationTestUserIdentifier: integrationTestUserIdentifier,
+      testTimeRecorder: testTimeRecorder,
     );
 
     try {
diff --git a/packages/flutter_tools/lib/src/test/test_compiler.dart b/packages/flutter_tools/lib/src/test/test_compiler.dart
index 7b119fd..dbeb6d3 100644
--- a/packages/flutter_tools/lib/src/test/test_compiler.dart
+++ b/packages/flutter_tools/lib/src/test/test_compiler.dart
@@ -16,6 +16,7 @@
 import '../flutter_plugins.dart';
 import '../globals.dart' as globals;
 import '../project.dart';
+import 'test_time_recorder.dart';
 
 /// A request to the [TestCompiler] for recompilation.
 class CompilationRequest {
@@ -40,10 +41,12 @@
   ///
   /// If [precompiledDillPath] is passed, it will be used to initialize the
   /// compiler.
+  ///
+  /// If [testTimeRecorder] is passed, times will be recorded in it.
   TestCompiler(
     this.buildInfo,
     this.flutterProject,
-    { String? precompiledDillPath }
+    { String? precompiledDillPath, this.testTimeRecorder }
   ) : testFilePath = precompiledDillPath ?? globals.fs.path.join(
         flutterProject!.directory.path,
         getBuildDirectory(),
@@ -73,6 +76,7 @@
   final BuildInfo buildInfo;
   final String testFilePath;
   final bool shouldCopyDillFile;
+  final TestTimeRecorder? testTimeRecorder;
 
 
   ResidentCompiler? compiler;
@@ -139,6 +143,7 @@
       final CompilationRequest request = compilationQueue.first;
       globals.printTrace('Compiling ${request.mainUri}');
       final Stopwatch compilerTime = Stopwatch()..start();
+      final Stopwatch? testTimeRecorderStopwatch = testTimeRecorder?.start(TestTimePhases.Compile);
       bool firstCompile = false;
       if (compiler == null) {
         compiler = await createCompiler();
@@ -200,6 +205,7 @@
         compiler!.reset();
       }
       globals.printTrace('Compiling ${request.mainUri} took ${compilerTime.elapsedMilliseconds}ms');
+      testTimeRecorder?.stop(TestTimePhases.Compile, testTimeRecorderStopwatch!);
       // Only remove now when we finished processing the element
       compilationQueue.removeAt(0);
     }
diff --git a/packages/flutter_tools/lib/src/test/test_time_recorder.dart b/packages/flutter_tools/lib/src/test/test_time_recorder.dart
new file mode 100644
index 0000000..08ad333
--- /dev/null
+++ b/packages/flutter_tools/lib/src/test/test_time_recorder.dart
@@ -0,0 +1,129 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:meta/meta.dart';
+
+import '../base/logger.dart';
+
+/// Utility class that can record time used in different phases of a test run.
+class TestTimeRecorder {
+  TestTimeRecorder(this.logger,
+      {this.stopwatchFactory = const StopwatchFactory()})
+      : _phaseRecords = List<TestTimeRecord>.generate(
+          TestTimePhases.values.length,
+          (_) => TestTimeRecord(stopwatchFactory),
+        );
+
+  final List<TestTimeRecord> _phaseRecords;
+  final Logger logger;
+  final StopwatchFactory stopwatchFactory;
+
+  Stopwatch start(TestTimePhases phase) {
+    return _phaseRecords[phase.index].start();
+  }
+
+  void stop(TestTimePhases phase, Stopwatch stopwatch) {
+    _phaseRecords[phase.index].stop(stopwatch);
+  }
+
+  void print() {
+    for (final TestTimePhases phase in TestTimePhases.values) {
+      logger.printTrace(_getPrintStringForPhase(phase));
+    }
+  }
+
+  @visibleForTesting
+  List<String> getPrintAsListForTesting() {
+    final List<String> result = <String>[];
+    for (final TestTimePhases phase in TestTimePhases.values) {
+      result.add(_getPrintStringForPhase(phase));
+    }
+    return result;
+  }
+
+  @visibleForTesting
+  Stopwatch getPhaseWallClockStopwatchForTesting(final TestTimePhases phase) {
+    return _phaseRecords[phase.index]._wallClockRuntime;
+  }
+
+  String _getPrintStringForPhase(final TestTimePhases phase) {
+    assert(_phaseRecords[phase.index].isDone());
+    return 'Runtime for phase ${phase.name}: ${_phaseRecords[phase.index]}';
+  }
+}
+
+/// Utility class that can record time used in a specific phase of a test run.
+class TestTimeRecord {
+  TestTimeRecord(this.stopwatchFactory)
+      : _wallClockRuntime = stopwatchFactory.createStopwatch();
+
+  final StopwatchFactory stopwatchFactory;
+  Duration _combinedRuntime = Duration.zero;
+  final Stopwatch _wallClockRuntime;
+  int _currentlyRunningCount = 0;
+
+  Stopwatch start() {
+    final Stopwatch stopwatch = stopwatchFactory.createStopwatch()..start();
+    if (_currentlyRunningCount == 0) {
+      _wallClockRuntime.start();
+    }
+    _currentlyRunningCount++;
+    return stopwatch;
+  }
+
+  void stop(Stopwatch stopwatch) {
+    _currentlyRunningCount--;
+    if (_currentlyRunningCount == 0) {
+      _wallClockRuntime.stop();
+    }
+    _combinedRuntime = _combinedRuntime + stopwatch.elapsed;
+    assert(_currentlyRunningCount >= 0);
+  }
+
+  @override
+  String toString() {
+    return 'Wall-clock: ${_wallClockRuntime.elapsed}; combined: $_combinedRuntime.';
+  }
+
+  bool isDone() {
+    return _currentlyRunningCount == 0;
+  }
+}
+
+enum TestTimePhases {
+  /// Covers entire TestRunner run, i.e. from the test runner was asked to `runTests` until it is done.
+  ///
+  /// This implicitly includes all the other phases.
+  TestRunner,
+
+  /// Covers time spent compiling, including subsequent copying of files.
+  Compile,
+
+  /// Covers time spent running, i.e. from starting the test device until the test has finished.
+  Run,
+
+  /// Covers all time spent collecting coverage.
+  CoverageTotal,
+
+  /// Covers collecting the coverage, but not parsing nor adding to hit map.
+  ///
+  /// This is included in [CoverageTotal]
+  CoverageCollect,
+
+  /// Covers parsing the json from the coverage collected.
+  ///
+  /// This is included in [CoverageTotal]
+  CoverageParseJson,
+
+  /// Covers adding the parsed coverage to the hitmap.
+  ///
+  /// This is included in [CoverageTotal]
+  CoverageAddHitmap,
+
+  /// Covers time spent in `collectCoverageData`, mostly formatting and writing coverage data to file.
+  CoverageDataCollect,
+
+  /// Covers time spend in the Watchers `handleFinishedTest` call. This is probably collecting coverage.
+  WatcherFinishedTest,
+}
diff --git a/packages/flutter_tools/test/commands.shard/hermetic/test_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/test_test.dart
index 816a83c..ecf8193 100644
--- a/packages/flutter_tools/test/commands.shard/hermetic/test_test.dart
+++ b/packages/flutter_tools/test/commands.shard/hermetic/test_test.dart
@@ -11,6 +11,7 @@
 import 'package:file/memory.dart';
 import 'package:flutter_tools/src/base/common.dart';
 import 'package:flutter_tools/src/base/file_system.dart';
+import 'package:flutter_tools/src/base/logger.dart';
 import 'package:flutter_tools/src/build_info.dart';
 import 'package:flutter_tools/src/cache.dart';
 import 'package:flutter_tools/src/commands/test.dart';
@@ -18,6 +19,7 @@
 import 'package:flutter_tools/src/project.dart';
 import 'package:flutter_tools/src/runner/flutter_command.dart';
 import 'package:flutter_tools/src/test/runner.dart';
+import 'package:flutter_tools/src/test/test_time_recorder.dart';
 import 'package:flutter_tools/src/test/test_wrapper.dart';
 import 'package:flutter_tools/src/test/watcher.dart';
 import 'package:meta/meta.dart';
@@ -25,6 +27,7 @@
 import '../../src/common.dart';
 import '../../src/context.dart';
 import '../../src/fake_devices.dart';
+import '../../src/logging_logger.dart';
 import '../../src/test_flutter_command_runner.dart';
 
 const String _pubspecContents = '''
@@ -57,6 +60,7 @@
 void main() {
   Cache.disableLocking();
   MemoryFileSystem fs;
+  LoggingLogger logger;
 
   setUp(() {
     fs = MemoryFileSystem.test();
@@ -70,6 +74,8 @@
     fs.directory('/package/integration_test').childFile('some_integration_test.dart').createSync(recursive: true);
 
     fs.currentDirectory = '/package';
+
+    logger = LoggingLogger();
   });
 
   testUsingContext('Missing dependencies in pubspec',
@@ -90,6 +96,7 @@
   }, overrides: <Type, Generator>{
     FileSystem: () => fs,
     ProcessManager: () => FakeProcessManager.any(),
+    Logger: () => logger,
   });
 
   testUsingContext('Missing dependencies in pubspec for integration tests',
@@ -317,6 +324,58 @@
     Cache: () => Cache.test(processManager: FakeProcessManager.any()),
   });
 
+  testUsingContext('Verbose prints phase timings', () async {
+    final FakeFlutterTestRunner testRunner = FakeFlutterTestRunner(0, const Duration(milliseconds: 1));
+
+    final TestCommand testCommand = TestCommand(testRunner: testRunner, verbose: true);
+    final CommandRunner<void> commandRunner =
+        createTestCommandRunner(testCommand);
+
+    await commandRunner.run(const <String>[
+      'test',
+      '--no-pub',
+      '--',
+      'test/fake_test.dart',
+    ]);
+
+    // Expect one message for each phase.
+    final List<String> logPhaseMessages = logger.messages.where((String m) => m.startsWith('Runtime for phase ')).toList();
+    expect(logPhaseMessages, hasLength(TestTimePhases.values.length));
+
+    // As we force the `runTests` command to take at least 1 ms expect at least
+    // one phase to take a non-zero amount of time.
+    final List<String> logPhaseMessagesNonZero = logPhaseMessages.where((String m) => !m.contains(Duration.zero.toString())).toList();
+    expect(logPhaseMessagesNonZero, isNotEmpty);
+  }, overrides: <Type, Generator>{
+    FileSystem: () => fs,
+    ProcessManager: () => FakeProcessManager.any(),
+    Cache: () => Cache.test(processManager: FakeProcessManager.any()),
+    Logger: () => logger,
+  });
+
+  testUsingContext('Non-verbose does not prints phase timings', () async {
+    final FakeFlutterTestRunner testRunner = FakeFlutterTestRunner(0, const Duration(milliseconds: 1));
+
+    final TestCommand testCommand = TestCommand(testRunner: testRunner);
+    final CommandRunner<void> commandRunner =
+        createTestCommandRunner(testCommand);
+
+    await commandRunner.run(const <String>[
+      'test',
+      '--no-pub',
+      '--',
+      'test/fake_test.dart',
+    ]);
+
+    final List<String> logPhaseMessages = logger.messages.where((String m) => m.startsWith('Runtime for phase ')).toList();
+    expect(logPhaseMessages, isEmpty);
+  }, overrides: <Type, Generator>{
+    FileSystem: () => fs,
+    ProcessManager: () => FakeProcessManager.any(),
+    Cache: () => Cache.test(processManager: FakeProcessManager.any()),
+    Logger: () => logger,
+  });
+
   testUsingContext('Pipes different args when running Integration Tests', () async {
     final FakePackageTest fakePackageTest = FakePackageTest();
 
@@ -728,9 +787,10 @@
 }
 
 class FakeFlutterTestRunner implements FlutterTestRunner {
-  FakeFlutterTestRunner(this.exitCode);
+  FakeFlutterTestRunner(this.exitCode, [this.leastRunTime]);
 
   int exitCode;
+  Duration leastRunTime;
   bool lastEnableObservatoryValue;
   DebuggingOptions lastDebuggingOptionsValue;
 
@@ -768,9 +828,15 @@
     int totalShards,
     Device integrationTestDevice,
     String integrationTestUserIdentifier,
+    TestTimeRecorder testTimeRecorder,
   }) async {
     lastEnableObservatoryValue = enableObservatory;
     lastDebuggingOptionsValue = debuggingOptions;
+
+    if (leastRunTime != null) {
+      await Future<void>.delayed(leastRunTime);
+    }
+
     return exitCode;
   }
 }
diff --git a/packages/flutter_tools/test/general.shard/coverage_collector_test.dart b/packages/flutter_tools/test/general.shard/coverage_collector_test.dart
index e63db2b..0f473c3 100644
--- a/packages/flutter_tools/test/general.shard/coverage_collector_test.dart
+++ b/packages/flutter_tools/test/general.shard/coverage_collector_test.dart
@@ -8,11 +8,13 @@
 import 'package:coverage/src/hitmap.dart';
 import 'package:flutter_tools/src/test/coverage_collector.dart';
 import 'package:flutter_tools/src/test/test_device.dart' show TestDevice;
+import 'package:flutter_tools/src/test/test_time_recorder.dart';
 import 'package:stream_channel/stream_channel.dart' show StreamChannel;
 import 'package:vm_service/vm_service.dart';
 
 import '../src/common.dart';
 import '../src/fake_vm_services.dart';
+import '../src/logging_logger.dart';
 
 void main() {
   testWithoutContext('Coverage collector Can handle coverage SentinelException', () async {
@@ -341,26 +343,13 @@
     Directory? tempDir;
     try {
       tempDir = Directory.systemTemp.createTempSync('flutter_coverage_collector_test.');
-      final File file = File('${tempDir.path}/packages.json');
-      file.writeAsStringSync(jsonEncode(<String, dynamic>{
-        'configVersion': 2,
-        'packages': <Map<String, String>>[
-          <String, String>{
-            'name': 'foo',
-            'rootUri': 'foo',
-          },
-          <String, String>{
-            'name': 'bar',
-            'rootUri': 'bar',
-          },
-        ],
-      }));
+      final File packagesFile = writeFooBarPackagesJson(tempDir);
       final Directory fooDir = Directory('${tempDir.path}/foo/');
       fooDir.createSync();
       final File fooFile = File('${fooDir.path}/foo.dart');
       fooFile.writeAsStringSync('hit\nnohit but ignored // coverage:ignore-line\nhit\n');
 
-      final String packagesPath = file.path;
+      final String packagesPath = packagesFile.path;
       final CoverageCollector collector = CoverageCollector(
           libraryNames: <String>{'foo', 'bar'},
           verbose: false,
@@ -408,6 +397,61 @@
       tempDir?.deleteSync(recursive: true);
     }
   });
+
+  testWithoutContext('Coverage collector records test timings when provided TestTimeRecorder', () async {
+    Directory? tempDir;
+    try {
+      tempDir = Directory.systemTemp.createTempSync('flutter_coverage_collector_test.');
+      final File packagesFile = writeFooBarPackagesJson(tempDir);
+      final Directory fooDir = Directory('${tempDir.path}/foo/');
+      fooDir.createSync();
+      final File fooFile = File('${fooDir.path}/foo.dart');
+      fooFile.writeAsStringSync('hit\nnohit but ignored // coverage:ignore-line\nhit\n');
+
+      final String packagesPath = packagesFile.path;
+      final LoggingLogger logger = LoggingLogger();
+      final TestTimeRecorder testTimeRecorder = TestTimeRecorder(logger);
+      final CoverageCollector collector = CoverageCollector(
+          libraryNames: <String>{'foo', 'bar'},
+          verbose: false,
+          packagesPath: packagesPath,
+          resolver: await CoverageCollector.getResolver(packagesPath),
+          testTimeRecorder: testTimeRecorder
+        );
+      await collector.collectCoverage(TestTestDevice(), connector: (Uri? uri) async {
+        return createFakeVmServiceHostWithFooAndBar().vmService;
+      });
+
+      // Expect one message for each phase.
+      final List<String> logPhaseMessages = testTimeRecorder.getPrintAsListForTesting().where((String m) => m.startsWith('Runtime for phase ')).toList();
+      expect(logPhaseMessages, hasLength(TestTimePhases.values.length));
+
+      // Several phases actually does something, but here we just expect at
+      // least one phase to take a non-zero amount of time.
+      final List<String> logPhaseMessagesNonZero = logPhaseMessages.where((String m) => !m.contains(Duration.zero.toString())).toList();
+      expect(logPhaseMessagesNonZero, isNotEmpty);
+    } finally {
+      tempDir?.deleteSync(recursive: true);
+    }
+  });
+}
+
+File writeFooBarPackagesJson(Directory tempDir) {
+  final File file = File('${tempDir.path}/packages.json');
+  file.writeAsStringSync(jsonEncode(<String, dynamic>{
+    'configVersion': 2,
+    'packages': <Map<String, String>>[
+      <String, String>{
+        'name': 'foo',
+        'rootUri': 'foo',
+      },
+      <String, String>{
+        'name': 'bar',
+        'rootUri': 'bar',
+      },
+    ],
+  }));
+  return file;
 }
 
 FakeVmServiceHost createFakeVmServiceHostWithFooAndBar() {
diff --git a/packages/flutter_tools/test/general.shard/devfs_test.dart b/packages/flutter_tools/test/general.shard/devfs_test.dart
index 3d3caf9..63fd077 100644
--- a/packages/flutter_tools/test/general.shard/devfs_test.dart
+++ b/packages/flutter_tools/test/general.shard/devfs_test.dart
@@ -16,7 +16,6 @@
 import 'package:flutter_tools/src/base/logger.dart';
 import 'package:flutter_tools/src/base/os.dart';
 import 'package:flutter_tools/src/base/platform.dart';
-import 'package:flutter_tools/src/base/terminal.dart';
 import 'package:flutter_tools/src/build_info.dart';
 import 'package:flutter_tools/src/build_system/targets/shader_compiler.dart';
 import 'package:flutter_tools/src/compile.dart';
@@ -30,6 +29,7 @@
 import '../src/fake_http_client.dart';
 import '../src/fake_vm_services.dart';
 import '../src/fakes.dart';
+import '../src/logging_logger.dart';
 
 final FakeVmServiceRequest createDevFSRequest = FakeVmServiceRequest(
   method: '_createDevFS',
@@ -716,27 +716,6 @@
   }
 }
 
-class LoggingLogger extends BufferLogger {
-  LoggingLogger() : super.test();
-
-  List<String> messages = <String>[];
-
-  @override
-  void printError(String message, {StackTrace? stackTrace, bool? emphasis, TerminalColor? color, int? indent, int? hangingIndent, bool? wrap}) {
-    messages.add(message);
-  }
-
-  @override
-  void printStatus(String message, {bool? emphasis, TerminalColor? color, bool? newline, int? indent, int? hangingIndent, bool? wrap}) {
-    messages.add(message);
-  }
-
-  @override
-  void printTrace(String message) {
-    messages.add(message);
-  }
-}
-
 class FakeBundle extends AssetBundle {
   @override
   List<File> get additionalDependencies => <File>[];
diff --git a/packages/flutter_tools/test/general.shard/test/test_compiler_test.dart b/packages/flutter_tools/test/general.shard/test/test_compiler_test.dart
index 5ff3c75..bd2bb06 100644
--- a/packages/flutter_tools/test/general.shard/test/test_compiler_test.dart
+++ b/packages/flutter_tools/test/general.shard/test/test_compiler_test.dart
@@ -11,11 +11,13 @@
 import 'package:flutter_tools/src/compile.dart';
 import 'package:flutter_tools/src/project.dart';
 import 'package:flutter_tools/src/test/test_compiler.dart';
+import 'package:flutter_tools/src/test/test_time_recorder.dart';
 import 'package:package_config/package_config_types.dart';
 import 'package:test/fake.dart';
 
 import '../../src/common.dart';
 import '../../src/context.dart';
+import '../../src/logging_logger.dart';
 
 final Platform linuxPlatform = FakePlatform(
   environment: <String, String>{},
@@ -33,6 +35,7 @@
 void main() {
   late FakeResidentCompiler residentCompiler;
   late FileSystem fileSystem;
+  late LoggingLogger logger;
 
   setUp(() {
     fileSystem = MemoryFileSystem.test();
@@ -40,6 +43,7 @@
     fileSystem.file('test/foo.dart').createSync(recursive: true);
     fileSystem.file('.packages').createSync();
     residentCompiler = FakeResidentCompiler(fileSystem);
+    logger = LoggingLogger();
   });
 
   testUsingContext('TestCompiler reports a dill file when compile is successful', () async {
@@ -92,6 +96,34 @@
     Logger: () => BufferLogger.test(),
   });
 
+
+  testUsingContext('TestCompiler records test timings when provided TestTimeRecorder', () async {
+    residentCompiler.compilerOutput = const CompilerOutput('abc.dill', 0, <Uri>[]);
+    final TestTimeRecorder testTimeRecorder = TestTimeRecorder(logger);
+    final FakeTestCompiler testCompiler = FakeTestCompiler(
+      debugBuild,
+      FlutterProject.fromDirectoryTest(fileSystem.currentDirectory),
+      residentCompiler,
+      testTimeRecorder: testTimeRecorder,
+    );
+    expect(await testCompiler.compile(Uri.parse('test/foo.dart')), 'test/foo.dart.dill');
+    testTimeRecorder.print();
+
+    // Expect one message for each phase.
+    final List<String> logPhaseMessages = logger.messages.where((String m) => m.startsWith('Runtime for phase ')).toList();
+    expect(logPhaseMessages, hasLength(TestTimePhases.values.length));
+
+    // As the compile method adds a job to a queue etc we expect at
+    // least one phase to take a non-zero amount of time.
+    final List<String> logPhaseMessagesNonZero = logPhaseMessages.where((String m) => !m.contains(Duration.zero.toString())).toList();
+    expect(logPhaseMessagesNonZero, isNotEmpty);
+  }, overrides: <Type, Generator>{
+    FileSystem: () => fileSystem,
+    Platform: () => linuxPlatform,
+    ProcessManager: () => FakeProcessManager.any(),
+    Logger: () => logger,
+  });
+
   testUsingContext('TestCompiler disposing test compiler shuts down backing compiler', () async {
     final FakeTestCompiler testCompiler = FakeTestCompiler(
       debugBuild,
@@ -171,6 +203,7 @@
     super.flutterProject,
     this.residentCompiler, {
       super.precompiledDillPath,
+      super.testTimeRecorder,
     }
   );
 
diff --git a/packages/flutter_tools/test/general.shard/test/test_time_recorder_test.dart b/packages/flutter_tools/test/general.shard/test/test_time_recorder_test.dart
new file mode 100644
index 0000000..d158286
--- /dev/null
+++ b/packages/flutter_tools/test/general.shard/test/test_time_recorder_test.dart
@@ -0,0 +1,60 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter_tools/src/test/test_time_recorder.dart';
+
+import '../../src/common.dart';
+import '../../src/fakes.dart';
+import '../../src/logging_logger.dart';
+
+void main() {
+  testWithoutContext('Test phases prints correctly', () {
+    const Duration zero = Duration.zero;
+    const Duration combinedDuration = Duration(seconds: 42);
+    const Duration wallClockDuration = Duration(seconds: 21);
+
+    for (final TestTimePhases phase in TestTimePhases.values) {
+      final TestTimeRecorder recorder = createRecorderWithTimesForPhase(
+          phase, combinedDuration, wallClockDuration);
+      final Set<String> prints = recorder.getPrintAsListForTesting().toSet();
+
+      // Expect one entry per phase.
+      expect(prints, hasLength(TestTimePhases.values.length));
+
+      // Expect this phase to have the specified times.
+      expect(
+        prints,
+        contains('Runtime for phase ${phase.name}: '
+            'Wall-clock: $wallClockDuration; combined: $combinedDuration.'),
+      );
+
+      // Expect all other phases to say 0.
+      for (final TestTimePhases innerPhase in TestTimePhases.values) {
+        if (phase == innerPhase) {
+          continue;
+        }
+        expect(
+          prints,
+          contains('Runtime for phase ${innerPhase.name}: '
+              'Wall-clock: $zero; combined: $zero.'),
+        );
+      }
+    }
+  });
+}
+
+TestTimeRecorder createRecorderWithTimesForPhase(TestTimePhases phase,
+    Duration combinedDuration, Duration wallClockDuration) {
+  final LoggingLogger logger = LoggingLogger();
+  final TestTimeRecorder recorder =
+      TestTimeRecorder(logger, stopwatchFactory: FakeStopwatchFactory());
+  final FakeStopwatch combinedStopwatch =
+      recorder.start(phase) as FakeStopwatch;
+  final FakeStopwatch wallClockStopwatch =
+      recorder.getPhaseWallClockStopwatchForTesting(phase) as FakeStopwatch;
+  wallClockStopwatch.elapsed = wallClockDuration;
+  combinedStopwatch.elapsed = combinedDuration;
+  recorder.stop(phase, combinedStopwatch);
+  return recorder;
+}
diff --git a/packages/flutter_tools/test/src/logging_logger.dart b/packages/flutter_tools/test/src/logging_logger.dart
new file mode 100644
index 0000000..96a77a3
--- /dev/null
+++ b/packages/flutter_tools/test/src/logging_logger.dart
@@ -0,0 +1,27 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter_tools/src/base/logger.dart';
+import 'package:flutter_tools/src/base/terminal.dart';
+
+class LoggingLogger extends BufferLogger {
+  LoggingLogger() : super.test();
+
+  List<String> messages = <String>[];
+
+  @override
+  void printError(String message, {StackTrace? stackTrace, bool? emphasis, TerminalColor? color, int? indent, int? hangingIndent, bool? wrap}) {
+    messages.add(message);
+  }
+
+  @override
+  void printStatus(String message, {bool? emphasis, TerminalColor? color, bool? newline, int? indent, int? hangingIndent, bool? wrap}) {
+    messages.add(message);
+  }
+
+  @override
+  void printTrace(String message) {
+    messages.add(message);
+  }
+}