"Test bukan untuk membuktikan kode kamu benar. Test untuk membuktikan kode kamu masih benar — setelah diubah orang lain, termasuk dirimu sendiri enam bulan lagi."
Tentang Artikel Ini
Artikel ini melanjutkan toko-api yang sudah kita bangun di artikel sebelumnya — kali ini kita tulis test untuk API itu, supaya "jalan di komputer saya" berubah jadi "terbukti jalan, dan akan ketahuan kalau suatu saat rusak."
Kita tidak akan membahas teori testing yang abstrak. Semua contoh di sini menguji kode yang sudah kamu punya — ProductService, endpoint /api/v1/products, middleware authenticate, dan schema Zod — jadi kamu bisa langsung salin ke project yang sama.
Setelah selesai, kamu akan bisa:
- Menulis unit test untuk business logic dengan Jest, tanpa menyentuh database asli
- Menulis integration test untuk endpoint API dengan Supertest
- Menyiapkan database khusus testing yang aman dan terisolasi
- Mock dependency eksternal (database, JWT) dengan benar di project ES Modules
- Membaca test coverage dan tahu angka mana yang layak dikejar, mana yang tidak
- Menjalankan test otomatis lewat CI setiap kali ada push
Prasyarat: Sudah menyelesaikan artikel Membangun REST API dengan Node.js & Express — kita lanjutkan project toko-api yang sama persis. Familiar dengan async/await dan Promise.
Daftar Isi
- Kenapa Testing Penting & Piramida Testing
- Setup Jest di Project ES Modules
- Unit Test: Menguji Service Layer
- Test Database: Isolasi dari Data Asli
- Integration Test dengan Supertest
- Testing Middleware: Auth & Validasi
- Test Coverage & Kebiasaan Baik
- CI-Ready: Menjalankan Test Otomatis
Bab 1: Kenapa Testing Penting & Piramida Testing
Masalah yang Testing Selesaikan
Tanpa test, cara kamu tahu API masih berfungsi setelah mengubah kode adalah: buka Postman, klik satu-satu, lihat responnya, ulangi. Itu bekerja untuk 5 endpoint. Tidak bekerja untuk 50 — kamu akan berhenti mengecek endpoint yang "kelihatannya tidak berkaitan" dengan perubahanmu, dan di situlah bug regresi lahir.
Test menggantikan proses manual itu dengan sesuatu yang bisa dijalankan dalam hitungan detik, konsisten setiap kali, dan tidak lupa mengecek edge case yang kamu sendiri lupa pernah kamu tangani.
Piramida Testing
▲
/e2e\ Sedikit, lambat, paling realistis
/------\ (browser + API + database sungguhan)
/ integ. \ Sedang — API + database asli, tanpa browser
/------------\
/ unit test \ Banyak, cepat, terisolasi dari dependency luar
/------------------\
Tiga level ini berbeda di apa yang dianggap "asli" dan apa yang di-mock:
| Level | Yang diuji | Database? | Kecepatan |
|---|---|---|---|
| Unit | Satu function/module, terisolasi | Di-mock | Milidetik |
| Integration | Beberapa modul bekerja sama (route → controller → service → DB) | Asli (tapi database test) | Detik |
| End-to-end | Seluruh sistem, termasuk frontend | Asli | Puluhan detik+ |
Artikel ini fokus di unit dan integration — dua level yang paling actionable untuk backend-only project seperti toko-api. E2E baru masuk akal setelah ada frontend yang menyatu dengan API ini.
Aturan praktis: Banyak unit test (murah, cepat, jalankan terus saat coding), integration test secukupnya untuk jalur kritis (checkout, auth, create/update data), e2e hanya untuk skenario yang benar-benar menentukan bisnis.
Bab 2: Setup Jest di Project ES Modules
Install Dependencies
npm install -D jest supertestJest dan ES Modules: Satu Hal yang Harus Diketahui dari Awal
toko-api pakai "type": "module" di package.json — artinya kita pakai import/export, bukan require. Jest secara default dirancang untuk CommonJS, jadi perlu satu flag tambahan supaya dia mengerti ES Modules native tanpa transpiler:
// package.json
{
"scripts": {
"test": "node --experimental-vm-modules node_modules/.bin/jest",
"test:watch": "node --experimental-vm-modules node_modules/.bin/jest --watch",
"test:coverage": "node --experimental-vm-modules node_modules/.bin/jest --coverage"
}
}Windows: Perintah di atas jalan langsung di macOS/Linux. Kalau tim kamu ada yang pakai Windows dan mengeluh script gagal, itu bukan bug di kode — install
cross-envdan bungkus variabel environment-nya. Untuk--experimental-vm-modulesyang diteruskan lewat argumennode, biasanya tidak perlucross-envkarena bukan environment variable, tapi tetap jalankan tim-mu di WSL kalau memungkinkan.
jest.config.js
// jest.config.js
export default {
testEnvironment: 'node',
transform: {}, // tidak perlu Babel — kode kita sudah ES Modules native
testMatch: ['**/tests/**/*.test.js'],
collectCoverageFrom: [
'src/**/*.js',
'!src/server.js', // entry point, bukan logic yang perlu di-test
],
coverageThreshold: {
global: { branches: 70, functions: 70, lines: 70, statements: 70 },
},
};Struktur Folder Test
toko-api/
├── src/
│ └── ... (sudah ada dari artikel sebelumnya)
├── tests/
│ ├── unit/
│ │ ├── product.service.test.js
│ │ └── auth.middleware.test.js
│ ├── integration/
│ │ └── product.routes.test.js
│ └── helpers/
│ └── db.js
├── jest.config.js
└── package.json
Sanity Check
Sebelum menulis test sungguhan, pastikan setup-nya jalan:
// tests/unit/sanity.test.js
describe('Jest setup', () => {
it('bisa menjalankan test dasar', () => {
expect(1 + 1).toBe(2);
});
});npm test
# PASS tests/unit/sanity.test.js
# Jest setup
# ✓ bisa menjalankan test dasar (2 ms)Kalau ini jalan, setup sudah benar. Hapus file ini setelah dipastikan bekerja.
Bab 3: Unit Test: Menguji Service Layer
Kenapa Service Layer Dulu
ProductService (dari artikel sebelumnya) berisi business logic murni — tidak tahu soal req/res, hanya menerima parameter dan memanggil pool.query. Ini yang membuatnya paling mudah dan paling berharga untuk di-unit-test: tidak perlu HTTP, tidak perlu database asli, cukup pastikan logikanya benar.
Mocking ES Modules: Beda dengan CommonJS
Di project CommonJS, jest.mock('../db/pool.js') cukup diletakkan di atas file test dan otomatis "hoisted" sebelum import lain jalan. Di ES Modules, import di-resolve lebih dulu sebelum baris kode apapun sempat jalan — jadi cara lama itu tidak bekerja. Gunakan jest.unstable_mockModule(), lalu import modul yang di-test secara dinamis setelah mock terpasang:
// tests/unit/product.service.test.js
import { jest } from '@jest/globals';
// 1. Daftarkan mock dulu, sebelum import apapun yang bergantung padanya
const mockQuery = jest.fn();
jest.unstable_mockModule('../../src/db/pool.js', () => ({
default: { query: mockQuery },
}));
// 2. Baru import module yang di-test — pakai dynamic import
const { ProductService } = await import('../../src/services/product.service.js');
describe('ProductService', () => {
beforeEach(() => {
mockQuery.mockReset(); // bersihkan mock antar test, jangan sampai bocor ke test lain
});
describe('findById', () => {
it('mengembalikan produk kalau ditemukan', async () => {
// Arrange
mockQuery.mockResolvedValueOnce({
rows: [{ id: 1, name: 'Kopi Arabika', price: '45000.00' }],
});
// Act
const result = await ProductService.findById(1);
// Assert
expect(result).toEqual({ id: 1, name: 'Kopi Arabika', price: '45000.00' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('WHERE p.id = $1'),
[1]
);
});
it('mengembalikan null kalau produk tidak ada', async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });
const result = await ProductService.findById(999);
expect(result).toBeNull();
});
});
describe('create', () => {
it('membuat produk baru dan mengembalikan row yang di-insert', async () => {
const input = { name: 'Teh Hijau', description: null, price: 25000, stock: 10, sku: 'TH-01', categoryId: 2 };
mockQuery.mockResolvedValueOnce({ rows: [{ id: 5, ...input }] });
const result = await ProductService.create(input);
expect(result.id).toBe(5);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO products'),
[input.name, input.description, input.price, input.stock, input.sku, input.categoryId]
);
});
});
describe('delete', () => {
it('melempar error kalau produk tidak ditemukan', async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });
await expect(ProductService.delete(999)).rejects.toThrow('Produk tidak ditemukan');
});
});
});Pola AAA: Arrange, Act, Assert
Perhatikan setiap test di atas mengikuti tiga langkah yang sama:
- Arrange — siapkan kondisi awal (di sini: apa yang dikembalikan mock)
- Act — jalankan kode yang di-test
- Assert — periksa hasilnya sesuai ekspektasi
Struktur ini membuat test mudah dibaca bahkan oleh orang yang belum pernah lihat kodenya. Kalau sebuah test butuh lebih dari beberapa baris di bagian Arrange, pertimbangkan pindahkan ke helper function (dibahas di Bab 7).
Matcher yang Sering Dipakai
expect(value).toBe(primitif) // perbandingan === (angka, string, boolean)
expect(obj).toEqual(objLain) // perbandingan deep-equal (object, array)
expect(fn).toHaveBeenCalledWith(...args) // mock function dipanggil dengan argumen tertentu
expect(fn).toHaveBeenCalledTimes(2) // mock function dipanggil berapa kali
expect(value).toBeNull()
expect(promise).resolves.toEqual(...) // untuk assert Promise yang resolve
expect(promise).rejects.toThrow('pesan') // untuk assert Promise yang rejectBab 4: Test Database: Isolasi dari Data Asli
Kenapa Mock Saja Tidak Cukup
Unit test di Bab 3 memastikan ProductService memanggil query yang benar secara logika — tapi mock tidak akan pernah menangkap bug seperti kolom yang salah nama, constraint yang dilanggar, atau tipe data yang tidak cocok dengan schema. Untuk itu, integration test perlu database PostgreSQL asli — bukan mock, tapi juga bukan database production kamu.
Database Test Terpisah
# .env.test — JANGAN pernah isi dengan DATABASE_URL production
NODE_ENV=test
DATABASE_URL=postgresql://postgres:password@localhost:5432/toko_db_test
JWT_SECRET=test-secret-key-minimal-32-karakter-panjang// src/config/index.js (tambahan dari artikel sebelumnya)
import 'dotenv/config';
// Load .env.test kalau NODE_ENV=test, timpa .env biasa
if (process.env.NODE_ENV === 'test') {
const { config: loadEnv } = await import('dotenv');
loadEnv({ path: '.env.test', override: true });
}Guard wajib: Tambahkan pengecekan di helper reset database di bawah — kalau
NODE_ENVbukan'test', lempar error dan hentikan proses. Sekali saja test-mu salah connect ke database production dan menjalankanTRUNCATE, itu cukup untuk jadi insiden yang tidak lucu.
Helper Reset Database
// tests/helpers/db.js
import pool from '../../src/db/pool.js';
export async function resetDatabase() {
if (process.env.NODE_ENV !== 'test') {
throw new Error('resetDatabase() hanya boleh jalan saat NODE_ENV=test — dicegah demi keamanan.');
}
// RESTART IDENTITY: reset auto-increment id juga, biar test bisa asumsikan id mulai dari 1
// CASCADE: hapus juga row di tabel yang punya foreign key ke sini
await pool.query('TRUNCATE TABLE products, categories RESTART IDENTITY CASCADE');
}
export async function seedCategory(overrides = {}) {
const result = await pool.query(
`INSERT INTO categories (name) VALUES ($1) RETURNING *`,
[overrides.name ?? 'Minuman']
);
return result.rows[0];
}
export async function closeDatabase() {
await pool.end();
}Menjalankan Migration ke Database Test
Sebelum test pertama kali jalan, database test butuh schema yang sama seperti production:
# Buat database test sekali saja
createdb toko_db_test
# Jalankan migration yang sama seperti production, tapi target ke DB test
psql toko_db_test -f migrations/001_create_categories.sql
psql toko_db_test -f migrations/002_create_products.sqlBab 5: Integration Test dengan Supertest
Kenapa app.js Dipisah dari server.js Sekarang Terbayar
Di artikel sebelumnya kita pisahkan app.js (setup Express, tanpa listen()) dari server.js (yang memanggil listen()). Keputusan itu bukan sekadar rapi-rapi kode — ini yang membuat Supertest bisa langsung memakai app tanpa benar-benar membuka port jaringan:
// tests/integration/product.routes.test.js
import request from 'supertest';
import app from '../../src/app.js';
import { resetDatabase, seedCategory, closeDatabase } from '../helpers/db.js';
describe('POST /api/v1/products', () => {
beforeEach(async () => {
await resetDatabase();
});
afterAll(async () => {
await closeDatabase();
});
it('membuat produk baru dan mengembalikan status 201', async () => {
const category = await seedCategory({ name: 'Minuman' });
const response = await request(app)
.post('/api/v1/products')
.send({
name: 'Kopi Arabika',
price: 45000,
stock: 20,
categoryId: category.id,
});
expect(response.status).toBe(201);
expect(response.body).toEqual({
success: true,
data: expect.objectContaining({
id: expect.any(Number),
name: 'Kopi Arabika',
}),
});
});
it('menolak request tanpa nama produk dengan status 400', async () => {
const response = await request(app)
.post('/api/v1/products')
.send({ price: 45000 }); // name sengaja dihilangkan
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({ field: 'name' }),
])
);
});
});
describe('GET /api/v1/products/:id', () => {
beforeEach(async () => {
await resetDatabase();
});
afterAll(async () => {
await closeDatabase();
});
it('mengembalikan 404 kalau produk tidak ada', async () => {
const response = await request(app).get('/api/v1/products/999');
expect(response.status).toBe(404);
expect(response.body.message).toBe('Produk tidak ditemukan');
});
});beforeEach vs beforeAll: Kapan Pakai yang Mana
beforeAll(async () => { /* jalan sekali, sebelum semua test di describe ini */ });
beforeEach(async () => { /* jalan sebelum SETIAP test — reset state di sini */ });
afterEach(async () => { /* jalan setelah SETIAP test */ });
afterAll(async () => { /* jalan sekali, setelah semua test selesai — tutup koneksi di sini */ });Aturan yang dipakai di atas: resetDatabase() di beforeEach, bukan beforeAll — supaya setiap test mulai dari kondisi database yang bersih dan tidak bergantung pada urutan eksekusi test lain. closeDatabase() cukup sekali di afterAll, karena koneksi pool bisa dipakai ulang antar test dalam file yang sama.
Jebakan umum: Kalau lupa
afterAll(closeDatabase), Jest akan menggantung setelah semua test selesai (dan biasanya keluar peringatan "Jest did not exit one second after the test run"). Connection pool yang masih terbuka adalah penyebab paling sering.
Bab 6: Testing Middleware: Auth & Validasi
Unit Test Middleware Tanpa HTTP Sungguhan
Middleware Express hanyalah function (req, res, next) — kamu bisa test langsung dengan objek palsu, tanpa perlu menjalankan server:
// tests/unit/auth.middleware.test.js
import { jest } from '@jest/globals';
import jwt from 'jsonwebtoken';
import { authenticate } from '../../src/middleware/auth.middleware.js';
describe('authenticate middleware', () => {
const mockRes = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};
it('menolak request tanpa header Authorization', () => {
const req = { headers: {} };
const res = mockRes();
const next = jest.fn();
authenticate(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it('menolak token yang tidak valid', () => {
const req = { headers: { authorization: 'Bearer token-palsu' } };
const res = mockRes();
const next = jest.fn();
authenticate(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it('meneruskan request dan set req.user kalau token valid', () => {
process.env.JWT_SECRET = 'test-secret-key-minimal-32-karakter-panjang';
const token = jwt.sign({ id: 1, email: 'user@test.com', role: 'admin' }, process.env.JWT_SECRET);
const req = { headers: { authorization: `Bearer ${token}` } };
const res = mockRes();
const next = jest.fn();
authenticate(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(req.user).toMatchObject({ id: 1, email: 'user@test.com' });
});
});Integration Test: Endpoint yang Diproteksi
// tests/integration/product.routes.test.js (tambahan)
describe('DELETE /api/v1/products/:id — tanpa auth', () => {
it('mengembalikan 401 kalau tidak ada token', async () => {
const response = await request(app).delete('/api/v1/products/1');
expect(response.status).toBe(401);
});
});Unit Test Zod Schema Langsung
Schema validasi bisa di-test tanpa menyentuh HTTP sama sekali — cukup panggil .safeParse():
// tests/unit/product.schema.test.js
import { createProductSchema } from '../../src/schemas/product.schema.js';
describe('createProductSchema', () => {
it('menolak harga negatif', () => {
const result = createProductSchema.safeParse({ name: 'Produk A', price: -1000 });
expect(result.success).toBe(false);
expect(result.error.errors[0].path).toEqual(['price']);
});
it('menerima payload yang valid', () => {
const result = createProductSchema.safeParse({ name: 'Produk A', price: 10000 });
expect(result.success).toBe(true);
});
});Test seperti ini jauh lebih cepat daripada mengirim request HTTP tiap kali kamu mengubah aturan validasi — pakai untuk mengecek setiap kombinasi kasus edge (harga nol, nama kosong, SKU dengan karakter aneh), baru pakai integration test untuk memastikan schema itu benar-benar terpasang di route yang tepat.
Unit Test Global Error Handler
errorHandler (dari artikel sebelumnya) yang menerjemahkan berbagai jenis error — AppError custom, error code PostgreSQL, error tak terduga — menjadi response JSON yang konsisten, juga layak di-unit-test langsung, tanpa perlu memicu error sungguhan lewat HTTP:
// tests/unit/error.middleware.test.js
import { jest } from '@jest/globals';
import { errorHandler } from '../../src/middleware/error.middleware.js';
import { NotFoundError } from '../../src/utils/AppError.js';
describe('errorHandler middleware', () => {
const mockRes = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};
it('mengembalikan statusCode dan message dari AppError', () => {
const err = new NotFoundError('Produk');
const res = mockRes();
errorHandler(err, {}, res, jest.fn());
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ success: false, message: 'Produk tidak ditemukan' })
);
});
it('menerjemahkan unique_violation PostgreSQL (23505) jadi 409', () => {
const err = { code: '23505', message: 'duplicate key value' };
const res = mockRes();
errorHandler(err, {}, res, jest.fn());
expect(res.status).toHaveBeenCalledWith(409);
});
it('mengembalikan 500 generik untuk error tak dikenal, tanpa membocorkan detail', () => {
const err = new Error('Koneksi database gagal di internal');
const res = mockRes();
process.env.NODE_ENV = 'production';
errorHandler(err, {}, res, jest.fn());
expect(res.status).toHaveBeenCalledWith(500);
const responseBody = res.json.mock.calls[0][0];
expect(responseBody.detail).toBeUndefined(); // detail internal tidak boleh bocor ke client
});
});Test terakhir di atas menguji sesuatu yang mudah terlewat kalau cuma mengandalkan pengecekan manual: pastikan pesan error internal tidak pernah ikut terkirim ke client saat NODE_ENV=production. Ini persis jenis regresi yang gampang lolos review manual tapi langsung ketahuan kalau ada test untuknya.
Bab 7: Test Coverage & Kebiasaan Baik
Membaca Coverage Report
npm run test:coverage--------------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
--------------------------|---------|----------|---------|---------|
All files | 78.42 | 71.05 | 80.00 | 78.12 |
services/product.service | 92.30 | 85.71 | 100.00 | 92.30 |
middleware/auth | 88.88 | 75.00 | 100.00 | 88.88 |
middleware/validate | 45.00 | 30.00 | 50.00 | 45.00 |
--------------------------|---------|----------|---------|---------|
Jest juga menghasilkan coverage/lcov-report/index.html — buka di browser untuk lihat baris mana yang belum ter-cover, ditandai merah.
Coverage tinggi bukan jaminan bebas bug. Kode bisa "ter-cover" (baris-nya dieksekusi) tanpa assertion-nya benar-benar menguji apapun yang berarti. 100% coverage dengan test yang asal-asalan lebih berbahaya daripada 70% coverage dengan test yang benar-benar memvalidasi behavior — karena yang pertama memberi rasa aman palsu. Kejar coverage di jalur-jalur kritis (payment, auth, perhitungan harga), jangan kejar angka.
Kebiasaan yang Membuat Test Awet
1. Satu behavior per test. Test yang menguji lima hal sekaligus akan gagal dengan pesan error yang tidak jelas menunjuk ke mana masalahnya.
// ✗ Kurang baik — kalau gagal, bagian mana yang salah?
it('produk berhasil dibuat', async () => {
const res = await request(app).post('/api/v1/products').send(validPayload);
expect(res.status).toBe(201);
expect(res.body.data.name).toBe('Kopi Arabika');
expect(res.body.data.stock).toBe(20);
// ...5 assertion lain
});
// ✓ Lebih baik — tiap test punya satu alasan untuk gagal
it('mengembalikan status 201 saat berhasil', async () => { /* ... */ });
it('mengembalikan data produk yang baru dibuat', async () => { /* ... */ });Dalam praktik, beberapa assertion yang berkaitan erat (seperti contoh pertama) masih wajar digabung — aturan ini soal menghindari satu test yang mencoba menguji modul alih-alih satu behavior.
2. Test data lewat factory, bukan copy-paste. Kalau kamu menulis object produk yang sama di lima file test, pindahkan ke helper:
// tests/factories/product.factory.js
export function buildProductPayload(overrides = {}) {
return {
name: 'Produk Test',
price: 10000,
stock: 5,
...overrides,
};
}// Di test:
const payload = buildProductPayload({ price: -1000 }); // hanya override yang relevan3. Nama test menjelaskan behavior, bukan implementasi. it('menolak harga negatif') bertahan meski kamu ganti Zod dengan library validasi lain. it('memanggil safeParse dengan benar') akan rusak duluan meski behavior-nya tidak berubah — karena dia menguji cara kerja, bukan hasil.
4. Jangan test hal yang sudah dijamin library-nya. Tidak perlu test bahwa bcrypt.compare() bekerja — itu tanggung jawab bcrypt. Test bahwa kodemu memanggilnya dengan benar dan bereaksi sesuai hasilnya.
Bab 8: CI-Ready: Menjalankan Test Otomatis
Kenapa Ini Penting
Test yang cuma dijalankan manual di laptop developer gampang terlewat — orang lupa, buru-buru, atau merasa "perubahannya kecil, pasti aman". Test yang jalan otomatis setiap ada push tidak punya masalah itu.
GitHub Actions Minimal
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: password
POSTGRES_DB: toko_db_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Jalankan migration
run: |
psql -h localhost -U postgres -d toko_db_test -f migrations/001_create_categories.sql
psql -h localhost -U postgres -d toko_db_test -f migrations/002_create_products.sql
env:
PGPASSWORD: password
- name: Jalankan test
run: npm test
env:
NODE_ENV: test
DATABASE_URL: postgresql://postgres:password@localhost:5432/toko_db_test
JWT_SECRET: ci-test-secret-key-minimal-32-karakterSetiap push atau pull request ke main sekarang otomatis: nyalakan PostgreSQL sungguhan di container, jalankan migration, lalu jalankan seluruh test suite. Kalau ada yang gagal, PR tidak boleh di-merge (aktifkan branch protection rule di GitHub kalau mau ini dipaksakan, bukan sekadar disarankan).
Batas scope artikel ini: Workflow di atas cukup untuk memastikan kode yang benar yang masuk ke
main. Pipeline yang mendeploy otomatis setelah test lolos — build image, push ke registry, restart service di VPS — itu topik tersendiri yang dibahas tuntas di artikel: CI/CD untuk Backend Developer dengan GitHub Actions.
Penutup
toko-api sekarang punya jaring pengaman. Berikut yang sudah ditambahkan:
Struktur Project Final
toko-api/
├── src/ # (sudah ada dari artikel sebelumnya)
├── tests/
│ ├── unit/
│ │ ├── product.service.test.js
│ │ ├── auth.middleware.test.js
│ │ └── product.schema.test.js
│ ├── integration/
│ │ └── product.routes.test.js
│ ├── factories/
│ │ └── product.factory.js
│ └── helpers/
│ └── db.js
├── .github/
│ └── workflows/
│ └── test.yml
├── .env.test
├── jest.config.js
└── package.json
Checklist Sebelum Bilang API-mu "Sudah Ditest"
- Service layer punya unit test untuk kasus sukses dan kasus gagal (produk tidak ditemukan, input invalid)
- Setiap endpoint punya minimal satu integration test untuk jalur sukses
- Endpoint yang butuh auth punya test untuk kasus tanpa token dan token invalid
- Database test terpisah total dari database production — dicek dengan guard di
resetDatabase() -
afterAllmenutup connection pool, supaya Jest tidak menggantung - Test jalan otomatis di CI setiap push, bukan cuma di laptop
- Coverage dicek di jalur kritis (auth, create/update data, payment kalau ada) — bukan dikejar sebagai angka semata
Langkah selanjutnya:
- Deploy — bawa API yang sudah teruji ini ke VPS sungguhan (artikel deploy-vps)
- CI/CD penuh — otomatiskan deployment setelah test lolos, bukan cuma testing-nya (artikel cicd-github-actions)
Kode test lengkap untuk project ini akan tersedia di repository publik — tautannya menyusul.