Unit Test #
Unit testing adalah fondasi dari software yang dapat diandalkan — ia memberikan jaring pengaman yang memungkinkan kamu merefaktor kode dengan percaya diri, mendokumentasikan perilaku yang diharapkan secara executable, dan mendeteksi regresi sebelum sampai ke produksi. TypeScript membawa dimensi baru ke unit testing: karena compiler sudah menangkap banyak bug tipe saat kompilasi, unit test di TypeScript bisa fokus pada perilaku dan logika bisnis, bukan pada “apakah fungsi ini menerima tipe yang benar?” Tapi TypeScript juga membutuhkan konfigurasi tambahan — test runner perlu memahami TypeScript, dan type definitions Jest/Vitest perlu tersedia. Artikel ini membahas setup yang benar, cara menulis test yang bermakna, dan prinsip-prinsip yang membedakan test suite yang membantu dari yang menjadi beban.
Piramida Testing — Kapan Pakai Unit Test #
flowchart TD
A[Piramida Testing] --> E2E[E2E Tests\nSedikit, lambat, mahal\nSelenium, Playwright, Cypress]
A --> INT[Integration Tests\nSedang, menegah\nSupertest, database nyata]
A --> UNIT[Unit Tests\nBanyak, cepat, murah\nJest, Vitest]
E2E --> E2E_CAP[Uji alur pengguna\nend-to-end secara nyata]
INT --> INT_CAP[Uji interaksi antar komponen\nHTTP, database, cache]
UNIT --> UNIT_CAP[Uji satu unit terisolasi\nfungsi, kelas, service]
style UNIT fill:#51cf66,color:#fff
style INT fill:#339af0,color:#fff
style E2E fill:#fcc419,color:#000Unit test adalah lapisan terbawah dan terbesar — banyak, cepat, dan murah untuk ditulis dan dijalankan. Fokus pada satu unit (fungsi atau kelas) secara terisolasi tanpa dependensi eksternal nyata.
Setup: Jest dengan TypeScript #
Jest adalah test runner paling populer di ekosistem TypeScript:
npm install --save-dev jest ts-jest @types/jest
// jest.config.ts — konfigurasi Jest yang type-safe
import type { Config } from "jest";
const config: Config = {
preset: "ts-jest",
testEnvironment: "node",
roots: ["<rootDir>/src"],
testMatch: ["**/__tests__/**/*.ts", "**/*.test.ts", "**/*.spec.ts"],
transform: {
"^.+\\.tsx?$": ["ts-jest", {
tsconfig: "tsconfig.test.json", // Gunakan tsconfig khusus test
}],
},
collectCoverageFrom: [
"src/**/*.ts",
"!src/**/*.d.ts",
"!src/**/__tests__/**",
"!src/index.ts", // Entry point biasanya tidak perlu ditest
],
coverageThresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
clearMocks: true, // Reset mock state antar test
restoreMocks: true, // Restore spy asli setelah setiap test
};
export default config;
// tsconfig.test.json — override untuk test environment
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"types": ["jest", "node"]
},
"include": ["src/**/*", "**/*.test.ts", "**/*.spec.ts"]
}
Alternatif: Vitest (Lebih Cepat) #
Jika proyek menggunakan Vite, Vitest adalah pilihan yang lebih cepat dengan API yang kompatibel dengan Jest:
npm install --save-dev vitest @vitest/coverage-v8
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
coverage: {
provider: "v8",
reporter: ["text", "lcov", "html"],
thresholds: { lines: 80, functions: 80 },
},
clearMocks: true,
restoreMocks: true,
},
});
// package.json scripts
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --ci --coverage --forceExit"
}
}
Anatomi Test yang Baik — Pola AAA #
Setiap test yang baik mengikuti pola Arrange-Act-Assert (AAA):
// src/utils/hitung.ts — fungsi yang akan ditest
export function hitungDiskon(
harga: number,
persenDiskon: number,
batasDiskon: number = Infinity
): number {
if (harga < 0) throw new RangeError("Harga tidak boleh negatif");
if (persenDiskon < 0 || persenDiskon > 100) {
throw new RangeError("Persen diskon harus antara 0-100");
}
const nilaiDiskon = Math.min(harga * (persenDiskon / 100), batasDiskon);
return Math.round(harga - nilaiDiskon);
}
// src/utils/hitung.test.ts
import { hitungDiskon } from "./hitung";
describe("hitungDiskon", () => {
// ✓ Nama describe = nama unit yang ditest
// ✓ Nama test = skenario spesifik yang diuji
describe("kasus normal", () => {
test("menghitung diskon 10% dengan benar", () => {
// Arrange — siapkan data
const harga = 100_000;
const diskon = 10;
// Act — jalankan unit yang ditest
const hasil = hitungDiskon(harga, diskon);
// Assert — verifikasi hasilnya
expect(hasil).toBe(90_000);
});
test("diskon 0% mengembalikan harga asli", () => {
expect(hitungDiskon(100_000, 0)).toBe(100_000);
});
test("diskon 100% mengembalikan 0", () => {
expect(hitungDiskon(100_000, 100)).toBe(0);
});
test("menerapkan batas maksimal diskon", () => {
// Arrange
const harga = 1_000_000;
const diskon = 50; // Mau diskon 500rb
const batasDiskon = 100_000; // Tapi maks diskon 100rb
// Act
const hasil = hitungDiskon(harga, diskon, batasDiskon);
// Assert
expect(hasil).toBe(900_000); // Dikurangi 100rb saja
});
});
describe("kasus edge", () => {
test("harga 0 menghasilkan 0", () => {
expect(hitungDiskon(0, 50)).toBe(0);
});
test("membulatkan hasil ke integer terdekat", () => {
expect(hitungDiskon(100, 33)).toBe(67); // 100 - 33 = 67
});
});
describe("validasi input — melempar error", () => {
test("melempar RangeError untuk harga negatif", () => {
expect(() => hitungDiskon(-1, 10)).toThrow(RangeError);
expect(() => hitungDiskon(-1, 10)).toThrow("Harga tidak boleh negatif");
});
test("melempar RangeError untuk persen diskon di luar 0-100", () => {
expect(() => hitungDiskon(100, -1)).toThrow(RangeError);
expect(() => hitungDiskon(100, 101)).toThrow(RangeError);
});
});
});
Testing Kelas dengan Dependensi #
Untuk kelas yang punya dependensi, inject dependensi lewat constructor agar mudah diganti saat testing:
// src/services/notifikasi.ts
export interface PengirimEmail {
kirim(tujuan: string, subjek: string, isi: string): Promise<void>;
}
export interface RepositoriPengguna {
ambilById(id: string): Promise<{ nama: string; email: string } | null>;
}
export class LayananNotifikasi {
constructor(
private readonly email: PengirimEmail,
private readonly repoPengguna: RepositoriPengguna
) {}
async kirimSelamatDatang(penggunaId: string): Promise<void> {
const pengguna = await this.repoPengguna.ambilById(penggunaId);
if (!pengguna) {
throw new Error(`Pengguna ${penggunaId} tidak ditemukan`);
}
await this.email.kirim(
pengguna.email,
"Selamat Datang!",
`Halo ${pengguna.nama}, terima kasih telah mendaftar.`
);
}
async kirimResetPassword(penggunaId: string, token: string): Promise<void> {
const pengguna = await this.repoPengguna.ambilById(penggunaId);
if (!pengguna) throw new Error(`Pengguna ${penggunaId} tidak ditemukan`);
const link = `https://app.example.com/reset?token=${token}`;
await this.email.kirim(
pengguna.email,
"Reset Password",
`Klik link berikut untuk reset password: ${link}`
);
}
}
// src/services/notifikasi.test.ts
import { LayananNotifikasi, PengirimEmail, RepositoriPengguna } from "./notifikasi";
describe("LayananNotifikasi", () => {
// Setup test doubles — implementasi sederhana untuk testing
let mockEmail: jest.Mocked<PengirimEmail>;
let mockRepo: jest.Mocked<RepositoriPengguna>;
let layanan: LayananNotifikasi;
beforeEach(() => {
// Buat mock baru sebelum setiap test — hindari state yang bocor antar test
mockEmail = { kirim: jest.fn().mockResolvedValue(undefined) };
mockRepo = { ambilById: jest.fn() };
layanan = new LayananNotifikasi(mockEmail, mockRepo);
});
describe("kirimSelamatDatang", () => {
test("mengirim email ke pengguna yang ditemukan", async () => {
// Arrange
const pengguna = { nama: "Budi Santoso", email: "[email protected]" };
mockRepo.ambilById.mockResolvedValue(pengguna);
// Act
await layanan.kirimSelamatDatang("usr-001");
// Assert — verifikasi email dikirim dengan argumen yang benar
expect(mockEmail.kirim).toHaveBeenCalledTimes(1);
expect(mockEmail.kirim).toHaveBeenCalledWith(
"[email protected]",
"Selamat Datang!",
expect.stringContaining("Budi Santoso") // Pesan mengandung nama
);
});
test("melempar error jika pengguna tidak ditemukan", async () => {
// Arrange
mockRepo.ambilById.mockResolvedValue(null);
// Act & Assert
await expect(layanan.kirimSelamatDatang("usr-999")).rejects.toThrow(
"Pengguna usr-999 tidak ditemukan"
);
// Pastikan email tidak terkirim
expect(mockEmail.kirim).not.toHaveBeenCalled();
});
test("melempar jika pengiriman email gagal", async () => {
// Arrange
mockRepo.ambilById.mockResolvedValue({ nama: "Budi", email: "[email protected]" });
mockEmail.kirim.mockRejectedValue(new Error("SMTP connection refused"));
// Act & Assert
await expect(layanan.kirimSelamatDatang("usr-001")).rejects.toThrow(
"SMTP connection refused"
);
});
});
});
Testing Async dan Promise #
// src/utils/retry.ts
export async function denganRetry<T>(
operasi: () => Promise<T>,
maxPercobaan: number,
delayMs: number = 0
): Promise<T> {
let percobaan = 0;
while (true) {
try {
return await operasi();
} catch (err) {
percobaan++;
if (percobaan >= maxPercobaan) throw err;
if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
}
}
}
// src/utils/retry.test.ts
import { denganRetry } from "./retry";
describe("denganRetry", () => {
// Gunakan fake timers untuk menghindari delay nyata di test
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
test("langsung berhasil jika tidak ada error", async () => {
const operasi = jest.fn().mockResolvedValue("berhasil");
const hasil = await denganRetry(operasi, 3);
expect(hasil).toBe("berhasil");
expect(operasi).toHaveBeenCalledTimes(1); // Hanya dipanggil sekali
});
test("retry setelah error dan berhasil di percobaan kedua", async () => {
const operasi = jest
.fn()
.mockRejectedValueOnce(new Error("Gagal 1")) // Percobaan 1 gagal
.mockResolvedValue("berhasil"); // Percobaan 2 berhasil
const hasil = await denganRetry(operasi, 3);
expect(hasil).toBe("berhasil");
expect(operasi).toHaveBeenCalledTimes(2);
});
test("melempar error setelah semua percobaan gagal", async () => {
const errorAkhir = new Error("Selalu gagal");
const operasi = jest.fn().mockRejectedValue(errorAkhir);
await expect(denganRetry(operasi, 3)).rejects.toThrow("Selalu gagal");
expect(operasi).toHaveBeenCalledTimes(3); // Tepat 3 percobaan
});
});
Testing HTTP Endpoint dengan Supertest #
// src/app.test.ts — integration test untuk HTTP endpoint
import request from "supertest"; // npm install --save-dev supertest @types/supertest
import { buatApp } from "./app";
describe("API /health", () => {
const app = buatApp();
test("GET /health mengembalikan 200 dan status ok", async () => {
const response = await request(app)
.get("/health")
.expect("Content-Type", /json/)
.expect(200);
expect(response.body).toMatchObject({
status: "ok",
env: expect.any(String),
waktu: expect.any(String),
});
});
});
describe("API /api/pengguna", () => {
const app = buatApp();
test("GET /api/pengguna tanpa token mengembalikan 401", async () => {
const response = await request(app).get("/api/pengguna").expect(401);
expect(response.body).toMatchObject({
error: expect.stringContaining("Token"),
});
});
test("POST /api/pengguna dengan data valid mengembalikan 201", async () => {
const dataPengguna = {
nama: "Budi Santoso",
email: "[email protected]",
password: "Password1!",
};
const response = await request(app)
.post("/api/pengguna")
.send(dataPengguna)
.expect("Content-Type", /json/)
.expect(201);
expect(response.body.data).toMatchObject({
nama: "Budi Santoso",
email: "[email protected]",
});
// Password tidak boleh ada di response
expect(response.body.data).not.toHaveProperty("password");
expect(response.body.data).not.toHaveProperty("passwordHash");
});
test("POST /api/pengguna dengan email tidak valid mengembalikan 400", async () => {
const response = await request(app)
.post("/api/pengguna")
.send({ nama: "Budi", email: "bukan-email", password: "Password1!" })
.expect(400);
expect(response.body.error).toBeDefined();
expect(response.body.detail?.email).toBeDefined();
});
});
Matcher yang Berguna #
// Kesetaraan
expect(2 + 2).toBe(4); // Identik (===)
expect({ a: 1 }).toEqual({ a: 1 }); // Deep equal
expect(nilai).toBeNull();
expect(nilai).toBeUndefined();
expect(nilai).toBeDefined();
expect(nilai).toBeTruthy();
expect(nilai).toBeFalsy();
// Angka
expect(angka).toBeGreaterThan(5);
expect(angka).toBeLessThanOrEqual(10);
expect(0.1 + 0.2).toBeCloseTo(0.3, 5); // Floating point comparison
// String
expect(teks).toContain("kata");
expect(teks).toMatch(/^\d+$/);
expect(teks).toHaveLength(10);
// Array
expect(arr).toContain("item");
expect(arr).toHaveLength(3);
expect(arr).toEqual(expect.arrayContaining(["a", "b"]));
// Object
expect(obj).toHaveProperty("nama");
expect(obj).toHaveProperty("pengguna.email", "[email protected]");
expect(obj).toMatchObject({ nama: expect.any(String) });
// Error
expect(() => fungsi()).toThrow();
expect(() => fungsi()).toThrow(TypeError);
expect(() => fungsi()).toThrow("pesan error");
await expect(asyncFn()).rejects.toThrow("async error");
// Mock
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith("arg1", expect.any(Number));
expect(mockFn).toHaveBeenLastCalledWith("argTerakhir");
expect(mockFn).not.toHaveBeenCalled();
Code Coverage yang Bermakna #
Code coverage adalah metrik yang mudah disalahgunakan. 100% coverage tidak berarti 0 bug — coverage hanya mengukur baris mana yang dieksekusi oleh test, bukan apakah semua skenario diuji dengan benar:
// ANTI-PATTERN: Test yang memberikan coverage tapi tidak berarti
test("hitungDiskon dipanggil", () => {
hitungDiskon(100, 10); // Dieksekusi = coverage ✓, tapi tidak ada assertion!
});
// BENAR: Test yang benar-benar memverifikasi perilaku
test("hitungDiskon mengurangi 10% dari harga", () => {
expect(hitungDiskon(100, 10)).toBe(90); // Assertion yang meaningful
});
Jalankan coverage report:
npx jest --coverage
# Output:
# ----------|---------|----------|---------|---------|
# File | % Stmts | % Branch | % Funcs | % Lines |
# ----------|---------|----------|---------|---------|
# hitung.ts | 100 | 87.5 | 100 | 100 |
# ----------|---------|----------|---------|---------|
Perhatikan Branch coverage — ini yang paling penting karena mengukur apakah semua percabangan (if/else, switch, ternary) sudah ditest. Branch 87.5% berarti ada satu cabang yang belum ditest — cari tahu mana.
Prinsip FIRST untuk Test Berkualitas #
F — Fast (Cepat)
Test harus selesai dalam milidetik, bukan detik.
Jika lambat: gunakan mock untuk dependensi eksternal (DB, API, filesystem).
I — Isolated / Independent (Terisolasi)
Test tidak boleh bergantung pada state dari test lain.
Gunakan beforeEach untuk setup fresh dan afterEach untuk cleanup.
Urutan test tidak boleh berpengaruh.
R — Repeatable (Dapat Diulang)
Test harus menghasilkan hasil yang sama setiap kali dijalankan.
Jangan bergantung pada tanggal/waktu nyata, data random, atau urutan Map.
Gunakan jest.useFakeTimers() untuk kontrol waktu.
S — Self-validating (Memvalidasi Sendiri)
Test harus jelas berhasil atau gagal — tidak perlu interpretasi manual.
Setiap test harus punya minimal satu assertion.
T — Timely (Tepat Waktu)
Tulis test bersamaan atau sebelum menulis kode produksi (TDD).
Test yang ditulis belakangan cenderung hanya menguji happy path.
Ringkasan #
- Setup
jest.config.tsdengan TypeScript — gunakants-jestatau@swc/jestsebagai transformer; buattsconfig.test.jsonterpisah dengannoEmit: trueagar file test tidak dikompilasi kedist/.- Pola AAA (Arrange-Act-Assert) — setiap test harus memiliki tiga bagian yang jelas; kode test yang sulit dibaca adalah tanda bahwa kode produksi perlu direfaktor agar lebih testable.
- Satu assertion per konsep — hindari satu test yang menguji banyak hal berbeda; test yang gagal harus langsung menunjukkan apa yang salah tanpa perlu investigasi.
- Inject dependensi melalui constructor — kelas yang membuat dependensinya sendiri (
new Database()di dalam constructor) tidak bisa ditest secara terisolasi; injeksi membuat penggantian dengan mock menjadi mudah.beforeEachuntuk setup segar — buat instance baru dari mock dan unit yang ditest dibeforeEach, bukan di scopedescribeuntuk menghindari kebocoran state antar test.jest.fn()dengan return value yang tepat —mockResolvedValueuntuk async sukses,mockRejectedValueuntuk async gagal,mockReturnValueuntuk sync; gunakanmockResolvedValueOnceuntuk mock yang berbeda per pemanggilan.- Branch coverage lebih penting dari line coverage — pastikan setiap
if,else,switch case, dan ternary sudah ditest dengan input yang berbeda; line coverage bisa 100% tapi branch coverage masih rendah.jest.useFakeTimers()untuk test yang melibatkansetTimeout,setInterval, atauDate.now()— ini membuat test deterministik dan tidak perlu menunggu waktu nyata berlalu.- Supertest untuk HTTP endpoint — gunakan supertest untuk integration test tanpa perlu server yang benar-benar berjalan; ini jauh lebih cepat dan lebih terisolasi dari test E2E.