Flaky test pada React Native umumnya disebabkan oleh ketidaksinkronan antara Jest runtime loop, microtask queue (Promise), dan macrotask queue (timer seperti setTimeout atau debounce). Ketidakstabilan ini paling sering muncul pada continuous integration (CI) akibat variasi alokasi CPU yang mengubah timing eksekusi asynchronous.

Akar Masalah: Siklus Asinkron dan act() Warning

Peringatan "An update to Component inside a test was not wrapped in act(...)" menandakan adanya mutasi state komponen setelah pengujian selesai atau di luar blok pemantauan Jest. Penyebab utamanya meliputi tiga hal:

  • Unresolved Promises: Pemanggilan API mock belum sepenuhnya resolve saat assertion dieksekusi.
  • Debounce Timer: Callback timer macrotask mengeksekusi setState saat runner sudah berpindah ke test case berikutnya.
  • Unmounted State Leak: Cleanup effect komponen (seperti pembatalan subscriber atau listener) tidak dipicu secara deterministik.

Mengatasi masalah ini dengan await new Promise(r => setTimeout(r, 1000)) adalah anti-pattern. Arbitrary sleep memperlambat runtime test suite dan tetap rentan gagal pada node CI yang lambat.

Konfigurasi Jest Fake Timers dan Microtask Loop

Jest menyediakan mock timer untuk mengontrol pergeseran waktu secara deterministik tanpa menunggu waktu riil. Namun, memajukan fake timers hanya mengeksekusi macrotask, bukan microtask queue tempat Promise bersarang.

Gunakan modern fake timers pada jest.setup.js atau secara lokal di dalam file pengujian:

// Konfigurasi fake timers modern
beforeEach(() => {
  jest.useFakeTimers();
});

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

Untuk menyelesaikan Promise berantai setelah timer dimajukan, biarkan runtime mengosongkan microtask queue menggunakan helper flush promise:

// Utility untuk flush seluruh microtask queue
const flushMicrotasks = () => new Promise(jest.requireActual('timers').setImmediate);

Studi Kasus: Form Input dengan Debounce Auto-Save

Berikut adalah contoh komponen form pencarian atau auto-save yang memicu pemanggilan network setelah debounce 500ms:

import React, { useState, useEffect } from 'react';
import { TextInput, View, Text } from 'react-native';

export const AutoSaveInput = ({ onSave }) => {
  const [text, setText] = useState('');
  const [status, setStatus] = useState('idle');

  useEffect(() => {
    if (!text) return;
    setStatus('saving');
    const handler = setTimeout(async () => {
      await onSave(text);
      setStatus('saved');
    }, 500);

    return () => clearTimeout(handler);
  }, [text, onSave]);

  return (
    <View>
      <TextInput
        testID="auto-save-input"
        value={text}
        onChangeText={setText}
      />
      <Text testID="status-indicator">{status}</Text>
    </View>
  );
};

Kode Test Flaky (Sebelum Refactor)

Pendekatan di bawah ini gagal di CI karena timer riil berkejaran dengan assertion, atau menggunakan waitFor bersamaan dengan fake timer yang belum di-advance secara eksplisit:

// BAD: Menggantung runner atau memicu timeout acak
it('menyimpan data setelah 500ms', async () => {
  const onSaveMock = jest.fn().mockResolvedValue(true);
  const { getByTestId } = render(<AutoSaveInput onSave={onSaveMock} />);

  fireEvent.changeText(getByTestId('auto-save-input'), 'React Native');

  // Flaky: waitFor akan timeout karena fake timer diam di tempat
  await waitFor(() => {
    expect(onSaveMock).toHaveBeenCalledWith('React Native');
  });
});

Kode Test Stabil (Setelah Refactor)

Pola deterministik: ubah input, majukan timer sesuai batas waktu debounce via jest.advanceTimersByTime(), lalu tunggu resolusi asynchronous state melalui waitFor terikat RNTL:

// GOOD: Sinkronisasi deterministik macrotask dan microtask
it('menyimpan data secara deterministik setelah debounce', async () => {
  const onSaveMock = jest.fn().mockResolvedValue(true);
  const { getByTestId } = render(<AutoSaveInput onSave={onSaveMock} />);

  fireEvent.changeText(getByTestId('auto-save-input'), 'React Native');
  expect(getByTestId('status-indicator').props.children).toBe('saving');

  // 1. Majukan timer debounce secara presisi
  act(() => {
    jest.advanceTimersByTime(500);
  });

  // 2. Tunggu state transition dari Promise onSave selesai
  await waitFor(() => {
    expect(onSaveMock).toHaveBeenCalledTimes(1);
    expect(onSaveMock).toHaveBeenCalledWith('React Native');
  });

  expect(getByTestId('status-indicator').props.children).toBe('saved');
});

Isolasi Mock Native Driver untuk Animasi

Komponen yang menggunakan Animated.timing dengan flag useNativeDriver: true kerap memicu unhandled rejection atau pending frame callbacks di lingkungan Node.js karena modul native bridge tidak tersedia.

Pasang mock untuk NativeAnimatedHelper pada jest.setup.js agar animasi fallback ke JavaScript runtime tanpa memblokir thread test:

// jest.setup.js
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');

Jika menggunakan library pihak ketiga seperti react-native-reanimated, gunakan mock resmi yang disediakan library terkait sebelum test suites dijalankan:

// jest.setup.js
require('react-native-reanimated').setUpTests();
Catatan: Jangan memanggil jest.runAllTimers() pada komponen yang memiliki animasi looping kontinu (seperti loader/spinner). Ini akan menghasilkan error infinite loop. Selalu gunakan jest.advanceTimersByTime(ms) dengan interval spesifik.

Aturan Praktis Menjaga Kestabilan Test Suite

  1. Hindari Arbitrary Delay: Hapus seluruh instruksi sleep() atau setTimeout manual di dalam file test.
  2. Batasi Lingkup waitFor: Jangan memasukkan banyak assertion di dalam satu closure waitFor. Taruh hanya assertion asinkron yang ditunggu, sisanya evaluasi secara sinkron di luar blok tersebut.
  3. Gunakan act() Secara Selektif: RNTL fireEvent dan render sudah dibungkus act() secara default. Bungkus eksplisit hanya dibutuhkan saat memanipulasi fake timers secara manual (advanceTimersByTime).