TESTING ASYNCHRONOUS-JAVASCRIPT JAVASCRIPT FRONTEND-TESTING BACKEND-TESTING UNIT-TESTING INTEGRATION-TESTING DEVELOPER-EXPERIENCE CODE-QUALITY SOFTWARE-TESTING WEB-DEVELOPMENT NODEJS

Menguji Kode Asynchronous: Strategi Praktis untuk Aplikasi Web yang Tangguh dan Bebas Bug

⏱️ 11 menit baca
👨‍💻

Menguji Kode Asynchronous: Strategi Praktis untuk Aplikasi Web yang Tangguh dan Bebas Bug

Sebagai developer web, kita sering berhadapan dengan kode asynchronous. Baik itu fetching data dari API, membaca file, berinteraksi dengan database, atau bahkan animasi UI, hampir semua aplikasi modern memiliki elemen asinkron. Kode asynchronous memungkinkan aplikasi kita tetap responsif dan tidak “hang” saat menunggu operasi yang memakan waktu.

Namun, menguji kode asynchronous bisa menjadi tantangan tersendiri. Bagaimana kita memastikan bahwa fungsi yang mengembalikan Promise berjalan dengan benar? Bagaimana kita menguji interaksi dengan API eksternal tanpa benar-benar memanggilnya? Artikel ini akan membahas strategi dan teknik praktis untuk menaklukkan pengujian kode asynchronous, membantu Anda membangun aplikasi web yang lebih stabil dan bebas bug.

1. Pendahuluan: Kenapa Asynchronous Testing Itu Krusial?

Bayangkan Anda memiliki fungsi yang mengambil data pengguna dari server. Jika fungsi ini gagal, atau mengembalikan data yang salah, seluruh bagian aplikasi Anda bisa terpengaruh. Tanpa pengujian yang tepat, bug pada kode asynchronous seringkali sulit dideteksi dan direproduksi karena sifatnya yang non-deterministik (tergantung waktu dan urutan kejadian).

Pengujian asynchronous yang efektif membantu kita:

Mari kita selami berbagai strategi untuk menguji kode asynchronous.

2. Memahami Sifat Asynchronous dalam Pengujian

Sebelum masuk ke strategi, penting untuk memahami bagaimana test runner (seperti Jest, Vitest, Mocha) menangani kode asynchronous. Secara default, jika sebuah test function selesai dieksekusi, test runner akan menganggap test tersebut berhasil, terlepas dari apakah ada operasi asynchronous yang masih berjalan di latar belakang. Inilah mengapa kita perlu “memberi tahu” test runner untuk menunggu operasi asynchronous selesai.

Pola umum dalam JavaScript untuk operasi asynchronous adalah:

Sebagian besar test runner modern memiliki dukungan bawaan untuk Promises dan async/await, yang sangat menyederhanakan proses pengujian.

3. Strategi Umum Menguji Kode Asynchronous

3.1. Menggunakan async/await dalam Fungsi Test ✅

Ini adalah strategi paling umum dan direkomendasikan untuk menguji Promise atau fungsi async. Cukup jadikan fungsi test Anda sebagai async, dan gunakan await untuk menunggu Promise yang relevan selesai.

// Fungsi yang akan diuji
async function fetchData(url) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }
  return await response.json();
}

// Test dengan async/await
describe('fetchData', () => {
  it('should fetch data successfully', async () => {
    // Mocking fetch API agar tidak memanggil jaringan sungguhan
    global.fetch = jest.fn(() =>
      Promise.resolve({
        ok: true,
        json: () => Promise.resolve({ message: 'Hello, Async World!' }),
      })
    );

    const data = await fetchData('https://api.example.com/data');
    expect(data).toEqual({ message: 'Hello, Async World!' });
    expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/data');
  });

  it('should throw an error if fetch fails', async () => {
    global.fetch = jest.fn(() =>
      Promise.resolve({
        ok: false,
        status: 404,
      })
    );

    await expect(fetchData('https://api.example.com/data')).rejects.toThrow('HTTP error! status: 404');
  });
});

📌 Tips: Saat menguji error pada Promise, gunakan await expect(...).rejects.toThrow(...).

3.2. Mocking dan Stubbing Dependencies 💡

