Pengujian HTTP

Pengenalan supertest dan mocking dependensi pada pengujian HTTP.

Daftar isi

Pengujian HTTP pada Node sering memerlukan permintaan ke endpoint aplikasi tanpa membuka port jaringan sungguhan. Topik ini membahas pemakaian supertest sebagai klien yang menyuntikkan permintaan langsung ke aplikasi Express, pola injeksi dependensi (menyuntikkan dependensi lewat argumen fungsi) sebagai bentuk mocking (mengganti bagian kode dengan tiruan) yang ringan, serta API t.mock.method bawaan node:test untuk mengganti metode pada objek dependensi. Setelah membaca topik ini, sebuah berkas uji HTTP dapat disusun dengan keluaran TAP yang stabil dan tanpa biaya soket TCP sungguhan.

Mengapa pengujian HTTP berbeda#

Tes HTTP berbeda dari tes unit pada satu titik, yaitu titik masuknya berupa permintaan HTTP yang harus diurai oleh aplikasi sebelum mencapai kode yang diuji. Sebuah tes yang langsung memanggil fungsi handler melewati routing, middleware, dan penguraian body, sehingga kesalahan pada lapisan tersebut tidak tertangkap. Pendekatan yang lebih dekat dengan produksi adalah mengirim permintaan HTTP melalui middleware stack aplikasi secara penuh, dan inilah wilayah yang dilayani supertest.

Pola tradisional untuk menguji server HTTP adalah memulai app.listen(port) pada port bebas, lalu melakukan fetch ke http://127.0.0.1:port/endpoint. Pola ini berfungsi, tetapi membayar biaya membuka soket TCP, mengikat port, dan menunggu peristiwa listening. supertest menghilangkan biaya tersebut dengan memanggil aplikasi sebagai fungsi, sehingga permintaan diteruskan langsung ke request listener Express tanpa pernah melewati tumpukan jaringan. Hasilnya adalah tes yang lebih cepat dan tidak bersaing memperebutkan port dengan proses lain.

Dasar supertest#

Berkas app.js berikut mengekspos fungsi createApp yang mengembalikan aplikasi Express baru. Fungsi ini menerima argumen opsional listUsers sebagai sumber data pengguna, sehingga tes dapat menyuntikkan sumber yang dikendalikan tanpa mengubah kode aplikasi. Endpoint /health bersifat statis, sedangkan endpoint /users memanggil listUsers bila tersedia dan menjawab dengan daftar kosong bila tidak.

js app.js
// tidak-dijalankan: butuh paket eksternal express yang dipasang melalui npm
const express = require('express');

function createApp({ listUsers } = {}) {
  const app = express();

  app.get('/health', (req, res) => {
    res.json({ status: 'ok' });
  });

  app.get('/users', async (req, res) => {
    const users = listUsers ? await listUsers() : [];
    res.json({ users });
  });

  return app;
}

module.exports = { createApp };

Berkas app.test.js menguji kedua endpoint dengan pola yang seragam. Setiap tes membuat aplikasi segar lewat createApp(), memanggil supertest(app) untuk memulai sesi klien, lalu menggunakan metode .get(jalur) atau .post(jalur) untuk menyusun permintaan. Hasil yang dikembalikan berupa objek respons dengan properti status, body, headers, dan text, sehingga asersi dapat membandingkan kode status dan isi body secara langsung.

js app.test.js
// tidak-dijalankan: butuh paket eksternal supertest serta sibling app.js
const test = require('node:test');
const assert = require('node:assert/strict');
const supertest = require('supertest');
const { createApp } = require('./app');

test('GET /health mengembalikan status 200 dan status ok', async () => {
  const app = createApp();
  const res = await supertest(app).get('/health');
  assert.equal(res.status, 200);
  assert.equal(res.body.status, 'ok');
});

test('GET /users memakai listUsers yang disuntikkan', async () => {
  const fakeListUsers = async () => [{ id: 1, nama: 'Ali' }];
  const app = createApp({ listUsers: fakeListUsers });
  const res = await supertest(app).get('/users');
  assert.equal(res.status, 200);
  assert.deepEqual(res.body.users, [{ id: 1, nama: 'Ali' }]);
});

test('GET /users tanpa dependensi mengembalikan daftar kosong', async () => {
  const app = createApp();
  const res = await supertest(app).get('/users');
  assert.equal(res.status, 200);
  assert.deepEqual(res.body.users, []);
});

test('mock.method pada objek deps menggantikan listUsers', async (t) => {
  const deps = { listUsers: async () => [] };
  t.mock.method(deps, 'listUsers', async () => [{ id: 2, nama: 'Budi' }]);
  const app = createApp(deps);
  const res = await supertest(app).get('/users');
  assert.deepEqual(res.body.users, [{ id: 2, nama: 'Budi' }]);
  assert.equal(deps.listUsers.mock.calls.length, 1);
});

Karena express dan supertest bukan modul inti Node, keduanya perlu dipasang sebelum tes dijalankan. Pada contoh ini, kedua paket dipasang pada direktori kerja di luar bundle topik, lalu dimuat lewat NODE_PATH agar require('express') dan require('supertest') dapat menemukannya tanpa memasang node_modules di dalam direktori konten.

