Kegagalan Mock Statis dan Risiko Breaking Changes pada Mobile

Aplikasi mobile memiliki karakteristik distribusi yang berbeda dari web: klien tidak dapat diperbarui secara instan. Sekali binari APK atau IPA terpasang pada perangkat pengguna, versi tersebut akan terus mengonsumsi backend API sampai pengguna memilih untuk melakukan pembaruan via app store.

Pengujian unit standar di React Native umumnya mengandalkan jest.mock() atau fixture JSON statis. Pendekatan ini memiliki kelemahan mendasar: API drift. Jika tim backend mengubah nama field, menghapus properti, atau mengubah format tanggal, unit test pada sisi mobile tetap berstatus passed karena membaca mock statis lama. Hasilnya, breaking change baru terdeteksi saat runtime di production.

Consumer-Driven Contract Testing (CDCT) membalik paradigma integrasi. Aplikasi mobile (consumer) mendefinisikan kontrak eksplisit mengenai struktur payload dan status HTTP yang dibutuhkan. Kontrak ini diekspor ke dalam format standar (Pact JSON) untuk kemudian divalidasi oleh backend (provider) sebelum backend melakukan deployment.

Arsitektur Pact Consumer di React Native

Pact JS tidak memerlukan bridge native React Native, Metro bundler, Android emulator, maupun iOS simulator. Kontrak diuji pada level network client menggunakan Node.js runtime di dalam runner Jest.

Pisahkan konfigurasi Jest khusus contract testing untuk menghindari konflik dengan modul React Native preset (seperti mock native modules atau runtime JSX). Buat file konfigurasi terpisah:

// jest.pact.config.js
module.exports = {
  testEnvironment: 'node',
  testMatch: ['**/*.contract.test.ts'],
  transform: {
    '^.+\\.[tj]sx?$': 'ts-jest',
  },
  testTimeout: 30000,
};

Instal dependensi yang dibutuhkan:

npm install --save-dev @pact-foundation/pact @types/jest ts-jest jest

Implementasi Consumer Test Menggunakan PactV3

Gunakan class PactV3 dari library @pact-foundation/pact. Hindari pencocokan nilai literal (exact value matching) pada payload response. Gunakan Matchers agar pengujian fokus pada tipe data dan struktur skema.

1. Service Layer Klien Mobile

Berikut adalah service HTTP sederhana yang akan diuji:

// src/api/user.ts
export interface UserProfile {
  id: string;
  name: string;
  email: string;
  status: 'ACTIVE' | 'SUSPENDED';
}

export const fetchUserProfile = async (baseUrl: string, token: string): Promise<UserProfile> => {
  const res = await fetch(`${baseUrl}/api/v1/users/me`, {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: 'application/json',
    },
  });

  if (!res.ok) {
    throw new Error(`Request failed with status ${res.status}`);
  }

  return res.json();
};

2. Penulisan Contract Test

Test berikut menjalankan mock server lokal Pact, mengeksekusi fungsi klien ke mock server tersebut, dan memvalidasi interaksi HTTP.

// src/api/__tests__/user.contract.test.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import path from 'path';
import { fetchUserProfile } from '../user';

const { like, uuid, string } = MatchersV3;

const provider = new PactV3({
  consumer: 'MobileApp-ReactNative',
  provider: 'UserBackendService',
  dir: path.resolve(process.cwd(), 'pacts'),
  logLevel: 'warn',
});

describe('User API Contract', () => {
  it('menerima payload profil user yang valid dari backend', async () => {
    // Definisi ekspektasi interaksi
    provider
      .given('pengguna terautentikasi dan aktif')
      .uponReceiving('permintaan GET untuk detail akun')
      .withRequest({
        method: 'GET',
        path: '/api/v1/users/me',
        headers: {
          Authorization: 'Bearer valid-jwt-token',
          Accept: 'application/json',
        },
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          id: uuid('e9b5c3e0-6b6f-4a6c-9c3f-981a8ef1c2e4'),
          name: string('Budi Santoso'),
          email: string('[email protected]'),
          status: like('ACTIVE'),
        },
      });

    // Eksekusi request nyata ke mock server Pact
    await provider.executeTest(async (mockServer) => {
      const result = await fetchUserProfile(mockServer.url, 'valid-jwt-token');

      expect(result.id).toBeDefined();
      expect(result.name).toBe('Budi Santoso');
      expect(result.status).toBe('ACTIVE');
    });
  });
});
Catatan Teknis: Matcher like() memastikan provider mengirim tipe data yang sesuai (misal: string), tanpa memaksa backend mengembalikan nilai literal 'ACTIVE' saat verifikasi provider berjalan.

Otomasi Pipeline CI: Publish dan can-i-deploy

Menjalankan test secara lokal hanya menghasilkan artefak JSON di folder pacts/. Untuk mengunci kompatibilitas antara mobile build dan backend, integrasikan artefak tersebut ke Pact Broker.

Tahapan CI/CD:

  1. Jalankan Test: Bentuk file contract JSON.
  2. Publish Contract: Unggah artefak dengan metadata Git SHA dan branch.
  3. Verifikasi Deployability: Jalankan perintah can-i-deploy sebelum melakukan build biner aplikasi (misal: via Fastlane atau EAS).

Berikut contoh implementasi GitHub Actions workflow:

# .github/workflows/pact-consumer.yml
name: Pact Consumer CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_BASE_URL }}
  PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
  GIT_COMMIT: ${{ github.sha }}
  GIT_BRANCH: ${{ github.ref_name }}

jobs:
  run-contract-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install Dependencies
        run: npm ci

      - name: Run Pact Contract Tests
        run: npx jest --config jest.pact.config.js

      - name: Publish Contract to Pact Broker
        run: |
          npx @pact-foundation/pact-cli pact-broker publish pacts \
            --consumer-app-version $GIT_COMMIT \
            --branch $GIT_BRANCH \
            --broker-base-url $PACT_BROKER_BASE_URL \
            --broker-token $PACT_BROKER_TOKEN

      - name: Check Deployability to Production
        run: |
          npx @pact-foundation/pact-cli pact-broker can-i-deploy \
            --pacticipant MobileApp-ReactNative \
            --version $GIT_COMMIT \
            --to-environment production \
            --broker-base-url $PACT_BROKER_BASE_URL \
            --broker-token $PACT_BROKER_TOKEN

Aturan Penerapan (Trade-offs & Best Practices)

  • Hindari Testing Functional State di Pact: Contract testing bukan pengganti End-to-End (E2E) testing. Jangan uji validasi UI atau business logic kompleks di sini. Batasi hanya pada skema komunikasi HTTP.
  • Gunakan State Provider Seperlunya: Klausul provider.given() harus didelegasikan ke provider untuk menyiapkan test data (mocking database backend). Jaga deklarasi state sesederhana mungkin.
  • Pemisahan Environment Script: Jangan gabungkan runner Pact ke dalam script npm test harian jika developer backend belum menerapkan Pact verifier di repo mereka, karena can-i-deploy akan memblokir pipeline jika provider belum memverifikasi kontrak tersebut.