Automated Testing API Node.js dengan Jest dan Supertest
Admin
Penulis Artikel
Automated Testing API Node.js dengan Jest dan Supertest
Sejak artikel pertama, setiap kali kita menambahkan fitur baru ke Todo List API, cara kita mengujinya selalu sama: buka terminal, ketik curl satu-satu, baca responsnya, lalu bandingkan manual dengan ekspektasi kita. Cara ini melelahkan dan berisiko — begitu project makin besar, kamu tidak mungkin mengetes ulang puluhan endpoint secara manual setiap kali mengubah sedikit kode.
Artikel ini akan membahas cara menulis automated test menggunakan Jest (test runner) dan Supertest (library untuk menguji endpoint HTTP), supaya pengujian bisa dijalankan otomatis, berulang, dan konsisten hanya dengan satu perintah.
Artikel ini melanjutkan project Todo List API dari artikel pertama hingga keenam. Setelah ini, kita akan lanjut ke Testing dan Dokumentasi API dengan Postman dan Swagger.
1. Kenapa Butuh Automated Testing?
Analogi: menguji API secara manual lewat curl itu seperti guru olahraga yang harus mengetes kelincahan setiap siswa satu per satu, setiap hari, dari awal lagi — padahal kemampuan siswa yang sudah lulus tes kemarin harusnya tidak perlu dites ulang dari nol tiap hari, cukup dipastikan skill itu masih ada. Automated testing ibarat rekaman standar tes kelincahan yang bisa dijalankan otomatis kapan saja: begitu ada siswa baru (fitur baru) atau ada perubahan (refactor kode), “rekaman tes” ini dijalankan ulang secara instan untuk memastikan semua yang sudah pernah lolos, tetap lolos — tanpa guru harus capek mengetes manual satu-satu lagi.
Keuntungan automated testing:
Cepat — ratusan skenario bisa dites dalam hitungan detik.
Konsisten — tidak ada human error saat membaca response manual.
Aman untuk refactor — kalau kamu mengubah struktur kode tapi semua test masih hijau (pass), kamu tahu fungsionalitasnya tidak rusak.
Dokumentasi hidup — test yang ditulis dengan baik menjelaskan perilaku API-nya sendiri.
2. Setup: Install Dependency
npm install --save-dev jest supertest mongodb-memory-server
jest— test runner, tempat kita menulis dan menjalankan test.supertest— mengirim request HTTP palsu ke aplikasi Express kita, tanpa perlu server benar-benar berjalan di port tertentu.mongodb-memory-server— menjalankan MongoDB sungguhan tapi di memori, khusus untuk testing, supaya test tidak menyentuh database development/production sungguhan.
Analogi mongodb-memory-server: ini seperti ruang simulasi ujian praktik yang terpisah dari ruang kelas sungguhan — siswa boleh berlatih sepuasnya di ruang simulasi tanpa takut merusak alat-alat asli di lab sekolah. Setiap kali simulasi selesai, ruangannya "direset" bersih, siap dipakai lagi dari nol.
Penjelasan teknis: tanpa mongodb-memory-server, test kita mau tidak mau harus konek ke MONGODB_URI yang sama dengan yang kita pakai saat development sejak artikel kedua — artinya setiap kali test dijalankan, data development asli bisa ikut terhapus atau tercampur data dummy dari test. Dengan library ini, Node.js akan men-download dan menjalankan satu instance MongoDB sungguhan secara lokal di memori komputer, lalu memberikan kita sebuah uri khusus yang hanya hidup selama proses test berjalan. Begitu proses test selesai, seluruh data dan "server" itu langsung lenyap tanpa bekas.
Tambahkan script test di package.json:
{
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"test": "jest --runInBand --detectOpenHandles"
}
}
--runInBandmenjalankan test satu per satu (bukan paralel), supaya tidak saling bentrok karena berbagi satu database memori.--detectOpenHandlesmembantu mendeteksi koneksi yang lupa ditutup setelah test selesai.
Penjelasan teknis: secara default, Jest menjalankan file test secara paralel di beberapa proses sekaligus supaya cepat. Tapi karena semua file test kita (nanti) memakai satu koneksi mongodb-memory-server yang sama, menjalankannya paralel bisa membuat satu test menghapus data yang sedang dipakai test lain di saat bersamaan. Flag --runInBand memaksa Jest menjalankan test satu demi satu secara berurutan, mengorbankan sedikit kecepatan demi menghindari race condition semacam itu.
3. Menyiapkan Test Environment
mkdir -p tests
tests/setup.js
const mongoose = require("mongoose");
const { MongoMemoryServer } = require("mongodb-memory-server");
let mongoServer;
// Dijalankan sekali sebelum semua test dimulai: nyalakan MongoDB versi memori
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const uri = mongoServer.getUri();
await mongoose.connect(uri);
});
// Dijalankan setelah setiap test selesai: bersihkan semua collection
afterEach(async () => {
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
});
// Dijalankan sekali setelah semua test selesai: matikan koneksi & server memori
afterAll(async () => {
await mongoose.connection.dropDatabase();
await mongoose.connection.close();
await mongoServer.stop();
});
Penjelasan teknis: file ini memakai tiga lifecycle hook bawaan Jest, masing-masing punya waktu eksekusi yang berbeda:
beforeAllhanya berjalan satu kali, sebelum test pertama di seluruh file dijalankan — cocok untuk hal yang mahal/lambat dan cukup dilakukan sekali, seperti menyalakanmongoServerdan membuka koneksi Mongoose.afterEachberjalan setelah setiap satuit(...)selesai. Bariscollections[key].deleteMany({})mengosongkan seluruh isi collection (tapi strukturnya tetap ada), supaya test berikutnya selalu mulai dari database yang benar-benar kosong — tanpa ini, data sisa dari satu test bisa mengacaukan hasil test lain (misalnya test "should reject duplicate email" bisa gagal kalau ternyata email itu masih tersisa dari test sebelumnya).afterAllberjalan satu kali di akhir, membersihkan seluruh database, menutup koneksi Mongoose, dan mematikan prosesmongoServersupaya tidak ada proses "menggantung" di background setelahnpm testselesai.
jest.config.js (buat di root project)
module.exports = {
testEnvironment: "node",
setupFilesAfterEach: undefined,
setupFilesAfterEnv: ["./tests/setup.js"],
testTimeout: 15000,
};
Penjelasan teknis: baris paling penting di sini adalah setupFilesAfterEnv: ["./tests/setup.js"] — ini yang memberitahu Jest untuk otomatis menjalankan isi tests/setup.js (termasuk beforeAll, afterEach, afterAll di dalamnya) sebelum file test manapun dijalankan, tanpa kita perlu mengimpornya manual satu-satu di setiap file *.test.js. testTimeout: 15000 menaikkan batas waktu tunggu tiap test dari default 5 detik menjadi 15 detik, karena proses menyalakan mongodb-memory-server di beforeAll kadang butuh waktu lebih lama dari biasanya, terutama di percobaan pertama.
Buat juga file .env.test agar konfigurasi seperti JWT_SECRET tetap tersedia saat testing (koneksi database akan tetap dioverride oleh mongodb-memory-server, jadi MONGODB_URI di file ini tidak akan benar-benar dipakai):
.env.test
NODE_ENV=test
JWT_SECRET=test-secret-key-untuk-testing
JWT_EXPIRES_IN=1h
EXTERNAL_API_KEY=test-api-key-untuk-testing
Agar Jest membaca .env.test, install dotenv di file setup (sudah kita install sebelumnya di artikel #2), lalu tambahkan baris berikut di baris paling atas tests/setup.js:
require("dotenv").config({ path: ".env.test" });
4. Menulis Test untuk Endpoint Auth
tests/auth.test.js
const request = require("supertest");
const app = require("../src/app");
describe("Auth Endpoints", () => {
describe("POST /api/auth/register", () => {
it("should register a new user successfully", async () => {
const res = await request(app).post("/api/auth/register").send({
name: "Budi",
email: "budi@example.com",
password: "rahasia123",
});
expect(res.statusCode).toBe(201);
expect(res.body.success).toBe(true);
expect(res.body.data.user.email).toBe("budi@example.com");
expect(res.body.data.token).toBeDefined();
});
it("should reject registration with missing fields", async () => {
const res = await request(app).post("/api/auth/register").send({
email: "budi@example.com",
});
expect(res.statusCode).toBe(400);
expect(res.body.success).toBe(false);
});
it("should reject duplicate email", async () => {
await request(app).post("/api/auth/register").send({
name: "Budi",
email: "budi@example.com",
password: "rahasia123",
});
const res = await request(app).post("/api/auth/register").send({
name: "Budi Kedua",
email: "budi@example.com",
password: "rahasia456",
});
expect(res.statusCode).toBe(409);
expect(res.body.message).toMatch(/already registered/i);
});
});
describe("POST /api/auth/login", () => {
beforeEach(async () => {
await request(app).post("/api/auth/register").send({
name: "Budi",
email: "budi@example.com",
password: "rahasia123",
});
});
it("should login successfully with correct credentials", async () => {
const res = await request(app).post("/api/auth/login").send({
email: "budi@example.com",
password: "rahasia123",
});
expect(res.statusCode).toBe(200);
expect(res.body.data.token).toBeDefined();
});
it("should reject login with wrong password", async () => {
const res = await request(app).post("/api/auth/login").send({
email: "budi@example.com",
password: "password-salah",
});
expect(res.statusCode).toBe(401);
expect(res.body.message).toMatch(/invalid email or password/i);
});
});
});
Penjelasan teknis:
request(app).post("/api/auth/register").send({...})adalah inti dari Supertest — ia membungkusappExpress kita (yang sama persis dengansrc/app.jsyang dipakaiserver.js) dan mengirim request HTTP tanpa benar-benar membuka port jaringan. Ini yang membuat test jauh lebih cepat dibanding kalau kita harus menyalakan server sungguhan dan mengirimcurlbeneran seperti di artikel-artikel sebelumnya.expect(res.statusCode).toBe(201)adalah assertion — pernyataan tentang apa yang seharusnya terjadi. Kalau status code yang benar-benar dikembalikan API bukan 201 (misalnya karena bug membuat controller balas 500), Jest akan menandai test ini gagal dan menunjukkan perbedaan antara nilai yang diharapkan dan nilai sesungguhnya.Blok
describe("POST /api/auth/register", () => {...})di dalamdescribe("Auth Endpoints", ...)adalah nested describe — cara mengelompokkan test yang berhubungan, sehingga saat dijalankan, hasilnya di terminal juga tersusun rapi per kelompok (mirip sub-bab di dalam bab, meneruskan analogi “bab ujian praktik” yang akan kita pakai lagi di bagian berikutnya).Test
"should reject duplicate email"memanggil endpoint register dua kali dengan email yang sama, lalu memastikan percobaan kedua mengembalikan409— ini menguji langsung logikaAppError("Email is already registered", 409)yang kita tulis diauth.service.jspada artikel autentikasi JWT.toMatch(/already registered/i)memakai regex sederhana (hurufiberarti tidak peduli huruf besar/kecil) untuk memastikan pesan errornya benar-benar menyebut hal itu, bukan cuma kebetulan status code-nya sama.
5. Menulis Test untuk Endpoint Todo
Analogi: setiap blok describe di Jest ibarat satu bab dalam soal ujian praktik, dan setiap it(...) di dalamnya adalah satu butir soal spesifik yang harus lolos syarat tertentu. beforeEach ibarat “persiapan alat” yang dilakukan ulang sebelum tiap butir soal dikerjakan, supaya kondisi awalnya selalu bersih dan adil untuk setiap soal.
tests/todo.test.js
const request = require("supertest");
const app = require("../src/app");
async function registerAndLogin(email = "budi@example.com") {
await request(app).post("/api/auth/register").send({
name: "Budi",
email,
password: "rahasia123",
});
const loginRes = await request(app).post("/api/auth/login").send({
email,
password: "rahasia123",
});
return loginRes.body.data.token;
}
describe("Todo Endpoints", () => {
let token;
beforeEach(async () => {
token = await registerAndLogin();
});
describe("POST /api/todos", () => {
it("should create a new todo when authenticated", async () => {
const res = await request(app)
.post("/api/todos")
.set("Authorization", `Bearer ${token}`)
.send({ title: "Belajar Jest dan Supertest" });
expect(res.statusCode).toBe(201);
expect(res.body.data.title).toBe("Belajar Jest dan Supertest");
expect(res.body.data.completed).toBe(false);
});
it("should reject creating todo without authentication", async () => {
const res = await request(app)
.post("/api/todos")
.send({ title: "Tanpa token" });
expect(res.statusCode).toBe(401);
});
it("should reject title shorter than 3 characters", async () => {
const res = await request(app)
.post("/api/todos")
.set("Authorization", `Bearer ${token}`)
.send({ title: "ab" });
expect(res.statusCode).toBe(400);
expect(res.body.message).toMatch(/between 3 and 100/i);
});
});
describe("GET /api/todos", () => {
it("should return paginated todos for the logged-in user", async () => {
// Buat 3 todo terlebih dahulu
for (let i = 1; i <= 3; i++) {
await request(app)
.post("/api/todos")
.set("Authorization", `Bearer ${token}`)
.send({ title: `Todo ke-${i}` });
}
const res = await request(app)
.get("/api/todos?page=1&limit=2")
.set("Authorization", `Bearer ${token}`);
expect(res.statusCode).toBe(200);
expect(res.body.data.length).toBe(2);
expect(res.body.pagination.totalItems).toBe(3);
expect(res.body.pagination.totalPages).toBe(2);
});
it("should not return todos belonging to another user", async () => {
await request(app)
.post("/api/todos")
.set("Authorization", `Bearer ${token}`)
.send({ title: "Todo milik Budi" });
const otherToken = await registerAndLogin("siti@example.com");
const res = await request(app)
.get("/api/todos")
.set("Authorization", `Bearer ${otherToken}`);
expect(res.statusCode).toBe(200);
expect(res.body.data.length).toBe(0);
});
});
describe("DELETE /api/todos/:id", () => {
it("should not allow deleting another user's todo", async () => {
const createRes = await request(app)
.post("/api/todos")
.set("Authorization", `Bearer ${token}`)
.send({ title: "Todo milik Budi" });
const todoId = createRes.body.data._id;
const otherToken = await registerAndLogin("siti@example.com");
const res = await request(app)
.delete(`/api/todos/${todoId}`)
.set("Authorization", `Bearer ${otherToken}`);
expect(res.statusCode).toBe(403);
});
it("should return 404 when deleting a non-existent todo", async () => {
const fakeId = "665f1c2e8b1e2a1a2c3d9999";
const res = await request(app)
.delete(`/api/todos/${fakeId}`)
.set("Authorization", `Bearer ${token}`);
expect(res.statusCode).toBe(404);
});
});
});
Penjelasan teknis:
Fungsi
registerAndLogin(email)dibuat sebagai helper function supaya kita tidak perlu menulis ulang proses register + login di setiap test. Karena database di-reset otomatis lewatafterEachyang sudah kita atur ditests/setup.js, setiap test butuh akun baru yang benar-benar fresh — helper ini membuat kebutuhan itu cukup satu baris:token = await registerAndLogin().beforeEach(async () => { token = await registerAndLogin(); })di leveldescribe("Todo Endpoints", ...)otomatis dijalankan sebelum setiapit(...)di dalam blok ini, bukan cuma sekali di awal. Artinya setiap butir soal (analogi dari bagian sebelumnya) selalu mulai dengan kondisi yang sama: ada satu user yang sudah login dan variabeltokensudah terisi.Test
"should not return todos belonging to another user"secara teknis membuktikan barisTodo.find({ owner: ownerId })ditodo.service.js(dari artikel autentikasi JWT) benar-benar bekerja — dengan cara membuat todo memakai akun Budi, lalu mencoba mengambil daftar todo memakai token akun Siti, dan memastikan hasilnya kosong (data.lengthharus0). Ini adalah contoh test yang menguji aturan bisnis (isolasi data antar-user), bukan cuma "apakah endpoint jalan atau tidak".Test
"should not allow deleting another user's todo"menguji jalur otorisasi yang berbeda: bukan soal data ditemukan atau tidak, tapi soal siapa yang boleh menghapusnya. Response yang diharapkan adalah403 Forbidden, sesuai konvensi status code yang kita tetapkan di artikel error handling — membedakannya dari404 Not Foundpada test setelahnya, yang menguji skenario ketika ID todo memang tidak ada sama sekali di database.
6. Menulis Test untuk Endpoint Stats (API Key)
tests/stats.test.js
const request = require("supertest");
const app = require("../src/app");
describe("Stats Endpoint (API Key)", () => {
it("should reject request without API key", async () => {
const res = await request(app).get("/api/stats/summary");
expect(res.statusCode).toBe(401);
expect(res.body.message).toMatch(/api key is missing/i);
});
it("should reject request with invalid API key", async () => {
const res = await request(app)
.get("/api/stats/summary")
.set("x-api-key", "kunci-ngasal");
expect(res.statusCode).toBe(401);
expect(res.body.message).toMatch(/invalid api key/i);
});
it("should return summary with valid API key", async () => {
const res = await request(app)
.get("/api/stats/summary")
.set("x-api-key", process.env.EXTERNAL_API_KEY);
expect(res.statusCode).toBe(200);
expect(res.body.data).toHaveProperty("totalTodos");
expect(res.body.data).toHaveProperty("completedTodos");
expect(res.body.data).toHaveProperty("pendingTodos");
});
});
Penjelasan teknis:
.set("x-api-key", process.env.EXTERNAL_API_KEY)mengambil nilai API key langsung dariprocess.env, yang sudah tersedia karena.env.testdi-load lewatdotenv.config({ path: ".env.test" })ditests/setup.js. Dengan begini, kita tidak perlu menulis ulang nilai API key secara hardcode di file test — kalau nanti key-nya berubah, cukup update satu tempat di.env.test.toHaveProperty("totalTodos")berbeda dengantoBe(...)yang kita pakai di test lain — assertion ini hanya memastikan field-nya ada di response, tanpa mengecek nilai persisnya. Ini cocok untuk data yang nilainya bisa berubah-ubah tergantung berapa banyak todo yang sudah dibuat di test sebelumnya, sehingga yang penting dipastikan bukan "harus persis segini", tapi "struktur response-nya harus benar".Ketiga test di file ini secara langsung memvalidasi tiga baris pengecekan yang ada di
apiKey.middleware.jspada artikel proteksi API Key: request tanpa headerx-api-key, request dengan key yang salah, dan request dengan key yang benar — masing-masing dites terpisah supaya kalau salah satu logikanya rusak di kemudian hari, kita langsung tahu persis skenario mana yang gagal.
7. Sedikit Penyesuaian: Memisahkan app dan server
Perhatikan bahwa semua file test mengimpor ../src/app, bukan ../src/server. Ini disengaja — app.js hanya berisi konfigurasi Express tanpa benar-benar membuka port, sedangkan server.js yang bertugas memanggil app.listen(). Supertest tidak butuh port terbuka sungguhan; dia bisa mengirim request langsung ke objek app di memori. Karena struktur folder kita dari artikel pertama sudah memisahkan app.js dan server.js sejak awal, kita tidak perlu mengubah apa pun di sini — ini salah satu manfaat nyata dari struktur folder yang rapi sejak awal.
8. Struktur Folder Setelah Artikel Ini
todo-api/
├── src/
│ ├── config/
│ │ └── db.js
│ ├── middlewares/
│ │ ├── logger.middleware.js
│ │ ├── notFound.middleware.js
│ │ ├── errorHandler.middleware.js
│ │ ├── auth.middleware.js
│ │ ├── restrictTo.middleware.js
│ │ ├── apiKey.middleware.js
│ │ └── validate.middleware.js
│ ├── validators/
│ │ └── todo.validator.js
│ ├── models/
│ │ ├── todo.model.js
│ │ └── user.model.js
│ ├── services/
│ │ ├── todo.service.js
│ │ └── auth.service.js
│ ├── controllers/
│ │ ├── todo.controller.js
│ │ ├── auth.controller.js
│ │ └── stats.controller.js
│ ├── routes/
│ │ ├── todo.routes.js
│ │ ├── auth.routes.js
│ │ └── stats.routes.js
│ ├── utils/
│ │ ├── AppError.js
│ │ └── catchAsync.js
│ ├── app.js
│ └── server.js
├── tests/
│ ├── setup.js
│ ├── auth.test.js
│ ├── todo.test.js
│ └── stats.test.js
├── jest.config.js
├── .env
├── .env.test
├── .env.example
├── .gitignore
└── package.json
9. Menjalankan Test
npm test
Kalau semua berjalan lancar, kamu akan melihat output seperti ini di terminal:
PASS tests/auth.test.js
PASS tests/stats.test.js
PASS tests/todo.test.js
Test Suites: 3 passed, 3 total
Tests: 15 passed, 15 total
Snapshots: 0 total
Time: 6.245 sPenjelasan teknis: Test Suites mengacu ke jumlah file test (di sini: auth.test.js, todo.test.js, stats.test.js), sedangkan Tests mengacu ke jumlah total butir it(...) di semua file itu digabung — sesuai analogi "bab" vs "butir soal" di awal artikel ini. Kalau salah satu expect(...) gagal, Jest tidak langsung menghentikan seluruh proses; ia akan tetap menjalankan sisa test lainnya, lalu di akhir menampilkan ringkasan lengkap mana saja yang PASS dan mana yang FAIL, sehingga kamu bisa melihat gambaran utuh kondisi API dalam satu kali jalan, bukan berhenti di kegagalan pertama seperti proses debugging manual dengan curl.
Kalau ada test yang gagal, Jest akan menunjukkan dengan jelas expect mana yang tidak sesuai, lengkap dengan nilai yang diharapkan vs nilai yang didapat — jauh lebih mudah dilacak dibanding membaca response curl satu-satu secara manual.
10. Perbandingan Sebelum dan Sesudah
Aspek | Sebelum (Manual dengan curl) | Sesudah (Jest + Supertest) |
|---|---|---|
Cara menguji | Ketik curl satu-satu, baca manual | Jalankan satu perintah: npm test |
Konsistensi | Rawan lupa kasus tertentu | Semua skenario tercatat dan dites otomatis tiap kali |
Kecepatan regresi | Lambat, harus diulang manual | Cepat, seluruh test suite jalan dalam hitungan detik |
Database saat testing | Memakai database development (berisiko) | Memakai MongoDB di memori, terisolasi & otomatis bersih |
Dokumentasi perilaku | Tidak ada catatan formal | File test menjelaskan perilaku API secara eksplisit |
11. Kesimpulan
Dengan Jest dan Supertest, Todo List API kita sekarang punya automated test suite yang mencakup skenario auth, CRUD todo, kepemilikan data, dan proteksi API key — semuanya bisa dijalankan ulang kapan saja hanya dengan npm test, tanpa risiko menyentuh data development sungguhan berkat mongodb-memory-server. Ini adalah pondasi penting sebelum kamu terjun ke proyek nyata, karena tim profesional hampir selalu mewajibkan automated test sebelum kode di-merge.
Automated test ini bagus untuk memastikan logika kode berjalan benar, tapi tim frontend atau QA manual biasanya tetap butuh cara mudah untuk mencoba API secara interaktif dan melihat dokumentasi endpoint yang tersedia. Artikel selanjutnya akan membahas cara melakukan testing manual terstruktur dengan Postman dan mendokumentasikan API dengan Swagger.
Lanjutkan ke artikel berikutnya: Testing dan Dokumentasi API dengan Postman dan Swagger untuk Pemula
Artikel Terkait
Panduan Membuat Animasi Animated on Scroll di Website Anda
Pelajari cara membuat animasi menarik pada scroll di website Anda dengan tutorial ini. Tampilkan konten dengan cara yang lebih interaktif!
Cara Menangani Error dan HTTP Status Code yang Benar
cara menentukan HTTP status code yang benar untuk tiap jenis error, dan cara membuat custom Error
Cara Restrukturisasi CRUD Node.js: Dari Function ke Folder Controller-Service-Model
cara merestrukturisasi kode CRUD dari kumpulan function menjadi struktur folder yang rapi menggunakan pola Controller-Service-Model.