bash
# tidak-dijalankan: perintah pemasangan tidak diverifikasi, jalankan sekali di luar direktori konten
cd /tmp/pengujian-http && npm init -y && npm install express supertest
bash
# jalankan dari direktori topik
NODE_PATH=/tmp/pengujian-http/node_modules node --test --test-reporter tap app.test.js 2>&1 | sed -E '/^[[:space:]]+---$/,/^[[:space:]]+\.\.\.$/d; /^# duration_ms/d'
text Keluaran
TAP version 13
# Subtest: GET /health mengembalikan status 200 dan status ok
ok 1 - GET /health mengembalikan status 200 dan status ok
# Subtest: GET /users memakai listUsers yang disuntikkan
ok 2 - GET /users memakai listUsers yang disuntikkan
# Subtest: GET /users tanpa dependensi mengembalikan daftar kosong
ok 3 - GET /users tanpa dependensi mengembalikan daftar kosong
# Subtest: mock.method pada objek deps menggantikan listUsers
ok 4 - mock.method pada objek deps menggantikan listUsers
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0

Empat tes di atas lulus tanpa satu pun soket TCP dibuka. supertest mengikat aplikasi pada ephemeral port secara internal hanya untuk keperluan parsing HTTP, tetapi proses binding tersebut tidak bersaing dengan proses lain karena dilakukan dalam proses Node yang sama. Properti res.body sudah berupa objek JavaScript karena supertest mengurai Content-Type: application/json secara otomatis, sehingga asersi deepEqual dapat membandingkan struktur objek tanpa langkah JSON.parse tambahan.

Mocking dependensi pada pengujian HTTP#

Tes keempat pada app.test.js memakai API t.mock.method yang disediakan oleh node:test. API ini menggantikan sebuah metode pada objek dengan fungsi pengganti, dan secara otomatis mengembalikan keadaan semula setelah tes selesai. Pemanggilan t.mock.method(deps, 'listUsers', async () => [...]) mengganti deps.listUsers dengan fungsi yang mengembalikan daftar tetap, sehingga tes dapat memverifikasi bahwa createApp(deps) benar-benar memanggil fungsi tersebut dan meneruskan hasilnya ke respons.

Setelah t.mock.method dipasang, properti deps.listUsers.mock.calls berisi daftar pemanggilan yang tercatat, dengan tiap elemen memuat argumen masukan dan hasil keluaran. Tes pada contoh memeriksa calls.length === 1 untuk memastikan listUsers dipanggil tepat sekali selama satu permintaan ke /users. Pemeriksaan ini berguna untuk mendeteksi bug di mana handler salah memanggil dependensi berkali-kali, misalnya pada kasus retry yang tidak diinginkan.

Pilihan antara injeksi dependensi dan mock modul memengaruhi rancangan kode. Aplikasi yang dirancang dengan injeksi cenderung memiliki fungsi pabrik seperti createApp({ repo, config }), sehingga tiap tes dapat menyusun dependensi yang sesuai tanpa mengutak-atik cache modul. Aplikasi yang mengimpor dependensi pada level akar memerlukan mock.module untuk menggantinya, dan pendekatan ini lebih rapuh karena urutan import menjadi penting.

Kesalahan umum#

Mengabaikan Content-Type pada respons. supertest mengisi res.body hanya bila respons berupa JSON, sedangkan respons text/plain tetap berada di res.text. Tes yang menganggap res.body selalu terisi akan melihat undefined pada respons non-JSON dan kesalahan asersi akan terlihat seperti masalah kode, padahal masalahnya ada pada asumsi tes.

Lupa mengembalikan aplikasi segar pada tiap tes. Bila sebuah aplikasi dibuat sekali di luar test() dan dipakai bersama oleh beberapa tes, state seperti header yang disetel atau middleware yang dipasang di dalam tes dapat bocor ke tes berikutnya. Pola const app = createApp() di dalam tiap test menjaga isolasi.

Memulai server sungguhan dengan app.listen di dalam tes. Pendekatan ini membayar biaya soket dan port, serta memerlukan penanganan server.close() yang sering terlupa sehingga proses Node menggantung. supertest(app) sudah cukup karena request listener dapat dipanggil tanpa listen.

Ringkasan#

  • supertest(app) menyuntikkan permintaan langsung ke aplikasi Express tanpa membuka soket TCP, sehingga tes HTTP berjalan cepat dan tidak bersaing memperebutkan port.
  • Hasil supertest menyediakan res.status, res.body, res.text, dan res.headers, dengan res.body terisi otomatis untuk respons JSON.
  • Pola injeksi dependensi seperti createApp({ listUsers }) menjadi bentuk mocking yang ringan dan menjaga tes tetap deterministik.
  • t.mock.method bawaan node:test mengganti metode pada objek dependensi dan mencatat pemanggilan pada mock.calls, lalu mengembalikan keadaan semula setelah tes selesai.

Referensi#