Flaky test pada integration test React Native umumnya bersumber dari dua hal: request HTTP yang lolos ke internet secara tidak sengaja dan state mock yang bocor antar-test case saat menggunakan jest.spyOn atau jest.mock. Pendekatan mock manual di level modul sering mengabaikan implementasi serialisasi HTTP, logic header, dan parsing response.

Alternatif paling ringkas: gunakan jest.mock('./apiClient') jika hanya ingin memverifikasi pemanggilan fungsi. Gunakan Mock Service Worker (MSW) jika ingin menguji integrasi komponen dari UI hingga network layer secara hermetis tanpa mock manual pada level modul.

Setup Polyfill Web API di Environment Jest

MSW v2 bergantung pada standar Fetch API Node (Request, Response, Headers, dan ReadableStream). Preset Jest React Native standar tidak menyertakan polyfill ini secara lengkap.

Install dependensi yang dibutuhkan:

npm install --save-dev msw undici

Konfigurasikan polyfill pada file setup Jest (misalnya jest.setup.ts) sebelum inisialisasi MSW server:

// jest.setup.ts
import { TextEncoder, TextDecoder } from 'util';
import { ReadableStream } from 'node:stream/web';
import { fetch, Headers, Request, Response, FormData } from 'undici';

// Polyfill global environment Node.js untuk Jest
globalThis.TextEncoder = TextEncoder;
globalThis.TextDecoder = TextDecoder as typeof globalThis.TextDecoder;
globalThis.ReadableStream = ReadableStream as unknown as typeof globalThis.ReadableStream;

globalThis.fetch = fetch as unknown as typeof globalThis.fetch;
globalThis.Headers = Headers as unknown as typeof globalThis.Headers;
globalThis.Request = Request as unknown as typeof globalThis.Request;
globalThis.Response = Response as unknown as typeof globalThis.Response;
globalThis.FormData = FormData as unknown as typeof globalThis.FormData;

Konfigurasi MSW Server dan Lifecycle Hermetis

Definisikan handler default dan buat instance server menggunakan modul Node MSW.

// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('https://api.example.com/profile', () => {
    return HttpResponse.json({
      id: 'usr_123',
      name: 'Budi Pratama',
      email: 'budi@example.com',
    });
  }),
];

Inisialisasi server untuk Jest:

// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

Atur lifecycle server di jest.setup.ts untuk memastikan isolasi antar-test:

// Tambahkan di jest.setup.ts setelah polyfill
import { server } from './src/mocks/server';

beforeAll(() => {
  // Blokir semua network request yang tidak terdaftar di handler
  server.listen({ onUnhandledRequest: 'error' });
});

afterEach(() => {
  // Reset runtime handler override agar tidak bocor ke test file lain
  server.resetHandlers();
});

afterAll(() => {
  server.close();
});

Parameter onUnhandledRequest: 'error' memastikan pipeline CI langsung gagal jika ada kode yang mencoba memanggil endpoint tanpa mock terdefinisi, mencegah flakiness akibat timeout request eksternal.

Implementasi Komponen Target

Komponen menampilkan loading indicator, data user setelah fetch selesai, dan opsi retry jika API mengembalikan error.

// src/components/UserProfile.tsx
import React, { useEffect, useState } from 'react';
import { View, Text, ActivityIndicator, Button } from 'react-native';

interface User {
  id: string;
  name: string;
}

export const UserProfile = () => {
  const [loading, setLoading] = useState(true);
  const [user, setUser] = useState<User | null>(null);
  const [error, setError] = useState<string | null>(null);

  const fetchProfile = async () => {
    setLoading(true);
    setError(null);
    try {
      const res = await fetch('https://api.example.com/profile');
      if (!res.ok) throw new Error('Gagal memuat profil');
      const data = await res.json();
      setUser(data);
    } catch (err) {
      setError((err as Error).message);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchProfile();
  }, []);

  if (loading) return <ActivityIndicator testID="loading-indicator" />;
  if (error) {
    return (
      <View>
        <Text testID="error-message">{error}</Text>
        <Button title="Coba Lagi" onPress={fetchProfile} />
      </View>
    );
  }

  return (
    <View>
      <Text testID="user-name">{user?.name}</Text>
    </View>
  );
};

Pengujian Skenario Async dengan RNTL

Pengujian integrasi menggunakan React Native Testing Library (RNTL) untuk memverifikasi lifecycle komponen: status loading, rendering data sukses, runtime failure override, dan pemulihan (recovery).

// src/components/__tests__/UserProfile.test.tsx
import React from 'react';
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react-native';
import userEvent from '@testing-library/react-native';
import { http, HttpResponse } from 'msw';
import { server } from '../../mocks/server';
import { UserProfile } from '../UserProfile';

describe('UserProfile Integration Test', () => {
  it('menampilkan loading lalu merender data user dengan sukses', async () => {
    render(<UserProfile />);

    // Verifikasi initial loading state
    expect(screen.getByTestID('loading-indicator')).toBeTruthy();

    // Tunggu loading hilang dan verifikasi data masuk
    await waitForElementToBeRemoved(() => screen.getByTestID('loading-indicator'));
    expect(screen.getByTestID('user-name')).toHaveTextContent('Budi Pratama');
  });

  it('menangani skenario error 500 dan berhasil memuat data setelah retry', async () => {
    // Override handler khusus untuk iterasi request pertama
    server.use(
      http.get('https://api.example.com/profile', () => {
        return new HttpResponse(null, { status: 500 });
      }, { once: true })
    );

    const user = userEvent.setup();
    render(<UserProfile />);

    // Tunggu response error muncul
    const errorText = await screen.findByTestID('error-message');
    expect(errorText).toHaveTextContent('Gagal memuat profil');

    // Klik tombol coba lagi (handler default MSW akan merespons 200 OK)
    const retryBtn = screen.getByText('Coba Lagi');
    await user.press(retryBtn);

    // Verifikasi data berhasil tampil setelah retry
    expect(await screen.findByTestID('user-name')).toHaveTextContent('Budi Pratama');
  });
});

Pencegahan Test Leakage dan Masalah Timer di CI

Tiga faktor utama pemicu flaky test saat menggunakan MSW di React Native:

  • Handler Leakage: Penggunaan server.use() tanpa server.resetHandlers() di afterEach menyebabkan override handler menular ke file test berikutnya. Pastikan pemanggilan server.resetHandlers() diletakkan di global setup.
  • Konflik Fake Timers: Pemanggilan jest.useFakeTimers() membekukan timer queue yang digunakan engine network (Fetch/Node HTTP streams). Jika menggunakan fake timers, operasikan macro/microtasks secara eksplisit via jest.runOnlyPendingTimers() atau nonaktifkan fake timer saat menangani testing network berulang.
  • Async Timing Races: Gunakan API findBy* atau waitForElementToBeRemoved daripada waitFor dengan assertion manual di dalamnya untuk memangkas retry cycles yang tidak sinkron.