Header Logo

Reducing Flaky Tests in React Native Realtime Avatar Apps: Timers, Network, and Media State

Reducing Flaky Tests in React Native Realtime Avatar Apps: Timers, Network, and Media State

React Native avatar test flakiness: control timers, model network transitions, and assert media readiness separately from session state.

Introduction


Flaky tests in React Native avatar apps usually aren’t caused by React Native itself. They come from the timing edges between three independent systems: JS timers, network-driven state, and media playback / capture state. When you add a realtime avatar, those edges get sharper. A test can pass locally and fail in CI because an animation frame arrives late, a WebRTC connection is still warming up, or a stream track is “live” but not yet rendering meaningful video.


This post focuses on the failure modes I see most often in realtime avatar clients: nondeterministic timers, network synchronization, and media lifecycle races. By the end, you should be able to make your tests less timing-sensitive, assert on the right state transitions, and build harnesses that fail for real regressions rather than scheduling noise.


Why realtime avatar tests flake


In a typical React Native app, the UI and network state already have enough asynchrony to create test instability. Realtime avatar apps add another layer:


  • Timer-driven UI: debounced controls, “speaking” indicators, reconnect backoff, token refresh, and local animation loops.

  • Network-driven session state: create avatar/session, authenticate, wait for join, wait for metadata, wait for server-side transitions.

  • Media-driven rendering state: microphone permission, audio output routing, WebRTC connection establishment, video track attachment, first frame decode.


The key mistake is treating these as one thing. They are not. A session can be “connected” while video is not yet visible. A track can be attached while the avatar still has no usable media. A timer can fire in the JS runtime while native work is blocked. If your tests assert on the wrong layer, they become brittle.


Control timers, don’t trust them


The first source of flakiness is usually arbitrary sleep calls. If a test says “wait 2 seconds and then expect speaking to be true,” it is encoding an assumption about execution speed, not the system’s actual state.


Use fake timers for pure JS behavior, and use explicit state transitions for everything else. If the component only depends on local debounce logic or a scheduled timeout, fake timers are a good fit. If it depends on a network response or media event, fake timers won’t help; you need to wait on observable state.


import { act } from '@testing-library/react-native';

});
import { act } from '@testing-library/react-native';

});
import { act } from '@testing-library/react-native';

});


A few practical rules:


  • Advance timers inside act() so React flushes updates predictably.

  • Do not mix fake timers and uncontrolled real timers unless you know exactly which code path uses which.

  • Prefer event-driven assertions over “wait and hope” patterns.


For reconnect loops or retry logic, test the policy, not the wall-clock. For example, verify that the next retry delay is computed correctly, or that a reconnect action is queued after a failure event. You do not need to wait for the full backoff window in every test.


Model network state as a finite sequence


Realtime avatar clients typically go through a small set of network states: unauthenticated, token acquired, session created, connecting, connected, failed, disconnected, reconnecting. Bugs often happen when the UI assumes an immediate happy path and tests only cover that path.


Instead, make the client state machine explicit in tests. Mock the API boundary and drive the component through the exact transitions you care about. This is especially important when your app talks to a backend service like a session API or a websocket/WebRTC signaling layer.


it('shows loading, then connected, then error on session failure', async () => {

});
it('shows loading, then connected, then error on session failure', async () => {

});
it('shows loading, then connected, then error on session failure', async () => {

});


That example is intentionally simplified. The important part is not the exact API shape; it is the discipline of asserting on transitions, not a single final snapshot.


Two common gotchas:


  • Optimistic UI: the app may render “connected” before the media layer is ready. If you need video, assert on the media-ready signal separately.

  • Event ordering: “connected” can arrive before metadata, or vice versa, depending on transport and server timing. Your code should tolerate both if the protocol allows it.


In practice, a reliable test harness often uses a mocked transport at the boundary plus a small number of end-to-end tests against a real environment. Keep the majority of tests deterministic and fast; reserve real network runs for smoke coverage.


Test media readiness separately from session readiness


This is the most common source of avatar-specific flakes. In realtime video systems, “session is established” does not mean “video is visible.” A WebRTC peer connection can be connected while the remote track has not produced a decoded frame yet. Likewise, audio may be active while video is still negotiating.


For React Native, isolate the media lifecycle into a narrow wrapper with observable states such as:


  • connecting — signaling or transport setup in progress

  • connected — control plane ready, but media may still be warming up

  • streaming — remote track attached and first frame received

  • errored — permission, negotiation, or decode failure


Tests should assert on the state that matters to the user. If the user-facing requirement is “the avatar should appear,” then wait for a visible video state or a first-frame event, not just a socket connection.


At the implementation level, prefer media events and track callbacks over polling. Polling a ref every 50 ms is exactly the kind of thing that passes locally and fails under CI load.


it('does not mark video ready before the first frame', async () => {

});
it('does not mark video ready before the first frame', async () => {

});
it('does not mark video ready before the first frame', async () => {

});


If you are using a real device or simulator in integration tests, also account for permissions and audio route changes. On mobile, the camera/mic permission dialog is an external asynchronous step, and the test should either stub it or handle it explicitly. Media tests that ignore permissions are usually testing a path users rarely take.


Make async assertions deterministic


React Native test suites become much more stable when you narrow the set of things you wait for. Use these patterns:


  1. Wait for one specific state change, not “eventually the screen looks right.”

  2. Stub at the transport boundary, not deep inside UI hooks.

  3. Use explicit cleanup for sockets, timers, and media tracks after each test.

  4. Avoid shared mutable singletons for session state or device state across tests.


Also pay attention to test environment drift. CI runners are slower, browser/device emulators vary, and React Native native modules can behave differently across platforms. If a test depends on timing margins that are tight on your laptop, it will eventually fail in CI.


A good smell test: if increasing a timeout makes the test pass, the test is probably observing the wrong thing.


Where Protoface fits


If your app integrates a realtime avatar backend rather than mocking the whole stack, it helps to have a clean boundary for avatar/session creation. Protoface exposes that boundary through its REST API and Python SDK, and the docs at docs.protoface.com are the right place to confirm exact request fields and session shapes.


For a test harness, you can keep the same shape whether you are using the API directly or a higher-level plugin. For example, you might create a session in setup, then assert on the client’s observable states rather than a fixed delay:


import os

session = resp.json()
import os

session = resp.json()
import os

session = resp.json()


If you are using the LiveKit plugin path, the integration goal is the same: let the voice agent own the conversation state, and treat avatar rendering as a media outcome with its own readiness signal. The relevant examples are in the plugin repo and quickstarts, but the testing principle does not change: mock the boundary when you can, and observe actual media readiness when you cannot.


Conclusion


Flaky tests in realtime avatar apps usually come from collapsing three different kinds of async behavior into one vague wait: timers, network transitions, and media readiness. Stabilizing them means testing each layer explicitly. Use fake timers only for pure JS scheduling, model network state as a sequence of transitions, and treat video/audio readiness as separate from session establishment.


In practice, the most reliable suites have a small number of real integration tests and a much larger set of deterministic unit/component tests around the state machine. Start by removing sleeps, then split session readiness from media readiness, then clean up the boundary mocks.


If you want implementation references or integration specifics, check the docs and the relevant quickstarts in the repo ecosystem. That will save you from guessing at protocol details and let your tests focus on the behavior that actually matters.

Add a face to your AI.

No credit card needed.

Add a face to your AI.

No credit card needed.

Add a face to your AI.

No credit card needed.