Uji otomatis yang tidak konsisten (flaky test) pada React Native sering kali lolos di lingkungan lokal, namun gagal saat dieksekusi pada pipeline CI/CD. Gejala paling umum berupa pesan galat Timed out in waitFor atau peringatan not wrapped in act(...) yang muncul acak.

Masalah ini jarang disebabkan oleh bug pada logika bisnis komponen. Akar persoalannya hampir selalu bertumpu pada desinkronisasi antara event loop Node.js pada Jest, antrean microtask/macrotask React Native, dan mekanisme polling asinkron pada React Native Testing Library (RNTL).

Akar Masalah: Event Loop Jest, Microtasks, dan Polling waitFor

Secara default, fungsi waitFor pada RNTL bekerja dengan menjalankan fungsi assertion secara berulang menggunakan interval polling (default setiap 50 milidetik) hingga batas waktu tercapai (default 1000 milidetik):

// Mekanisme konseptual RNTL waitFor
async function waitFor(callback, { timeout = 1000, interval = 50 } = {}) {
  const startTime = Date.now();
  while (Date.now() - startTime < timeout) {
    try {
      return runWithAct(callback);
    } catch (error) {
      await runWithAct(() => delay(interval));
    }
  }
  throw new Error('Timed out in waitFor');
}

Ketidakkonsistenan terjadi ketika arsitektur pengujian mengabaikan interaksi antara tiga komponen runtime:

  1. Microtask starvation: Pembaruan state React dijadwalkan via microtask queue (Promise). Jika assertion berjalan sebelum antrean microtask selesai dikosongkan pada CPU pipeline CI yang lambat, assertion akan gagal sebelum polling berikutnya sempat membaca perubahan DOM/tree.
  2. Ketidakcocokan Fake Timers: Pemanggilan jest.useFakeTimers() versi legacy menghentikan clock native Node.js tanpa memajukan interval internal RNTL, menyebabkan waitFor mengalami hang tak terhingga.
  3. Unbounded I/O Mocks: Mock API yang menyelesaikan Promise di luar siklus act() memicu race condition antara event rendering komponen dan pembacaan elemen oleh test runner.

Anti-Pattern Umum Penyebab Flaky Test

1. Arbitrary Sleep (`setTimeout`)

Menyisipkan jeda waktu statis untuk menunggu state diperbarui adalah pendekatan keliru yang memperlambat suite pengujian dan tetap rentan gagal saat beban CPU meningkat.

// ANTI-PATTERN: Menambah delay statis
fireEvent.press(screen.getByText('Submit'));
await new Promise((resolve) => setTimeout(resolve, 500));
expect(screen.getByText('Dashboard')).toBeTruthy();

2. Memasukkan Side Effects ke Dalam `waitFor`

Fungsi callback di dalam waitFor dieksekusi berulang kali selama periode polling. Menaruh trigger aksi (seperti klik atau pengetikan teks) di dalamnya akan memicu efek ganda dan re-render tak terkontrol.

// ANTI-PATTERN: Side-effect di dalam waitFor
await waitFor(() => {
  fireEvent.press(screen.getByText('Submit')); // Dijalankan berulang kali setiap 50ms!
  expect(screen.getByText('Dashboard')).toBeTruthy();
});

3. Nested `act()` Manual

RNTL membungkus utilitas seperti fireEvent, render, dan waitFor secara otomatis menggunakan act(). Menambahkan wrapper act() secara manual di sekitar waitFor justru merusak isolasi flush batching React.

Studi Kasus: Form Login Asinkron

Perhatikan komponen autentikasi berikut yang melakukan validasi dan pemanggilan API asinkron:

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

