Flaky test pada end-to-end (E2E) testing React Native sering kali berakar dari kegagalan sinkronisasi framework pengujian dengan siklus hidup aplikasi. Detox mengandalkan mekanisme sinkronisasi internal (idling resources) untuk memastikan aplikasi telah selesai memproses aksi sebelum mengeksekusi assertion berikutnya. Ketika aplikasi menjalankan animasi yang berulang tanpa henti (looping animation), Detox menganggap aplikasi sedang sibuk terus-menerus, memicu pengujian terhenti hingga batas waktu habis (timeout).

Akar Masalah: Mekanisme Idle Synchronization pada Detox

Detox memantau kestabilan aplikasi menggunakan EarlGrey di iOS dan Espresso Idling Resources di Android. Komponen sinkronisasi ini mengawasi lima elemen utama:

  • Main dispatch queue dan native UI thread.
  • JavaScript thread queue.
  • Pending network requests (XMLHttpRequests).
  • Active timers (setTimeout/setInterval).
  • Native animation display link callbacks.

Saat menggunakan Animated.loop dari React Native core, withRepeat pada Reanimated, atau file animasi Lottie dengan properti loop={true}, aplikasi menjadwalkan frame callback secara konstan ke native run loop atau Choreographer. Konsekuensinya, status idle tidak pernah tercapai. Detox menahan eksekusi test runner hingga keluar error Timed out waiting for app to idle.

Solusi 1: Bypass Sinkronisasi Menggunakan Guard Pattern

Metode tercepat tanpa merombak arsitektur kode aplikasi adalah mematikan mekanisme sinkronisasi Detox saat berinteraksi dengan layar yang memuat animasi loop. Namun, mengeksekusi device.disableSynchronization() secara langsung berisiko tinggi. Jika test gagal di tengah assertion, sinkronisasi akan tetap mati dan merusak pengujian berikutnya.

Bungkus aksi dalam utility function berbasis try...finally untuk menjamin sinkronisasi selalu aktif kembali:

// e2e/helpers/syncHelper.ts
export async function withSyncDisabled<T>(action: () => Promise<T>): Promise<T> {
  await device.disableSynchronization();
  try {
    return await action();
  } finally {
    await device.enableSynchronization();
  }
}

Implementasikan wrapper tersebut langsung pada test file:

// e2e/loginScreen.e2e.ts
import { withSyncDisabled } from './helpers/syncHelper';

describe('Login Flow dengan Background Animation', () => {
  beforeAll(async () => {
    await device.launchApp();
  });

  it('berhasil input data saat Lottie background sedang aktif', async () => {
    await withSyncDisabled(async () => {
      await element(by.id('input-email')).typeText('[email protected]');
      await element(by.id('input-password')).typeText('SecurePass123!');
      await element(by.id('button-submit')).tap();
    });

    // Assertion setelah sinkronisasi aktif kembali
    await expect(element(by.id('dashboard-screen'))).toBeVisible();
  });
});

Solusi 2: Matikan atau Mock Animasi di Environment E2E

Pendekatan paling stabil untuk Continuous Integration (CI) adalah menonaktifkan looping animation secara menyeluruh pada environment pengujian. Cara ini memotong beban rendering CPU simulator dan mengeliminasi flakiness di level kode aplikasi.

1. Flagging via Environment Variable

Inject environment variable saat build E2E berjalan (misalnya APP_ENV=e2e via react-native-config atau Babel plugin):

// src/components/LoadingSpinner.tsx
import React from 'react';
import LottieView from 'lottie-react-native';
import Config from 'react-native-config';

const IS_E2E = Config.APP_ENV === 'e2e';

export const LoadingSpinner = () => {
  return (
    <LottieView
      source={require('../assets/spinner.json')}
      autoPlay
      loop={!IS_E2E} // Nonaktifkan loop khusus environment test
    />
  );
};

2. Global Flag untuk Reanimated dan Animated Core

Jika menggunakan Animated.loop, override perilakunya agar hanya berjalan 1 kali ketika variabel global pengujian aktif:

// src/utils/animationHelper.ts
import { Animated } from 'react-native';
import Config from 'react-native-config';

export const safeLoop = (
  animation: Animated.CompositeAnimation
): Animated.CompositeAnimation => {
  if (Config.APP_ENV === 'e2e') {
    return animation; // Jalankan satu kali tanpa loop
  }
  return Animated.loop(animation);
};

Solusi 3: Blacklist Network Polling dan Konfigurasi Timer

Selain animasi grafis, looping timer (setInterval) atau HTTP polling reguler yang berjalan simultan dengan animasi sering memperburuk sinkronisasi. Detox menyediakan fungsi untuk mengabaikan request URL spesifik agar tidak diperhitungkan sebagai network blocker:

// e2e/init.ts
beforeAll(async () => {
  await device.launchApp();
  // Blacklist network call telemetry atau periodic polling
  await device.setURLBlacklist([
    '.*analytics\.example\.com.*',
    '.*notification-poller.*'
  ]);
});

Atur juga ambang batas idle monitor pada konfigurasi .detoxrc.js untuk menangani toleransi eksekusi:

// .detoxrc.js
/** @type {Detox.DetoxConfig} */
module.exports = {
  testRunner: {
    args: {
      '$0': 'jest',
      config: 'e2e/jest.config.js'
    },
    jest: {
      setupTimeout: 120000
    }
  },
  behavior: {
    init: {
      exposeGlobals: true
    }
  }
};
Catatan: Jangan meningkatkan setupTimeout Jest untuk menutupi animasi looping yang macet. Hal ini hanya menunda failure tanpa menyelesaikan bottleneck idle synchronization.

Verifikasi Kestabilan Test Runner di CI

Setelah menerapkan solusi, verifikasi bahwa test case bebas dari flakiness sebelum merge ke main branch. Jalankan test suite berulang kali secara terisolasi menggunakan flag jest:

npx detox test -c ios.sim.release --testNamePattern="Login Flow" -- --runInBand --repeatEach=10

Jika tes sukses 10 kali berturut-turut tanpa failure akibat idle timeout, strategi mitigasi animasi telah bekerja dengan aman. Gunakan kombinasi withSyncDisabled untuk interaksi spesifik dan isolasi environment build (mocking loop) untuk komponen animasi statis di seluruh aplikasi.