Operasi asynchronous seringkali melibatkan dependensi eksternal (API, database, file system). Untuk menjaga test tetap cepat, deterministik, dan terisolasi, kita perlu “mengganti” dependensi ini dengan mock atau stub.

Contoh di atas menggunakan jest.fn() untuk me-mock global.fetch. Ini memastikan bahwa test tidak benar-benar melakukan panggilan jaringan, yang akan lambat dan tidak stabil.

3.3. Menggunakan Fake Timers untuk Operasi Berbasis Waktu ⏰

Beberapa operasi asynchronous seperti setTimeout, setInterval, atau fungsi debounce/throttle sangat bergantung pada waktu. Menunggu waktu sesungguhnya akan membuat test menjadi sangat lambat. Di sinilah fake timers sangat membantu.

Test runner seperti Jest dan Vitest menyediakan fungsionalitas fake timers yang memungkinkan kita “memajukan” waktu secara instan.

// Fungsi debounce sederhana
function debounce(func, delay) {
  let timeout;
  return function(...args) {
    const context = this;
    clearTimeout(timeout);
    timeout = setTimeout(() => func.apply(context, args), delay);
  };
}

// Test dengan fake timers
describe('debounce', () => {
  beforeEach(() => {
    jest.useFakeTimers(); // Mengaktifkan fake timers
  });

  afterEach(() => {
    jest.runOnlyPendingTimers(); // Menjalankan timer yang mungkin masih ada
    jest.useRealTimers(); // Mengembalikan ke timer asli
  });

  it('should call the function after the specified delay', () => {
    const mockFunction = jest.fn();
    const debouncedFunction = debounce(mockFunction, 1000);

    debouncedFunction();
    expect(mockFunction).not.toHaveBeenCalled(); // Belum dipanggil

    jest.advanceTimersByTime(500); // Majukan waktu 500ms
    expect(mockFunction).not.toHaveBeenCalled(); // Masih belum

    jest.advanceTimersByTime(500); // Majukan waktu 500ms lagi (total 1000ms)
    expect(mockFunction).toHaveBeenCalledTimes(1); // Dipanggil sekali

    debouncedFunction(); // Panggil lagi
    jest.runAllTimers(); // Jalankan semua timer yang ada
    expect(mockFunction).toHaveBeenCalledTimes(2);
  });

  it('should not call the function if called again within the delay', () => {
    const mockFunction = jest.fn();
    const debouncedFunction = debounce(mockFunction, 1000);

    debouncedFunction();
    jest.advanceTimersByTime(500);
    debouncedFunction(); // Panggil lagi sebelum delay selesai
    jest.advanceTimersByTime(500);
    expect(mockFunction).not.toHaveBeenCalled(); // Harusnya belum dipanggil

    jest.advanceTimersByTime(500); // Delay baru selesai
    expect(mockFunction).toHaveBeenCalledTimes(1);
  });
});

📌 Penting: Selalu aktifkan jest.useFakeTimers() di beforeEach dan kembalikan ke jest.useRealTimers() di afterEach untuk menghindari efek samping pada test lain.

4. Contoh Praktis: Menguji Fungsi Data Fetching (Promise/Async/Await)

Mari kita kombinasikan strategi async/await dan mocking untuk menguji fungsi yang mengambil daftar artikel dari API.