export const LoginForm = ({ onLogin }) => {
  const [email, setEmail] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const handlePress = async () => {
    if (!email) {
      setError('Email wajib diisi');
      return;
    }
    setLoading(true);
    setError(null);
    try {
      await onLogin(email);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <View>
      <TextInput testID="input-email" value={email} onChangeText={setEmail} />
      <Pressable testID="btn-submit" onPress={handlePress}>
        <Text>{loading ? 'Memproses...' : 'Masuk'}</Text>
      </Pressable>
      {error ? <Text testID="txt-error">{error}</Text> : null}
    </View>
  );
};

Implementasi Flaky

// LoginForm.test.js (Flaky)
it('menampilkan pesan error saat login gagal', async () => {
  const mockLogin = jest.fn().mockRejectedValue(new Error('Kredensial salah'));
  render(<LoginForm onLogin={mockLogin} />);

  fireEvent.changeText(screen.getByTestId('input-email'), '[email protected]');
  fireEvent.press(screen.getByTestId('btn-submit'));

  // Flaky: Menggunakan query getBy* langsung setelah fireEvent
  // Berpotensi fail jika thread CI mengalami delay microtask
  await waitFor(() => {
    expect(screen.getByTestId('txt-error')).toBeTruthy();
    expect(screen.getByText('Kredensial salah')).toBeTruthy();
  });
});

Implementasi Deterministik

Gunakan varian query findBy*. Di balik layar, findBy* menggabungkan getBy* dengan implementasi waitFor yang dioptimalkan untuk siklus render React Native:

// LoginForm.test.js (Deterministik)
it('menampilkan pesan error saat login gagal secara deterministik', async () => {
  const mockLogin = jest.fn().mockRejectedValue(new Error('Kredensial salah'));
  render(<LoginForm onLogin={mockLogin} />);

  fireEvent.changeText(screen.getByTestId('input-email'), '[email protected]');
  fireEvent.press(screen.getByTestId('btn-submit'));

  // findByTestId otomatis menangani polling assertion secara asinkron
  const errorElement = await screen.findByTestId('txt-error');
  expect(errorElement).toHaveTextContent('Kredensial salah');
  expect(mockLogin).toHaveBeenCalledTimes(1);
});

Menangani Fake Timers Bersama waitFor

Jika komponen Anda melibatkan timer native (seperti debounce pencarian, animasi, atau auto-dismiss banner), Anda wajib mengonfigurasi Modern Fake Timers dengan benar agar tidak memblokir polling RNTL.

Semenjak Jest v27+, konfigurasi fake timer harus diintegrasikan dengan flag pemajuan otomatis pada RNTL:

describe('Komponen dengan Debounce Timer', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

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

  it('menampilkan hasil setelah durasi debounce', async () => {
    render(<SearchComponent />);

    fireEvent.changeText(screen.getByTestId('search-input'), 'React Native');

    // Majukan timer sesuai interval debounce komponen
    jest.advanceTimersByTime(300);

    // RNTL tetap dapat mengevaluasi assertion tanpa timeout deadlock
    expect(await screen.findByText('Hasil: React Native')).toBeTruthy();
  });
});
Catatan Teknis: Hindari memanggil jest.runAllTimers() jika komponen memiliki proses looping berkala (misal polling setiap 5 detik), karena pemanggilan tersebut akan menciptakan infinite loop yang membekukan thread Jest. Gunakan jest.advanceTimersByTime(ms) untuk kontrol presisi.

Konfigurasi Jest Setup untuk Lingkungan CI

Untuk memastikan kestabilan uji di pipeline CI, standarisasi konfigurasi global runner melalui file jest.setup.js dan konfigurasi Jest.

1. Pengaturan jest.config.js

module.exports = {
  preset: 'react-native',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  // Batasi worker di CI untuk mencegah resource thrashing yang memicu timeout
  maxWorkers: process.env.CI ? 2 : '50%',
  testTimeout: 10000,
};

2. Pengaturan jest.setup.js

import '@testing-library/react-native/extend-expect';

// Bersihkan mock setelah setiap assertion
afterEach(() => {
  jest.clearAllMocks();
});

// Pastikan animasi default dinonaktifkan agar tidak menahan unmount komponen
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');

Aturan Praktis Menghindari Race Condition

  • Pilih findBy* sebagai standar utama pengambilan elemen yang muncul secara asinkron pasca interaksi.
  • Batasi cakupan waitFor hanya untuk assertion murni (ekspresi expect(...)), bukan untuk eksekusi aksi (fireEvent).
  • Gunakan jest.useFakeTimers({ advanceTimers: true }) pada versi RNTL modern jika komponen mengombinasikan interval waktu dengan rendering state.
  • Hindari penggunaan maxWorkers tak terbatas di CI; alokasi 2 thread worker terbukti paling stabil pada instans runner dengan 2 vCPU standar.