Detox mengandalkan mekanisme automatic synchronization untuk menentukan kapan sebuah aksi pengujian (tap, scroll, type) aman dieksekusi. Mekanisme ini memantau thread aplikasi untuk memastikan status idle sebelum beralih ke assertion berikutnya. Pada iOS, Detox bergantung pada mekanisme internal EarlGrey untuk mengawasi run loop, GCD queues, dan layer animasi. Pada Android, Detox memanfaatkan Espresso Idling Resources untuk mengawasi main looper dan thread pool asynchronous.

Masalah timbul saat aplikasi menjalankan background task berulang, seperti infinite looping animation (Lottie, React Native Animated), polling HTTP (misal: setInterval setiap 3 detik), atau WebSocket heartbeat. Detox menganggap aplikasi terus-menerus sibuk dan menolak melanjutkan eksekusi hingga melebihi batas waktu default, menghasilkan error: DetoxRuntimeError: Execution of test was interrupted: App did not become idle within timeout. Berikut adalah langkah mitigasi komprehensif untuk menstabilkan pipeline testing E2E Anda.

1. Diagnosis Blocker Sinkronisasi via Trace Logs

Langkah pertama dalam memperbaiki sync timeout adalah mengidentifikasi resource mana yang menahan status idle aplikasi. Menjalankan test dengan flag logging detail akan membongkar status antrean EarlGrey atau Espresso.

Jalankan Detox dengan log level trace:

detox test -c ios.sim.debug --loglevel trace --record-logs all

Periksa output log saat timeout terjadi. Detox akan mencetak daftar resource yang masih aktif (busy resources):

  • iOS (EarlGrey): Mencari entri seperti Main Run Loop is busy, Network requests in progress, atau Delayed perform selectors pending. Jika log menampilkan URL spesifik berulang, URL tersebut adalah kandidat blacklist.
  • Android (Espresso): Mencari entri LooperIdlingResource atau ChoreographerIdlingResource. Jika frame render tidak pernah berhenti bernilai 0, penyebab utamanya adalah animasi native atau looping driver.

2. Bypass Network Polling Menggunakan device.setURLBlacklist()

Setiap request HTTP yang berjalan di latar belakang secara otomatis dicegat oleh network layer Detox. Jika aplikasi melakukan polling interval atau logging analitik setiap beberapa detik, Detox tidak akan pernah berada dalam kondisi idle.

Gunakan API device.setURLBlacklist() pada konfigurasi test setup atau file beforeAll untuk memerintahkan Detox mengabaikan endpoint polling tersebut.

describe('Dashboard Polling Tests', () => {
  beforeAll(async () => {
    await device.launchApp();
    
    // Polling endpoint dan tracker analytics diabaikan dari tracking sinkronisasi
    await device.setURLBlacklist([
      '.*127\.0\.0\.1.*heartbeat',
      '.*api\.example\.com\/v1\/notifications\/poll.*',
      '.*google-analytics\.com.*'
    ]);
  });

  afterAll(async () => {
    // Reset blacklist jika suite test lain memerlukan sinkronisasi penuh
    await device.setURLBlacklist([]);
  });
});
Catatan: Parameter setURLBlacklist menerima array ekspresi reguler (regex) string. Pastikan karakter khusus seperti titik (.) di-escape secara benar.

3. Strategi Bypass Infinite Animation pada Level Aplikasi

Animasi looping seperti loading spinner skeleton atau indikator status terus memicu frame update pada UI thread. Baik EarlGrey maupun Espresso mendeteksi ini sebagai aktivitas rendering yang belum selesai.

A. Mengirim Launch Arguments

Inject parameter saat Detox meluncurkan aplikasi, lalu matikan animasi looping pada level kode aplikasi jika argumen tersebut terdeteksi.

// e2e/test.spec.js
await device.launchApp({
  newInstance: true,
  launchArgs: { detoxDisableAnimations: 'YES' },
});

