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:#000

Unit 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.ts with TypeScript — use ts-jest or @swc/jest as the transformer; create a separate tsconfig.test.json with noEmit: true so test files don’t get compiled into dist/.
  • 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.
  • beforeEach for fresh setup — create new instances of mocks and the tested unit in beforeEach, not in the describe scope, to avoid state leaking between tests.
  • jest.fn() with the right return valuesmockResolvedValue for async success, mockRejectedValue for async failure, mockReturnValue for sync; use mockResolvedValueOnce for 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 involving setTimeout, setInterval, or Date.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.

← Previous: Web Server   Next: Mocking →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact