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:
- 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.
- Ketidakcocokan Fake Timers: Pemanggilan
jest.useFakeTimers()versi legacy menghentikan clock native Node.js tanpa memajukan interval internal RNTL, menyebabkanwaitFormengalami hang tak terhingga. - 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 memanggiljest.runAllTimers()jika komponen memiliki proses looping berkala (misal polling setiap 5 detik), karena pemanggilan tersebut akan menciptakan infinite loop yang membekukan thread Jest. Gunakanjest.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
waitForhanya untuk assertion murni (ekspresiexpect(...)), bukan untuk eksekusi aksi (fireEvent). - Gunakan
jest.useFakeTimers({ advanceTimers: true })pada versi RNTL modern jika komponen mengombinasikan interval waktu dengan rendering state. - Hindari penggunaan
maxWorkerstak terbatas di CI; alokasi 2 thread worker terbukti paling stabil pada instans runner dengan 2 vCPU standar.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!