Baca argumen tersebut di React Native (misalnya melalui package react-native-launch-arguments):

// App.js / AnimationComponent.js
import { LaunchArguments } from 'react-native-launch-arguments';

const shouldAnimate = !LaunchArguments.value().detoxDisableAnimations;

// Jika mode test aktif, set iterations ke 1 atau gunakan mock statis
if (!shouldAnimate) {
  return <View testID="static-skeleton" style={styles.placeholder} />;
}

B. Menonaktifkan Looping pada react-native-reanimated atau Animated

Jika menggunakan core Animated API dengan flag useNativeDriver: true, method Animated.loop() akan membuat native thread sibuk terus-menerus. Ganti loop tak hingga menjadi render statis saat __DEV__ atau lewat environment flag pengujian.

4. Kontrol Terukur: device.disableSynchronization()

Ketika aplikasi bergantung pada third-party SDK yang tidak dapat di-blacklist (misalnya connection pool real-time audio atau background thread native tertutup), Anda harus mematikan sinkronisasi otomatis Detox secara lokal.

Jangan biarkan sinkronisasi mati secara global di seluruh suite pengujian karena akan merusak reliabilitas assertion Detox. Gunakan pola mati-lalu-nyalakan secara presisi, lalu gunakan waitFor() eksplisit.

// Matikan sinkronisasi Detox sebelum aksi yang memicu loop tak terkontrol
await device.disableSynchronization();

try {
  await element(by.id('live-stream-button')).tap();
  
  // Tunggu elemen target muncul secara manual dengan timeout eksplisit
  await waitFor(element(by.id('stream-connected-indicator')))
    .toBeVisible()
    .withTimeout(10000);
} finally {
  // Pastikan sinkronisasi dikembalikan agar test case berikutnya tetap aman
  await device.enableSynchronization();
}

Contoh Implementasi: Sebelum vs Sesudah Mitigasi

Berikut perbandingan kode test suite pada halaman yang memiliki polling interval notifikasi setiap 2 detik dan animasi radar scanning.

Sebelum Mitigasi (Flaky / Timeout)

describe('Dashboard Screen Flaky', () => {
  beforeEach(async () => {
    // Aplikasi gagal idle karena polling notifikasi dan radar animation
    await device.reloadReactNative();
  });

  it('navigasi ke riwayat transaksi', async () => {
    // Timeout di sini karena Detox menunggu loop radar & network polling berhenti
    await element(by.id('button-history')).tap();
    await expect(element(by.text('Riwayat Transaksi'))).toBeVisible();
  });
});

Sesudah Mitigasi (Stabil & Deterministic)

describe('Dashboard Screen Stable', () => {
  beforeAll(async () => {
    await device.launchApp({
      newInstance: true,
      launchArgs: { detoxDisableAnimations: 'YES' },
    });

    // 1. Abaikan background polling dari network idling monitor
    await device.setURLBlacklist(['.*\/api\/notifications\/poll.*']);
  });

  beforeEach(async () => {
    await device.reloadReactNative();
  });

  it('navigasi ke riwayat transaksi', async () => {
    // 2. Transisi aman karena animasi dimatikan via launchArgs
    // dan polling diabaikan via setURLBlacklist
    await waitFor(element(by.id('button-history')))
      .toBeVisible()
      .withTimeout(5000);

    await element(by.id('button-history')).tap();
    await expect(element(by.text('Riwayat Transaksi'))).toBeVisible();
  });
});

Kesimpulan Praktik Terbaik

Jangan menyelesaikan masalah sync timeout dengan menambahkan sleep() statis. Terapkan strategi bertahap:

  1. Gunakan log trace untuk mendiagnosis thread atau request yang memblokir.
  2. Terapkan device.setURLBlacklist() untuk traffic periodik background.
  3. Matikan infinite animation pada test environment melalui launchArgs.
  4. Gunakan kombinasi disableSynchronization() dan waitFor() sebagai opsi terakhir hanya pada scope interaksi yang terisolasi.