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
setStatesaat 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 memanggiljest.runAllTimers()pada komponen yang memiliki animasi looping kontinu (seperti loader/spinner). Ini akan menghasilkan error infinite loop. Selalu gunakanjest.advanceTimersByTime(ms)dengan interval spesifik.
Aturan Praktis Menjaga Kestabilan Test Suite
- Hindari Arbitrary Delay: Hapus seluruh instruksi
sleep()atausetTimeoutmanual di dalam file test. - 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. - Gunakan act() Secara Selektif: RNTL
fireEventdanrendersudah dibungkusact()secara default. Bungkus eksplisit hanya dibutuhkan saat memanipulasi fake timers secara manual (advanceTimersByTime).
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!