Integrate testWidgets with leak tracking. (#138057)
Contributes to: https://github.com/flutter/flutter/issues/135856
TODO:
diff --git a/packages/flutter_test/lib/src/test_compat.dart b/packages/flutter_test/lib/src/test_compat.dart
index 201f616..9e64974 100644
--- a/packages/flutter_test/lib/src/test_compat.dart
+++ b/packages/flutter_test/lib/src/test_compat.dart
@@ -4,6 +4,7 @@
import 'dart:async';
+import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
import 'package:meta/meta.dart';
import 'package:test_api/scaffolding.dart' show Timeout;
import 'package:test_api/src/backend/declarer.dart'; // ignore: implementation_imports
@@ -163,6 +164,7 @@
Map<String, dynamic>? onPlatform,
int? retry,
}) {
+ _configureTearDownForTestFile();
_declarer.test(
description.toString(),
body,
@@ -186,6 +188,7 @@
/// of running the group's tests.
@isTestGroup
void group(Object description, void Function() body, { dynamic skip, int? retry }) {
+ _configureTearDownForTestFile();
_declarer.group(description.toString(), body, skip: skip, retry: retry);
}
@@ -201,6 +204,7 @@
/// Each callback at the top level or in a given group will be run in the order
/// they were declared.
void setUp(dynamic Function() body) {
+ _configureTearDownForTestFile();
_declarer.setUp(body);
}
@@ -218,6 +222,7 @@
///
/// See also [addTearDown], which adds tear-downs to a running test.
void tearDown(dynamic Function() body) {
+ _configureTearDownForTestFile();
_declarer.tearDown(body);
}
@@ -235,6 +240,7 @@
/// prefer [setUp], and only use [setUpAll] if the callback is prohibitively
/// slow.
void setUpAll(dynamic Function() body) {
+ _configureTearDownForTestFile();
_declarer.setUpAll(body);
}
@@ -250,9 +256,27 @@
/// prefer [tearDown], and only use [tearDownAll] if the callback is
/// prohibitively slow.
void tearDownAll(dynamic Function() body) {
+ _configureTearDownForTestFile();
_declarer.tearDownAll(body);
}
+bool _isTearDownForTestFileConfigured = false;
+/// Configures `tearDownAll` after all user defined `tearDownAll` in the test file.
+///
+/// This function should be invoked in all functions, that may be invoked by user in the test file,
+/// to be invoked before any other `tearDownAll`.
+void _configureTearDownForTestFile() {
+ if (_isTearDownForTestFileConfigured) {
+ return;
+ }
+ _declarer.tearDownAll(_tearDownForTestFile);
+ _isTearDownForTestFileConfigured = true;
+}
+
+/// Tear down that should happen after all user defined tear down.
+Future<void> _tearDownForTestFile() async {
+ await maybeTearDownLeakTrackingForAll();
+}
/// A reporter that prints each test on its own line.
///
diff --git a/packages/flutter_test/lib/src/widget_tester.dart b/packages/flutter_test/lib/src/widget_tester.dart
index 2a8c36d..b035088 100644
--- a/packages/flutter_test/lib/src/widget_tester.dart
+++ b/packages/flutter_test/lib/src/widget_tester.dart
@@ -9,6 +9,7 @@
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
+import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
import 'package:matcher/expect.dart' as matcher_expect;
import 'package:meta/meta.dart';
import 'package:test_api/scaffolding.dart' as test_package;
@@ -116,6 +117,18 @@
/// If the [tags] are passed, they declare user-defined tags that are implemented by
/// the `test` package.
///
+/// The argument [experimentalLeakTesting] is experimental and is not recommended
+/// for use outside of the Flutter framework.
+/// When [experimentalLeakTesting] is set, it is used to leak track objects created
+/// during test execution.
+/// Otherwise [LeakTesting.settings] is used.
+/// Adjust [LeakTesting.settings] in flutter_test_config.dart
+/// (see https://github.com/flutter/flutter/blob/master/packages/flutter_test/lib/flutter_test.dart)
+/// for the entire package or folder, or in the test's main for a test file
+/// (don't use [setUp] or [setUpAll]).
+/// To turn off leak tracking just for one test, set [experimentalLeakTesting] to
+/// `LeakTrackingForTests.ignore()`.
+///
/// ## Sample code
///
/// ```dart
@@ -135,6 +148,7 @@
TestVariant<Object?> variant = const DefaultTestVariant(),
dynamic tags,
int? retry,
+ LeakTesting? experimentalLeakTesting,
}) {
assert(variant.values.isNotEmpty, 'There must be at least one value to test in the testing variant.');
final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized();
@@ -165,9 +179,11 @@
Object? memento;
try {
memento = await variant.setUp(value);
+ maybeSetupLeakTrackingForTest(experimentalLeakTesting, combinedDescription);
await callback(tester);
} finally {
await variant.tearDown(value, memento);
+ maybeTearDownLeakTrackingForTest();
}
semanticsHandle?.dispose();
},
diff --git a/packages/flutter_test/test/utils/leaking_classes.dart b/packages/flutter_test/test/utils/leaking_classes.dart
new file mode 100644
index 0000000..b49463f
--- /dev/null
+++ b/packages/flutter_test/test/utils/leaking_classes.dart
@@ -0,0 +1,54 @@
+// 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/widgets.dart';
+import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
+
+class LeakTrackedClass {
+ LeakTrackedClass() {
+ LeakTracking.dispatchObjectCreated(
+ library: library,
+ className: '$LeakTrackedClass',
+ object: this,
+ );
+ }
+
+ static const String library = 'package:my_package/lib/src/my_lib.dart';
+
+ void dispose() {
+ LeakTracking.dispatchObjectDisposed(object: this);
+ }
+}
+
+final List<LeakTrackedClass> _notGCedObjects = <LeakTrackedClass>[];
+
+class LeakingClass {
+ LeakingClass() {
+ _notGCedObjects.add(LeakTrackedClass()..dispose());
+ }
+}
+
+class StatelessLeakingWidget extends StatelessWidget {
+ StatelessLeakingWidget({
+ super.key,
+ this.notGCed = true,
+ this.notDisposed = true,
+ }) {
+ if (notGCed) {
+ _notGCedObjects.add(LeakTrackedClass()..dispose());
+ }
+ if (notDisposed) {
+ // ignore: unused_local_variable, it is unused intentionally, to illustrate not disposed object.
+ final LeakTrackedClass notDisposedObject = LeakTrackedClass();
+ }
+ }
+
+ final bool notGCed;
+ final bool notDisposed;
+
+ @override
+ Widget build(BuildContext context) {
+ return const Placeholder();
+ }
+}
diff --git a/packages/flutter_test/test/widget_tester_leaks_test.dart b/packages/flutter_test/test/widget_tester_leaks_test.dart
new file mode 100644
index 0000000..9c614db
--- /dev/null
+++ b/packages/flutter_test/test/widget_tester_leaks_test.dart
@@ -0,0 +1,204 @@
+// 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/cupertino.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
+
+import 'utils/leaking_classes.dart';
+
+late final String _test1TrackingOnNoLeaks;
+late final String _test2TrackingOffLeaks;
+late final String _test3TrackingOnLeaks;
+late final String _test4TrackingOnWithCreationStackTrace;
+late final String _test5TrackingOnWithDisposalStackTrace;
+late final String _test6TrackingOnNoLeaks;
+late final String _test7TrackingOnNoLeaks;
+late final String _test8TrackingOnNotDisposed;
+
+void main() {
+ LeakTesting.collectedLeaksReporter = (Leaks leaks) => verifyLeaks(leaks);
+ LeakTesting.settings = LeakTesting.settings.copyWith(ignore: false);
+
+ // It is important that the test file starts with group, to test that leaks are collected for all tests after group too.
+ group('Group', () {
+ testWidgets('test', (_) async {
+ StatelessLeakingWidget();
+ });
+ });
+
+ testWidgets(_test1TrackingOnNoLeaks = 'test1, tracking-on, no leaks', (WidgetTester widgetTester) async {
+ expect(LeakTracking.isStarted, true);
+ expect(LeakTracking.phase.name, _test1TrackingOnNoLeaks);
+ expect(LeakTracking.phase.ignoreLeaks, false);
+ await widgetTester.pumpWidget(Container());
+ });
+
+ testWidgets(
+ _test2TrackingOffLeaks = 'test2, tracking-off, leaks',
+ experimentalLeakTesting: LeakTesting.settings.withIgnoredAll(),
+ (WidgetTester widgetTester) async {
+ expect(LeakTracking.isStarted, true);
+ expect(LeakTracking.phase.name, null);
+ expect(LeakTracking.phase.ignoreLeaks, true);
+ await widgetTester.pumpWidget(StatelessLeakingWidget());
+ });
+
+ testWidgets(_test3TrackingOnLeaks = 'test3, tracking-on, leaks', (WidgetTester widgetTester) async {
+ expect(LeakTracking.isStarted, true);
+ expect(LeakTracking.phase.name, _test3TrackingOnLeaks);
+ expect(LeakTracking.phase.ignoreLeaks, false);
+ await widgetTester.pumpWidget(StatelessLeakingWidget());
+ });
+
+ testWidgets(
+ _test4TrackingOnWithCreationStackTrace = 'test4, tracking-on, with creation stack trace',
+ experimentalLeakTesting: LeakTesting.settings.withCreationStackTrace(),
+ (WidgetTester widgetTester) async {
+ expect(LeakTracking.isStarted, true);
+ expect(LeakTracking.phase.name, _test4TrackingOnWithCreationStackTrace);
+ expect(LeakTracking.phase.ignoreLeaks, false);
+ await widgetTester.pumpWidget(StatelessLeakingWidget());
+ },
+ );
+
+ testWidgets(
+ _test5TrackingOnWithDisposalStackTrace = 'test5, tracking-on, with disposal stack trace',
+ experimentalLeakTesting: LeakTesting.settings.withDisposalStackTrace(),
+ (WidgetTester widgetTester) async {
+ expect(LeakTracking.isStarted, true);
+ expect(LeakTracking.phase.name, _test5TrackingOnWithDisposalStackTrace);
+ expect(LeakTracking.phase.ignoreLeaks, false);
+ await widgetTester.pumpWidget(StatelessLeakingWidget());
+ },
+ );
+
+ testWidgets(_test6TrackingOnNoLeaks = 'test6, tracking-on, no leaks', (_) async {
+ LeakTrackedClass().dispose();
+ });
+
+ testWidgets(_test7TrackingOnNoLeaks = 'test7, tracking-on, tear down, no leaks', (_) async {
+ final LeakTrackedClass myClass = LeakTrackedClass();
+ addTearDown(myClass.dispose);
+ });
+
+ testWidgets(_test8TrackingOnNotDisposed = 'test8, tracking-on, not disposed leak', (_) async {
+ expect(LeakTracking.isStarted, true);
+ expect(LeakTracking.phase.name, _test8TrackingOnNotDisposed);
+ expect(LeakTracking.phase.ignoreLeaks, false);
+ LeakTrackedClass();
+ });
+}
+
+int _leakReporterInvocationCount = 0;
+
+void verifyLeaks(Leaks leaks) {
+ _leakReporterInvocationCount += 1;
+ expect(_leakReporterInvocationCount, 1);
+
+ try {
+ expect(leaks, isLeakFree);
+ } on TestFailure catch (e) {
+ expect(e.message, contains('https://github.com/dart-lang/leak_tracker'));
+
+ expect(e.message, isNot(contains(_test1TrackingOnNoLeaks)));
+ expect(e.message, isNot(contains(_test2TrackingOffLeaks)));
+ expect(e.message, contains('test: $_test3TrackingOnLeaks'));
+ expect(e.message, contains('test: $_test4TrackingOnWithCreationStackTrace'));
+ expect(e.message, contains('test: $_test5TrackingOnWithDisposalStackTrace'));
+ expect(e.message, isNot(contains(_test6TrackingOnNoLeaks)));
+ expect(e.message, isNot(contains(_test7TrackingOnNoLeaks)));
+ expect(e.message, contains('test: $_test8TrackingOnNotDisposed'));
+ }
+
+ _verifyLeaks(
+ leaks,
+ _test3TrackingOnLeaks,
+ notDisposed: 1,
+ notGCed: 1,
+ expectedContextKeys: <LeakType, List<String>>{
+ LeakType.notGCed: <String>[],
+ LeakType.notDisposed: <String>[],
+ },
+ );
+ _verifyLeaks(
+ leaks,
+ _test4TrackingOnWithCreationStackTrace,
+ notDisposed: 1,
+ notGCed: 1,
+ expectedContextKeys: <LeakType, List<String>>{
+ LeakType.notGCed: <String>['start'],
+ LeakType.notDisposed: <String>['start'],
+ },
+ );
+ _verifyLeaks(
+ leaks,
+ _test5TrackingOnWithDisposalStackTrace,
+ notDisposed: 1,
+ notGCed: 1,
+ expectedContextKeys: <LeakType, List<String>>{
+ LeakType.notGCed: <String>['disposal'],
+ LeakType.notDisposed: <String>[],
+ },
+ );
+ _verifyLeaks(
+ leaks,
+ _test8TrackingOnNotDisposed,
+ notDisposed: 1,
+ expectedContextKeys: <LeakType, List<String>>{},
+ );
+}
+
+/// Verifies [allLeaks] contain expected number of leaks for the test [testDescription].
+///
+/// [notDisposed] and [notGCed] set number for expected leaks by leak type.
+/// The method will fail if the leaks context does not contain [expectedContextKeys].
+void _verifyLeaks(
+ Leaks allLeaks,
+ String testDescription, {
+ int notDisposed = 0,
+ int notGCed = 0,
+ Map<LeakType, List<String>> expectedContextKeys = const <LeakType, List<String>>{},
+}) {
+ final Leaks testLeaks = Leaks(
+ allLeaks.byType.map(
+ (LeakType key, List<LeakReport> value) =>
+ MapEntry<LeakType, List<LeakReport>>(key, value.where((LeakReport leak) => leak.phase == testDescription).toList()),
+ ),
+ );
+
+ for (final LeakType type in expectedContextKeys.keys) {
+ final List<LeakReport> leaks = testLeaks.byType[type]!;
+ final List<String> expectedKeys = expectedContextKeys[type]!..sort();
+ for (final LeakReport leak in leaks) {
+ final List<String> actualKeys = leak.context?.keys.toList() ?? <String>[];
+ expect(actualKeys..sort(), equals(expectedKeys), reason: '$testDescription, $type');
+ }
+ }
+
+ _verifyLeakList(
+ testLeaks.notDisposed,
+ notDisposed,
+ testDescription,
+ );
+ _verifyLeakList(
+ testLeaks.notGCed,
+ notGCed,
+ testDescription,
+ );
+}
+
+void _verifyLeakList(
+ List<LeakReport> list,
+ int expectedCount,
+ String testDescription,
+) {
+ expect(list.length, expectedCount, reason: testDescription);
+
+ for (final LeakReport leak in list) {
+ expect(leak.trackedClass, contains(LeakTrackedClass.library));
+ expect(leak.trackedClass, contains('$LeakTrackedClass'));
+ }
+}