| // 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/foundation.dart'; | |
| import 'package:flutter/scheduler.dart'; | |
| import 'package:flutter/widgets.dart'; | |
| import 'package:flutter_test/flutter_test.dart'; | |
| // Registers a tear down that calls [TestWidgetsFlutterBinding.postTest] if a | |
| // test that drives the binding directly fails before calling it. Otherwise the | |
| // binding would stay in a test and every later test in the file would fail. | |
| // | |
| // Call this before registering tear downs that reset state, so that (since tear | |
| // downs run in reverse order) the state is reset before `postTest` runs. | |
| void _addPostTestTearDown(TestWidgetsFlutterBinding binding) { | |
| addTearDown(() { | |
| if (binding.inTest) { | |
| binding.postTest(); | |
| } | |
| }); | |
| } | |
| // Generates test cases for a debug variable or test setting. | |
| // | |
| // The `name` argument is the identifier of the variable or setting being tested. | |
| // | |
| // The `mutateAndGetReset` callback modifies the variable or setting and | |
| // returns a [VoidCallback] that restores it to its original value. | |
| // | |
| // The `expectedErrorMessage` is the message expected on the [FlutterError] | |
| // reported by [TestWidgetsFlutterBinding.postTest] when the variable is left | |
| // modified. | |
| void _testDebugVariable({ | |
| required String name, | |
| required VoidCallback Function(TestWidgetsFlutterBinding binding) mutateAndGetReset, | |
| required String expectedErrorMessage, | |
| }) { | |
| group('$name:', () { | |
| // Verifies that the variable can be mutated in [testWidgets] and cleanly | |
| // reset via [addTearDown] without triggering invariant failures. | |
| testWidgets('can be reset with addTearDown', (WidgetTester tester) async { | |
| final VoidCallback reset = mutateAndGetReset(tester.binding); | |
| addTearDown(reset); | |
| }); | |
| // Verifies that when mutated during a test without being reset, | |
| // [TestWidgetsFlutterBinding.postTest] catches the invariant failure, reports | |
| // it via [reportTestException] with [expectedErrorMessage], and finishes | |
| // cleaning up the binding state without throwing. | |
| test('direct runTest reports exception in postTest when not reset', () async { | |
| FlutterErrorDetails? reportedError; | |
| final TestExceptionReporter oldReporter = reportTestException; | |
| reportTestException = (FlutterErrorDetails details, String testDescription) { | |
| reportedError = details; | |
| }; | |
| addTearDown(() { | |
| reportTestException = oldReporter; | |
| }); | |
| final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized(); | |
| _addPostTestTearDown(binding); | |
| VoidCallback? reset; | |
| addTearDown(() => reset?.call()); | |
| await binding.runTest(() async { | |
| reset = mutateAndGetReset(binding); | |
| }, () {}); | |
| expect(() => binding.postTest(), returnsNormally); | |
| expect(reportedError, isNotNull); | |
| expect((reportedError!.exception as FlutterError).message, expectedErrorMessage); | |
| expect(binding.inTest, isFalse); | |
| }); | |
| }); | |
| } | |
| void main() { | |
| const foundationErrorMessage = | |
| 'The value of a foundation debug variable was changed by the test.'; | |
| _testDebugVariable( | |
| name: 'debugDefaultTargetPlatformOverride', | |
| mutateAndGetReset: (TestWidgetsFlutterBinding binding) { | |
| final TargetPlatform? original = debugDefaultTargetPlatformOverride; | |
| debugDefaultTargetPlatformOverride = TargetPlatform.macOS; | |
| return () => debugDefaultTargetPlatformOverride = original; | |
| }, | |
| expectedErrorMessage: foundationErrorMessage, | |
| ); | |
| _testDebugVariable( | |
| name: 'debugDoublePrecision', | |
| mutateAndGetReset: (TestWidgetsFlutterBinding binding) { | |
| final int? original = debugDoublePrecision; | |
| debugDoublePrecision = 3; | |
| return () => debugDoublePrecision = original; | |
| }, | |
| expectedErrorMessage: foundationErrorMessage, | |
| ); | |
| _testDebugVariable( | |
| name: 'debugBrightnessOverride', | |
| mutateAndGetReset: (TestWidgetsFlutterBinding binding) { | |
| final Brightness? original = debugBrightnessOverride; | |
| debugBrightnessOverride = Brightness.dark; | |
| return () => debugBrightnessOverride = original; | |
| }, | |
| expectedErrorMessage: foundationErrorMessage, | |
| ); | |
| _testDebugVariable( | |
| name: 'autoUpdateGoldenFiles', | |
| mutateAndGetReset: (TestWidgetsFlutterBinding binding) { | |
| final bool original = autoUpdateGoldenFiles; | |
| autoUpdateGoldenFiles = !original; | |
| return () => autoUpdateGoldenFiles = original; | |
| }, | |
| expectedErrorMessage: 'The value of autoUpdateGoldenFiles was changed by the test.', | |
| ); | |
| _testDebugVariable( | |
| name: 'ErrorWidget.builder', | |
| mutateAndGetReset: (TestWidgetsFlutterBinding binding) { | |
| final ErrorWidgetBuilder original = ErrorWidget.builder; | |
| ErrorWidget.builder = (FlutterErrorDetails details) => Container(); | |
| return () => ErrorWidget.builder = original; | |
| }, | |
| expectedErrorMessage: 'The value of ErrorWidget.builder was changed by the test.', | |
| ); | |
| _testDebugVariable( | |
| name: 'shouldPropagateDevicePointerEvents', | |
| mutateAndGetReset: (TestWidgetsFlutterBinding binding) { | |
| final bool original = binding.shouldPropagateDevicePointerEvents; | |
| binding.shouldPropagateDevicePointerEvents = !original; | |
| return () => binding.shouldPropagateDevicePointerEvents = original; | |
| }, | |
| expectedErrorMessage: | |
| 'The value of shouldPropagateDevicePointerEvents was changed by the test.', | |
| ); | |
| _testDebugVariable( | |
| name: 'reportTestException', | |
| mutateAndGetReset: (TestWidgetsFlutterBinding binding) { | |
| final TestExceptionReporter original = reportTestException; | |
| reportTestException = (FlutterErrorDetails details, String testDescription) {}; | |
| return () => reportTestException = original; | |
| }, | |
| expectedErrorMessage: 'The value of reportTestException was changed by the test.', | |
| ); | |
| // Verifies that when a test body fails, [TestWidgetsFlutterBinding._runTestBody] | |
| // leaves the widget tree intact for inspection (does not unmount it to | |
| // `_postTestMessage`) and [TestWidgetsFlutterBinding.postTest] skips invariant | |
| // verification to avoid spurious secondary errors. | |
| test('direct runTest with failed test body skips invariant check and preserves widget tree in postTest', () async { | |
| final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized(); | |
| final TestExceptionReporter oldReporter = reportTestException; | |
| reportTestException = (FlutterErrorDetails details, String testDescription) {}; | |
| addTearDown(() { | |
| reportTestException = oldReporter; | |
| }); | |
| _addPostTestTearDown(binding); | |
| addTearDown(() { | |
| debugDefaultTargetPlatformOverride = null; | |
| }); | |
| await binding.runTest(() async { | |
| debugDefaultTargetPlatformOverride = TargetPlatform.macOS; | |
| runApp(const Placeholder()); | |
| throw Exception('test failed'); | |
| }, () {}); | |
| // postTest should NOT throw invariant error because the test body failed. | |
| expect(() => binding.postTest(), returnsNormally); | |
| expect(binding.inTest, isFalse); | |
| // The widget tree from the test is preserved for inspection rather than | |
| // being unmounted and replaced with _postTestMessage. | |
| expect(find.byType(Placeholder), findsOneWidget); | |
| }); | |
| // Verifies that asynchronous operations executed in [addTearDown] callbacks | |
| // (which may schedule microtasks) do not cause invariant failures in | |
| // [TestWidgetsFlutterBinding.postTest]. | |
| testWidgets( | |
| 'addTearDown can execute asynchronous operations without triggering invariant errors', | |
| (WidgetTester tester) async { | |
| addTearDown(() async { | |
| await Future<void>.value(); | |
| }); | |
| }, | |
| ); | |
| // Verifies that invariant checks that report failures via | |
| // [FlutterError.reportError] instead of throwing (such as | |
| // [SchedulerBinding.debugAssertNoTransientCallbacks]) are captured and | |
| // reported with the test description in [TestWidgetsFlutterBinding.postTest]. | |
| test('postTest reports invariant failures logged via FlutterError.reportError', () async { | |
| FlutterErrorDetails? reportedError; | |
| String? reportedDescription; | |
| final TestExceptionReporter oldReporter = reportTestException; | |
| reportTestException = (FlutterErrorDetails details, String testDescription) { | |
| reportedError = details; | |
| reportedDescription = testDescription; | |
| }; | |
| addTearDown(() { | |
| reportTestException = oldReporter; | |
| }); | |
| final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized(); | |
| _addPostTestTearDown(binding); | |
| // A Ticker reschedules its frame callback on every tick, so it survives the | |
| // frames pumped while unmounting the tree. A one-shot frame callback would | |
| // be consumed by those frames instead. | |
| Ticker? ticker; | |
| addTearDown(() => ticker?.dispose()); | |
| await binding.runTest( | |
| () async { | |
| ticker = Ticker((Duration _) {})..start(); | |
| }, | |
| () {}, | |
| description: 'leaking ticker test', | |
| ); | |
| expect(() => binding.postTest(), returnsNormally); | |
| expect(reportedError, isNotNull); | |
| expect( | |
| reportedError!.exception, | |
| 'An animation is still running even after the widget tree was disposed.', | |
| ); | |
| expect(reportedDescription, 'leaking ticker test'); | |
| expect(binding.inTest, isFalse); | |
| }); | |
| } |