skip to content

A React Native Jest suite with Animated transitions flakes in CI and logs act() warnings after tests end; what causes it, and how do fake timers fix it?

level: seniorimportance: should knowfreq 34%

answer

  1. requestAnimationFrame is setTimeout in the preset
  2. TimingAnimation reads Date.now
  3. real timers outlive the test
  4. advance by the duration, not runAll
  5. native driver: mock ends after 16 ms

basics

~20 s

Animated animations keep scheduling frames on real timers after a test finishes, so callbacks and state updates land late and timing depends on CI speed. Fake timers make the animation advance only when the test calls jest.advanceTimersByTime.

solid answer

~40 s

Under `@react-native/jest-preset`, `requestAnimationFrame` is a `setTimeout` of 0 ms, and `Animated.timing` computes progress from `Date.now()`. With real timers a 300 ms animation keeps running after the assertion; its `start` callback sets state after the test ends, producing act() warnings, and slower CI machines change which frame an assertion sees. With `jest.useFakeTimers()` (Jest's modern timers also fake `Date`), nothing moves until the test calls `jest.advanceTimersByTime(300)`, so the end state is deterministic. Avoid `runAllTimers` with `Animated.loop`, restore real timers after each test, and remember that a `useNativeDriver: true` animation hits the preset's native-module mock, which reports completion after a 16 ms timer without a final value.

code

tsx · 21 lines
tsx
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import { CheckoutScreen } from '../src/CheckoutScreen';

beforeEach(() => {
  jest.useFakeTimers();
});

afterEach(() => {
  jest.useRealTimers();
});

test('shows the receipt after the banner animation', async () => {
  await render(<CheckoutScreen />);
  await fireEvent.press(screen.getByText('Pay'));

  await act(async () => {
    jest.advanceTimersByTime(300); // the banner's Animated.timing duration
  });

  expect(screen.getByText('Receipt')).toBeOnTheScreen();
});

go deeper

for a junior

Recall that animations use timers, and that fake timers let a test move time forward on purpose instead of waiting.

for a middle

Explain that the preset's requestAnimationFrame is timer-based and TimingAnimation reads Date.now, so modern fake timers drive it through advanceTimersByTime.

for a senior

Diagnose CI-only animation flakiness from late callbacks and act() warnings, fix it with scoped fake timers, and account for the native-driver mock's 16 ms completion.

for a principal

Set suite-wide conventions for timers and animation tests so flakiness is prevented by design rather than hidden with retries.

## The symptom A checkout screen fades a confirmation banner in with `Animated.timing` and, in the `start` callback, sets `showReceipt` to `true`. Locally the tests pass. In CI they fail every few runs, and the log shows act() warnings about state updates after tests finished, sometimes with Jest reporting that work was still pending when the run ended. ## Why real timers make Animated flaky Two facts from the preset and from React Native's `Animated` explain it: - The preset's setup file defines **`requestAnimationFrame`** as a `setTimeout` with a 0 ms delay, and `cancelAnimationFrame` as `clearTimeout`. - **`TimingAnimation`** records `Date.now()` when it starts and, on each frame, compares `Date.now()` with the start time plus `duration` to decide progress and completion. With **real timers**, a 300 ms animation therefore runs for about 300 ms of wall-clock time, one timer tick after another. The test's synchronous assertions finish first; then: 1. frames keep firing after the test body returns; 2. the `start` callback runs and calls `setState` outside `act()`, which React reports because the preset sets `IS_REACT_ACT_ENVIRONMENT` to `true`; 3. on a loaded CI machine, a test that waits "a bit" sees a different frame from the one it saw locally. The flakiness is not random: it is wall-clock time leaking into a test. ## How fake timers fix it `jest.useFakeTimers()` replaces the timer functions with a controllable clock. Jest's default (modern) fake timers also fake `Date`, which matters because `TimingAnimation` reads `Date.now()`. The preset's `requestAnimationFrame` looks up `setTimeout` at call time, so it uses the fake one too. Then the test decides when time passes: ```ts jest.useFakeTimers(); // trigger the animation act(() => { jest.advanceTimersByTime(300); }); // assert the end state ``` The practical rules: - **Advance by the duration** you care about. `advanceTimersByTime` is explicit and works for loops. - **Avoid `runAllTimers` with `Animated.loop`** or repeating sequences: every frame schedules another timer, so Jest hits its infinite-timer guard. - **Wrap advances that cause React updates in `act()`**, or use your testing library's helpers that do it for you. - **Restore real timers** in `afterEach` (`jest.useRealTimers()`), and unmount or stop animations so nothing survives into the next test. - **Turn fake timers on before starting** the animation, so its first frame lands on the fake clock. ## The native driver in Jest `useNativeDriver: true` sends the animation to the native animated module. In Jest that module is the preset's **mock**: its `startAnimatingNode` calls the completion callback after a 16 ms `setTimeout` with `{finished: true}`, whatever the configured duration, and reports no final value. Consequences: | Driver | What advancing timers does | What you can assert | |---|---|---| | JS (`useNativeDriver: false`) | Steps `TimingAnimation` frame by frame | Intermediate and final values, callbacks | | Native (`useNativeDriver: true`) | Fires the mock's completion after 16 ms | That completion callbacks ran and their effects rendered | So for native-driven animations, assert the **effect of completion** (the receipt appears) rather than the `Animated.Value` itself. ## Reanimated animations Reanimated 4 has its own route: its Jest resolver plus `setUpTests()`, after which fake timers and `advanceTimersByTime` move its animations and `toHaveAnimatedStyle` checks the style at that moment. The same discipline applies: fake clock on, advance explicitly, restore afterwards. ## A checklist for a flaky animated suite - Search for tests that trigger animations without fake timers. - Replace `setTimeout`-based waits in tests with explicit timer advances. - Check `afterEach` restores real timers and unmounts. - Assert completion effects for native-driven animations. - Keep a loop-free path, or advance loops by a bounded time. Fixing the timing model once removes a whole class of CI-only failures instead of retrying them.

  • Why does jest.runAllTimers() hang or throw on a screen with Animated.loop?
    Each animation frame is a timer that schedules the next frame, and a loop never finishes, so running all timers never empties the queue. Jest stops at its infinite-timer guard and throws. Advance by a bounded time with `jest.advanceTimersByTime` instead.
  • A useNativeDriver: true fade reports finished, but the Animated.Value still reads 0 in the test; why?
    In Jest the native animated module is the preset's mock. It calls the completion callback with `{finished: true}` after a 16 ms timer but sends no final value, so the JavaScript-side value is never synced. Assert what the completion callback changes on screen, or test the animation logic with the JS driver.

Real timers are a film playing in the next room while you describe a scene: by the time you speak, the film has moved on. Fake timers are a remote with a pause button: the scene only advances when you press forward by an exact number of seconds.

saying these in an interview costs you the question

  • Adding retries in CI is the fix for animation flakiness
  • Fake timers cannot move Animated because it uses the UI thread in tests
  • jest.runAllTimers is safe for any animation, including loops
  • A native-driven animation in Jest steps through intermediate values
  • act() warnings after a test are harmless noise