Pengujian unit pada komponen React Native yang memanfaatkan react-native-reanimated sering memicu kegagalan intermiten (flaky test) di CI/CD. Gejala utamanya mencakup waktu eksekusi yang melampaui batas (timeout) atau peringatan "A worker process has failed to exit gracefully".

Masalah ini berakar pada perbedaan eksekusi runtime animasi antara platform mobile dan lingkungan Node.js/Jest, terutama pada penjadwalan frame via requestAnimationFrame.

Akar Masalah: Frame Loop dan Fake Timers

Reanimated beroperasi di UI thread pada runtime native menggunakan mesin JavaScript terpisah (Hermes/JSI). Di lingkungan Jest, animasi dialihkan ke JavaScript driver tiruan yang bergantung pada requestAnimationFrame (rAF) global.

Ketika pengujian mengaktifkan fake timers standar Jest (jest.useFakeTimers()), fungsi timer bawaan tidak secara otomatis menyinkronkan jam internal Reanimated. Kondisi ini memicu dua kegagalan umum:

  • Hanging Worker Threads: Loop rAF berjalan tanpa henti untuk menunggu kondisi pegas (withSpring) mencapai nilai ekuilibrium, menahan event loop Jest sehingga worker thread tidak pernah keluar.
  • State Assertion Balapan: jest.advanceTimersByTime() memajukan timer JavaScript umum (seperti setTimeout), tetapi melewatkan komputasi delta waktu Reanimated, mengakibatkan nilai sharedValue tidak berubah saat diuji.

Konfigurasi jest.setup.js

Reanimated menyediakan utilitas pengujian khusus untuk mengintersepsi scheduler internal. Inisialisasi mock harus didefinisikan sebelum file pengujian dieksekusi.

// jest.setup.js
require('react-native-reanimated').setUpTests();

Fungsi setUpTests() menjalankan konfigurasi berikut:

  • Mengganti implementasi clock bawaan dengan scheduler virtual Reanimated.
  • Mengatur mock global untuk native worklets dan JSI bindings.
  • Menyediakan hook untuk kontrol frame deterministik manual.

Kontrol Transisi: advanceAnimationByTime dan advanceAnimationByFrame

Untuk menguji transisi withTiming atau withSpring secara deterministik, Reanimated mengekspos API kontrol waktu langsung dari paket pengujian:

  • advanceAnimationByTime(ms): Memajukan clock internal sejumlah milidetik tertentu. Cocok untuk transisi berbasis durasi konstan (withTiming).
  • advanceAnimationByFrame(count): Memajukan clock sebanyak hitungan frame (standar 1 frame ~16.6ms pada 60 FPS). Efektif untuk simulasi kalkulasi fisika pegas (withSpring).
Semua instruksi pemajuan waktu animasi wajib dibungkus di dalam act() dari React Test Renderer atau React Native Testing Library untuk memastikan rekonsiliasi DOM virtual selesai sebelum asersi.

Perbandingan Implementasi Tes

Berikut adalah komponen animasi yang mengubah ukuran berdasarkan properti boolean:

// ExpandableBox.tsx
import React, { useEffect } from 'react';
import { View } from 'react-native';
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated';

interface Props {
  expanded: boolean;
}

export const ExpandableBox = ({ expanded }: Props) => {
  const width = useSharedValue(100);

  useEffect(() => {
    width.value = withTiming(expanded ? 300 : 100, { duration: 500 });
  }, [expanded]);

  const animatedStyle = useAnimatedStyle(() => ({
    width: width.value,
  }));

  return <Animated.View testID="expandable-box" style={animatedStyle} />;
};

Kasus 1: Pengujian Gagal (Flaky / Timeout)

// ExpandableBox.bad.test.tsx
import React from 'react';
import { render } from '@testing-library/react-native';
import { ExpandableBox } from './ExpandableBox';

jest.useFakeTimers();

test('width berubah saat expanded aktif - GAGAL', () => {
  const { getByTestId, rerender } = render(<ExpandableBox expanded={false} />);
  const box = getByTestId('expandable-box');

  rerender(<ExpandableBox expanded={true} />);

  // SALAH: advanceTimersByTime milik Jest tidak memicu kalkulasi clock Reanimated
  jest.advanceTimersByTime(500);

  // Asersi gagal karena style width masih 100
  expect(box.props.style.width).toBe(300);
});

Kasus 2: Pengujian Stabil (Deterministik)

// ExpandableBox.good.test.tsx
import React from 'react';
import { render, act } from '@testing-library/react-native';
import { advanceAnimationByTime } from 'react-native-reanimated';
import { ExpandableBox } from './ExpandableBox';

describe('ExpandableBox', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

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

  test('width berubah saat expanded bernilai true', () => {
    const { getByTestId, rerender } = render(<ExpandableBox expanded={false} />);
    const box = getByTestId('expandable-box');

    rerender(<ExpandableBox expanded={true} />);

    // BENAR: Memajukan clock Reanimated secara eksplisit di dalam act()
    act(() => {
      advanceAnimationByTime(500);
    });

    expect(box.props.style.width).toBe(300);
  });
});

Verifikasi Teardown untuk Mencegah Test Pollution

Animasi yang belum selesai pada akhir blok test() dapat mewariskan callback timer ke file pengujian berikutnya. Kondisi ini menyebabkan kontaminasi state (test pollution) di mana pengujian yang tidak terkait gagal secara acak.

Gunakan protokol pembersihan timer berikut:

  • Gunakan runOnlyPendingTimers: Sebelum beralih ke real timers, eksekusi timer pending yang tersisa tanpa memicu loop baru: jest.runOnlyPendingTimers().
  • Selesaikan Siklus Animasi: Jika komponen memiliki animasi keluar (unmount), pastikan animasi tersebut dijalankan hingga selesai via advanceAnimationByTime sebelum asersi unmount dieksekusi.
  • Kembalikan ke Real Timers: Selalu panggil jest.useRealTimers() pada hook afterEach untuk mencegah kebocoran status fake timers antar-file.