Unit Test #
Unit testing is the foundation of reliable software — it provides a safety net that lets you refactor code with confidence, documents expected behavior in an executable way, and detects regressions before they reach production. TypeScript brings a new dimension to unit testing: because the compiler already catches many type bugs at compile time, unit tests in TypeScript can focus on behavior and business logic, not on “does this function accept the right types?” But TypeScript also requires extra configuration — the test runner needs to understand TypeScript, and Jest/Vitest type definitions need to be available. This article covers the correct setup, how to write meaningful tests, and the principles that separate a helpful test suite from one that becomes a burden.
The Testing Pyramid — When to Use Unit Tests #
flowchart TD
A[Testing Pyramid] --> E2E[E2E Tests\nFew, slow, expensive\nSelenium, Playwright, Cypress]
A --> INT[Integration Tests\nModerate, middle\nSupertest, real databases]
A --> UNIT[Unit Tests\nMany, fast, cheap\nJest, Vitest]
E2E --> E2E_CAP[Test user flows\nend-to-end for real]
INT --> INT_CAP[Test component interactions\nHTTP, database, cache]
UNIT --> UNIT_CAP[Test one isolated unit\nfunctions, classes, services]
style UNIT fill:#51cf66,color:#fff
style INT fill:#339af0,color:#fff
style E2E fill:#fcc419,color:#000Unit tests are the bottom and largest layer — many, fast, and cheap to write and run. They focus on a single unit (a function or class) in isolation without real external dependencies.
Setup: Jest with TypeScript #
Jest is the most popular test runner in the TypeScript ecosystem:
npm install --save-dev jest ts-jest @types/jest
// jest.config.ts — type-safe Jest configuration
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", // Use a test-specific tsconfig
}],
},
collectCoverageFrom: [
"src/**/*.ts",
"!src/**/*.d.ts",
"!src/**/__tests__/**",
"!src/index.ts", // Entry points usually don't need testing
],
coverageThresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
clearMocks: true, // Reset mock state between tests
restoreMocks: true, // Restore original spies after every test
};
export default config;
// tsconfig.test.json — overrides for the test environment
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"types": ["jest", "node"]
},
"include": ["src/**/*", "**/*.test.ts", "**/*.spec.ts"]
}
Alternative: Vitest (Faster) #
If the project uses Vite, Vitest is a faster choice with a Jest-compatible API:
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"
}
}
The Anatomy of a Good Test — The AAA Pattern #
Every good test follows the Arrange-Act-Assert (AAA) pattern:
// src/utils/hitung.ts — the function to be tested
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", () => {
// ✓ describe name = the unit being tested
// ✓ test name = the specific scenario being tested
describe("normal cases", () => {
test("correctly calculates a 10% discount", () => {
// Arrange — prepare the data
const harga = 100_000;
const diskon = 10;
// Act — run the unit being tested
const hasil = hitungDiskon(harga, diskon);
// Assert — verify the result
expect(hasil).toBe(90_000);
});
test("a 0% discount returns the original price", () => {
expect(hitungDiskon(100_000, 0)).toBe(100_000);
});
test("a 100% discount returns 0", () => {
expect(hitungDiskon(100_000, 100)).toBe(0);
});
test("applies the maximum discount cap", () => {
// Arrange
const harga = 1_000_000;
const diskon = 50; // Wants a 500k discount
const batasDiskon = 100_000; // But the max discount is 100k
// Act
const hasil = hitungDiskon(harga, diskon, batasDiskon);
// Assert
expect(hasil).toBe(900_000); // Only reduced by 100k
});
});
describe("edge cases", () => {
test("a price of 0 yields 0", () => {
expect(hitungDiskon(0, 50)).toBe(0);
});
test("rounds the result to the nearest integer", () => {
expect(hitungDiskon(100, 33)).toBe(67); // 100 - 33 = 67
});
});
describe("input validation — throws errors", () => {
test("throws a RangeError for a negative price", () => {
expect(() => hitungDiskon(-1, 10)).toThrow(RangeError);
expect(() => hitungDiskon(-1, 10)).toThrow("Harga tidak boleh negatif");
});
test("throws a RangeError for a discount percentage outside 0-100", () => {
expect(() => hitungDiskon(100, -1)).toThrow(RangeError);
expect(() => hitungDiskon(100, 101)).toThrow(RangeError);
});
});
});
Testing Classes with Dependencies #
For classes with dependencies, inject the dependencies through the constructor so they’re easy to replace during 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", () => {
// Set up test doubles — simple implementations for testing
let mockEmail: jest.Mocked<PengirimEmail>;
let mockRepo: jest.Mocked<RepositoriPengguna>;
let layanan: LayananNotifikasi;
beforeEach(() => {
// Create fresh mocks before every test — avoid state leaking between tests
mockEmail = { kirim: jest.fn().mockResolvedValue(undefined) };
mockRepo = { ambilById: jest.fn() };
layanan = new LayananNotifikasi(mockEmail, mockRepo);
});
describe("kirimSelamatDatang", () => {
test("sends an email to a found user", async () => {
// Arrange
const pengguna = { nama: "Budi Santoso", email: "[email protected]" };
mockRepo.ambilById.mockResolvedValue(pengguna);
// Act
await layanan.kirimSelamatDatang("usr-001");
// Assert — verify the email was sent with the correct arguments
expect(mockEmail.kirim).toHaveBeenCalledTimes(1);
expect(mockEmail.kirim).toHaveBeenCalledWith(
"[email protected]",
"Selamat Datang!",
expect.stringContaining("Budi Santoso") // The message contains the name
);
});
test("throws an error if the user isn't found", async () => {
// Arrange
mockRepo.ambilById.mockResolvedValue(null);
// Act & Assert
await expect(layanan.kirimSelamatDatang("usr-999")).rejects.toThrow(
"Pengguna usr-999 tidak ditemukan"
);
// Make sure no email was sent
expect(mockEmail.kirim).not.toHaveBeenCalled();
});
test("throws if the email sending fails", 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 and Promises #
// 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", () => {
// Use fake timers to avoid real delays in tests
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
test("succeeds immediately if there are no errors", async () => {
const operasi = jest.fn().mockResolvedValue("berhasil");
const hasil = await denganRetry(operasi, 3);
expect(hasil).toBe("berhasil");
expect(operasi).toHaveBeenCalledTimes(1); // Only called once
});
test("retries after an error and succeeds on the second attempt", async () => {
const operasi = jest
.fn()
.mockRejectedValueOnce(new Error("Gagal 1")) // Attempt 1 fails
.mockResolvedValue("berhasil"); // Attempt 2 succeeds
const hasil = await denganRetry(operasi, 3);
expect(hasil).toBe("berhasil");
expect(operasi).toHaveBeenCalledTimes(2);
});
test("throws an error after all attempts fail", 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); // Exactly 3 attempts
});
});
Testing HTTP Endpoints with Supertest #
// src/app.test.ts — integration test for HTTP endpoints
import request from "supertest"; // npm install --save-dev supertest @types/supertest
import { buatApp } from "./app";
describe("API /health", () => {
const app = buatApp();
test("GET /health returns 200 and an ok status", 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 without a token returns 401", async () => {
const response = await request(app).get("/api/pengguna").expect(401);
expect(response.body).toMatchObject({
error: expect.stringContaining("Token"),
});
});
test("POST /api/pengguna with valid data returns 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]",
});
// The password must not be in the response
expect(response.body.data).not.toHaveProperty("password");
expect(response.body.data).not.toHaveProperty("passwordHash");
});
test("POST /api/pengguna with an invalid email returns 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();
});
});
Useful Matchers #
// Equality
expect(2 + 2).toBe(4); // Identical (===)
expect({ a: 1 }).toEqual({ a: 1 }); // Deep equal
expect(nilai).toBeNull();
expect(nilai).toBeUndefined();
expect(nilai).toBeDefined();
expect(nilai).toBeTruthy();
expect(nilai).toBeFalsy();
// Numbers
expect(angka).toBeGreaterThan(5);
expect(angka).toBeLessThanOrEqual(10);
expect(0.1 + 0.2).toBeCloseTo(0.3, 5); // Floating point comparison
// Strings
expect(teks).toContain("kata");
expect(teks).toMatch(/^\d+$/);
expect(teks).toHaveLength(10);
// Arrays
expect(arr).toContain("item");
expect(arr).toHaveLength(3);
expect(arr).toEqual(expect.arrayContaining(["a", "b"]));
// Objects
expect(obj).toHaveProperty("nama");
expect(obj).toHaveProperty("pengguna.email", "[email protected]");
expect(obj).toMatchObject({ nama: expect.any(String) });
// Errors
expect(() => fungsi()).toThrow();
expect(() => fungsi()).toThrow(TypeError);
expect(() => fungsi()).toThrow("pesan error");
await expect(asyncFn()).rejects.toThrow("async error");
// Mocks
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith("arg1", expect.any(Number));
expect(mockFn).toHaveBeenLastCalledWith("argTerakhir");
expect(mockFn).not.toHaveBeenCalled();
Meaningful Code Coverage #
Code coverage is a metric that’s easily misused. 100% coverage doesn’t mean 0 bugs — coverage only measures which lines were executed by tests, not whether all scenarios were tested correctly:
// ANTI-PATTERN: A test that provides coverage but means nothing
test("hitungDiskon is called", () => {
hitungDiskon(100, 10); // Executed = coverage ✓, but there's no assertion!
});
// CORRECT: A test that actually verifies behavior
test("hitungDiskon subtracts 10% from the price", () => {
expect(hitungDiskon(100, 10)).toBe(90); // A meaningful assertion
});
Run the coverage report:
npx jest --coverage
# Output:
# ----------|---------|----------|---------|---------|
# File | % Stmts | % Branch | % Funcs | % Lines |
# ----------|---------|----------|---------|---------|
# hitung.ts | 100 | 87.5 | 100 | 100 |
# ----------|---------|----------|---------|---------|
Note the Branch coverage — this is the most important because it measures whether all branches (if/else, switch, ternary) have been tested. 87.5% branches means there’s one branch not yet tested — find out which one.
The FIRST Principles for Quality Tests #
F — Fast
Tests should finish in milliseconds, not seconds.
If slow: use mocks for external dependencies (DB, API, filesystem).
I — Isolated / Independent
Tests must not depend on state from other tests.
Use beforeEach for fresh setup and afterEach for cleanup.
Test order must not matter.
R — Repeatable
Tests must produce the same result every run.
Don't depend on real dates/times, random data, or Map order.
Use jest.useFakeTimers() for time control.
S — Self-validating
Tests must clearly pass or fail — no manual interpretation needed.
Every test must have at least one assertion.
T — Timely
Write tests together with or before writing production code (TDD).
Tests written later tend to only test the happy path.
Summary #
- Set up
jest.config.tswith TypeScript — usets-jestor@swc/jestas the transformer; create a separatetsconfig.test.jsonwithnoEmit: trueso test files don’t get compiled intodist/.- The AAA pattern (Arrange-Act-Assert) — every test should have three clear parts; hard-to-read test code is a sign that the production code needs refactoring to be more testable.
- One assertion per concept — avoid one test that checks many different things; a failing test should immediately show what is wrong without investigation.
- Inject dependencies through the constructor — classes that create their own dependencies (
new Database()inside the constructor) can’t be tested in isolation; injection makes replacing with mocks easy.beforeEachfor fresh setup — create new instances of mocks and the tested unit inbeforeEach, not in thedescribescope, to avoid state leaking between tests.jest.fn()with the right return values —mockResolvedValuefor async success,mockRejectedValuefor async failure,mockReturnValuefor sync; usemockResolvedValueOncefor mocks that differ per call.- Branch coverage matters more than line coverage — make sure every
if,else,switch case, and ternary is tested with different inputs; line coverage can be 100% while branch coverage is still low.jest.useFakeTimers()for tests involvingsetTimeout,setInterval, orDate.now()— this makes tests deterministic without waiting for real time to pass.- Supertest for HTTP endpoints — use Supertest for integration tests without a real running server; it’s far faster and more isolated than E2E tests.