// articleService.js
export async function getArticles() {
  try {
    const response = await fetch('/api/articles');
    if (!response.ok) {
      throw new Error(`Failed to fetch articles: ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    console.error("Error fetching articles:", error);
    throw error;
  }
}

// articleService.test.js
import { getArticles } from './articleService';

describe('getArticles', () => {
  const mockArticles = [
    { id: 1, title: 'Artikel A' },
    { id: 2, title: 'Artikel B' },
  ];

  beforeEach(() => {
    // Reset mock sebelum setiap test
    global.fetch = jest.fn();
  });

  it('should return articles if fetch is successful', async () => {
    global.fetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve(mockArticles),
    });

    const articles = await getArticles();
    expect(articles).toEqual(mockArticles);
    expect(global.fetch).toHaveBeenCalledWith('/api/articles');
  });

  it('should throw an error if fetch fails (network error)', async () => {
    global.fetch.mockRejectedValueOnce(new Error('Network is down!'));

    await expect(getArticles()).rejects.toThrow('Network is down!');
    expect(global.fetch).toHaveBeenCalledWith('/api/articles');
  });

  it('should throw an error for non-2xx HTTP status codes', async () => {
    global.fetch.mockResolvedValueOnce({
      ok: false,
      status: 500,
      statusText: 'Internal Server Error',
    });

    await expect(getArticles()).rejects.toThrow('Failed to fetch articles: 500');
    expect(global.fetch).toHaveBeenCalledWith('/api/articles');
  });
});

🎯 Pelajaran: Dengan mocking fetch, kita bisa menguji berbagai skenario (sukses, gagal jaringan, error server) tanpa ketergantungan pada API sungguhan. Ini membuat test cepat dan deterministik.

5. Contoh Praktis: Menguji Interaksi WebSocket

Menguji interaksi real-time seperti WebSocket membutuhkan pendekatan khusus. Kita perlu memastikan bahwa pesan terkirim dan diterima dengan benar.

// websocketClient.js
export class WebSocketClient {
  constructor(url) {
    this.url = url;
    this.ws = null;
    this.messages = [];
    this.onMessageCallback = () => {};
  }

  connect() {
    this.ws = new WebSocket(this.url);
    this.ws.onopen = () => console.log('WebSocket connected');
    this.ws.onmessage = (event) => {
      this.messages.push(event.data);
      this.onMessageCallback(event.data);
    };
    this.ws.onclose = () => console.log('WebSocket disconnected');
    this.ws.onerror = (error) => console.error('WebSocket error:', error);
  }

  send(message) {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(message);
    } else {
      console.warn('WebSocket not open. Message not sent:', message);
    }
  }

  onMessage(callback) {
    this.onMessageCallback = callback;
  }

  close() {
    this.ws.close();
  }
}

// websocketClient.test.js
import { WebSocketClient } from './websocketClient';
import 'jest-websocket-mock'; // Library untuk mocking WebSocket

describe('WebSocketClient', () => {
  const WS_URL = 'ws://localhost:1234';
  let server; // Mock WebSocket server

  beforeEach(async () => {
    server = new WebSocket(WS_URL); // Memulai mock server
    await server.connected; // Tunggu hingga server siap
  });

  afterEach(() => {
    server.close(); // Tutup mock server setelah setiap test
  });

  it('should connect to the WebSocket server', async () => {
    const client = new WebSocketClient(WS_URL);
    client.connect();
    await server.connected; // Tunggu koneksi dari client ke mock server
    expect(client.ws.readyState).toBe(WebSocket.OPEN);
  });

  it('should send messages to the server', async () => {
    const client = new WebSocketClient(WS_URL);
    client.connect();
    await server.connected;

    client.send('Hello from client!');
    await expect(server).toReceiveMessage('Hello from client!'); // Memastikan server menerima pesan
  });

  it('should receive messages from the server', async () => {
    const client = new WebSocketClient(WS_URL);
    client.connect();
    await server.connected;

    const mockCallback = jest.fn();
    client.onMessage(mockCallback);

    server.send('Hello from server!'); // Server mengirim pesan
    await server.nextMessage; // Tunggu client menerima pesan

    expect(client.messages).toEqual(['Hello from server!']);
    expect(mockCallback).toHaveBeenCalledWith('Hello from server!');
  });
});

⚠️ Perhatian: Untuk mocking WebSocket, library seperti jest-websocket-mock sangat membantu. Tanpa itu, mocking WebSocket secara manual bisa sangat kompleks.

6. Best Practices dan Pertimbangan Tambahan

Kesimpulan

Menguji kode asynchronous mungkin terasa rumit pada awalnya, tetapi dengan strategi yang tepat, Anda bisa melakukannya dengan efisien dan efektif. Menguasai async/await dalam test, memanfaatkan mocking untuk dependensi eksternal, dan menggunakan fake timers untuk operasi berbasis waktu adalah kunci untuk membangun test suite yang robust.

Dengan menerapkan strategi ini, Anda tidak hanya akan menangkap bug lebih awal, tetapi juga akan membangun aplikasi web yang lebih tangguh, stabil, dan memberikan pengalaman pengguna yang lebih baik. Jangan biarkan kompleksitas asynchronous menghalangi Anda mencapai kualitas kode yang tinggi!

🔗 Baca Juga