Skip to content

Proposal: Deterministic animation curve testing in flutter widget tests #181597

Description

@Mairramer

Summary

Flutter currently lacks a first-class, deterministic way to validate animation curves and timings in widget tests.
The recommended approach relies on pixel/position assertions at fixed timestamps, which is brittle, device-dependent, and tightly coupled to layout details.

This proposal introduces a testing-oriented abstraction to:

  • Record animation progress over time (Animation<double>).
  • Normalize samples into a time/value trace.
  • Assert curve conformance statistically instead of via fixed coordinates.
  • Enforce proper lifecycle cleanup (dispose) to avoid test leaks.

The goal is not to change runtime animation behavior, only to improve test observability and correctness.

Motivation

Current Problems

  1. No public API to observe animation progress

    • Tests cannot access Animation<double> without reaching into private state or widget internals.
    • Developers often cast State or extract controllers, which is fragile and non-generic.
  2. Brittle assertions

    • Tests assert layout positions (dy, dx) at specific timestamps.
    • Minor changes in layout, device size, or easing math break tests unrelated to animation intent.
  3. No curve-level validation

    • There is no way to assert that an animation follows Curves.easeIn, easeOut, etc.
    • Reverse curves (reverseCurve) are especially hard to validate correctly.
  4. Lifecycle hazards

    • Attaching listeners during tests without guaranteed cleanup leads to flakiness and memory leaks across frames.

Example from the Framework

The following test demonstrates how animation customization is currently validated for showModalBottomSheet:

testWidgets('Modal bottom sheet animation can be customized', (WidgetTester tester) async {
  final Key sheetKey = UniqueKey();

  Widget buildWidget({AnimationStyle? sheetAnimationStyle}) {
    return MaterialApp(
      home: Scaffold(
        body: Builder(
          builder: (BuildContext context) {
            return GestureDetector(
              onTap: () {
                showModalBottomSheet<void>(
                  context: context,
                  sheetAnimationStyle: sheetAnimationStyle,
                  builder: (BuildContext context) {
                    return SizedBox.expand(
                      child: ColoredBox(
                        key: sheetKey,
                        color: Theme.of(context).colorScheme.primary,
                        child: FilledButton(
                          onPressed: () {
                            Navigator.pop(context);
                          },
                          child: const Text('Close'),
                        ),
                      ),
                    );
                  },
                );
              },
              child: const Text('X'),
            );
          },
        ),
      ),
    );
  }

  // Test custom animation style.
  await tester.pumpWidget(
    buildWidget(
      sheetAnimationStyle: const AnimationStyle(
        duration: Duration(milliseconds: 800),
        reverseDuration: Duration(milliseconds: 400),
      ),
    ),
  );

  await tester.tap(find.text('X'));
  await tester.pump();
  await tester.pump(const Duration(milliseconds: 400));

  expect(tester.getTopLeft(find.byKey(sheetKey)).dy, closeTo(316.7, 0.1));

  await tester.pump(const Duration(milliseconds: 400));
  expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(262.5));

  await tester.tap(find.widgetWithText(FilledButton, 'Close'));
  await tester.pump();
  await tester.pump(const Duration(milliseconds: 200));

  expect(tester.getTopLeft(find.byKey(sheetKey)).dy, closeTo(316.7, 0.1));

  await tester.pump(const Duration(milliseconds: 200));
  expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(600.0));

  // Test no animation style.
  await tester.pumpWidget(buildWidget(sheetAnimationStyle: AnimationStyle.noAnimation));
  await tester.pumpAndSettle();
  await tester.tap(find.text('X'));
  await tester.pump();

  expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(262.5));

  await tester.tap(find.widgetWithText(FilledButton, 'Close'));
  await tester.pump();

  expect(find.byKey(sheetKey), findsNothing);
});

This test correctly verifies that animation durations are respected and that the sheet transitions between visible states. However, it also highlights several limitations of the current approach.

  • Tests assert exact layout values at specific timestamps.
  • Assertions depend on screen size, constraints, and layout behavior.
  • The test cannot validate which curve is used, only the final geometry.
  • Forward and reverse animations cannot be distinguished beyond timing.
  • Small internal changes can invalidate the test without changing behavior.

In practice, this means tests validate effects of animations, not animation behavior itself.

Proposed Direction (Conceptual)

This proposal does not suggest changes to runtime animation behavior. Instead, it outlines potential improvements to test-only observability and validation.

1. Test-oriented animation observation

Introduce a test-only abstraction that allows observing animation progress over time in a normalized, implementation-agnostic way.

2. Generic animation association

Allow tests to associate a widget under test with the animation that drives it, without relying on private state or widget-specific knowledge.

3. Curve-based validation

Enable validation of animations based on sampled progress compared against an expected curve, rather than asserting fixed layout values at specific timestamps.

4. Lifecycle enforcement

Any animation observation mechanism should define and enforce a clear lifecycle, including explicit cleanup, to avoid cross-test interference.

Benefits

  • Tests express animation intent rather than layout math.
  • Reduced brittleness and flakiness in animation-heavy tests.
  • Better validation coverage for curve and reverse-curve APIs.
  • Improved confidence when evolving animation-related framework APIs.

Non-Goals

  • No changes to runtime animation behavior.
  • No exposure of animation controllers or internal state.
  • No breaking changes to existing APIs.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Issues that are less important to the Flutter projecta: animationAnimation APIsa: tests"flutter test", flutter_test, or one of our testsc: new featureNothing broken; request for a new capabilityc: proposalA detailed proposal for a change to Flutterframeworkflutter/packages/flutter repository. See also f: labels.team-frameworkOwned by Framework teamtriaged-frameworkTriaged by Framework team

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions