Testing Piramida: Unit, Integration, dan E2E Testing yang Efektif

Bayangkan sebuah tim yang mengandalkan QA manual untuk mengetes setiap fitur sebelum rilis. Setiap kali ada perubahan kecil, seluruh aplikasi harus dites ulang secara manual, memakan waktu berhari-hari, dan tetap saja bug lolos ke production. Di sisi lain, ada tim yang menulis ratusan unit test tapi tetap sering mengalami bug integrasi antar service karena tidak ada test yang memverifikasi bagaimana komponen-komponen tersebut bekerja bersama. Kedua skenario ini menggambarkan pentingnya strategi testing yang seimbang — dan di sinilah konsep testing pyramid berperan.
Testing pyramid bukan sekadar diagram segitiga yang sering muncul di slide presentasi. Ini adalah panduan strategis tentang bagaimana mendistribusikan usaha testing Anda agar aplikasi tetap terjaga kualitasnya tanpa mengorbankan kecepatan development. Artikel ini akan membahas secara mendalam tiga lapisan utama testing pyramid: unit test, integration test, dan end-to-end (E2E) test, lengkap dengan contoh kode, tools, dan strategi praktis.
Apa Itu Testing Pyramid?
Konsep testing pyramid pertama kali dipopulerkan oleh Mike Cohn dalam bukunya Succeeding with Agile yang terbit tahun 2009. Idenya sederhana namun sangat berpengaruh: sebuah strategi testing yang sehat harus memiliki banyak unit test di dasar piramida, lebih sedikit integration test di tengah, dan paling sedikit E2E test di puncak. Bentuk piramida ini merepresentasikan trade-off antara kecepatan eksekusi, biaya maintenance, dan tingkat kepercayaan (confidence) yang diberikan oleh setiap jenis test.
Semakin ke atas piramida, test semakin lambat dijalankan, semakin mahal untuk di-maintain, dan semakin rapuh (fragile) terhadap perubahan kecil di UI atau infrastruktur. Namun semakin ke atas juga semakin mendekati pengalaman pengguna sesungguhnya, sehingga memberikan kepercayaan lebih tinggi bahwa aplikasi benar-benar berfungsi end-to-end.
Lapisan Pertama: Unit Testing
Unit test adalah fondasi dari testing pyramid. Test jenis ini menguji unit kode terkecil — biasanya satu fungsi atau satu method — secara terisolasi dari dependency eksternal seperti database, network, atau file system.
Karakteristik Unit Test yang Baik
- Cepat — ribuan unit test seharusnya bisa selesai dalam hitungan detik karena tidak melibatkan I/O nyata.
- Terisolasi — satu test tidak boleh bergantung pada test lain atau state global yang tersisa dari test sebelumnya.
- Deterministik — hasilnya harus selalu sama setiap kali dijalankan, tidak boleh flaky (kadang lulus, kadang gagal tanpa perubahan kode).
- Fokus — menguji satu perilaku spesifik, sehingga ketika test gagal, penyebabnya langsung jelas.
Contoh Unit Test dengan Jest
// discount.js
function applyDiscount(price, discountPercent) {
if (discountPercent < 0 || discountPercent > 100) {
throw new Error("Diskon harus antara 0 dan 100");
}
return price - (price * discountPercent / 100);
}
module.exports = { applyDiscount };
// discount.test.js
const { applyDiscount } = require("./discount");
describe("applyDiscount", () => {
test("menghitung diskon 10% dengan benar", () => {
expect(applyDiscount(100000, 10)).toBe(90000);
});
test("mengembalikan harga penuh saat diskon 0%", () => {
expect(applyDiscount(50000, 0)).toBe(50000);
});
test("melempar error jika diskon negatif", () => {
expect(() => applyDiscount(100000, -5)).toThrow("Diskon harus antara 0 dan 100");
});
test("melempar error jika diskon lebih dari 100", () => {
expect(() => applyDiscount(100000, 150)).toThrow();
});
});
Mocking dan Stubbing
Ketika unit yang diuji memiliki dependency eksternal, kita menggunakan teknik mocking atau stubbing untuk mengganti dependency tersebut dengan versi palsu yang perilakunya bisa dikontrol penuh dalam test.
// userService.js
class UserService {
constructor(userRepository, emailSender) {
this.userRepository = userRepository;
this.emailSender = emailSender;
}
async registerUser(email, name) {
const existing = await this.userRepository.findByEmail(email);
if (existing) {
throw new Error("Email sudah terdaftar");
}
const user = await this.userRepository.create({ email, name });
await this.emailSender.sendWelcome(email);
return user;
}
}
// userService.test.js
test("registerUser mengirim email selamat datang untuk user baru", async () => {
const mockRepo = {
findByEmail: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({ id: 1, email: "[email protected]" }),
};
const mockEmailSender = { sendWelcome: jest.fn().mockResolvedValue(true) };
const service = new UserService(mockRepo, mockEmailSender);
await service.registerUser("[email protected]", "Budi");
expect(mockEmailSender.sendWelcome).toHaveBeenCalledWith("[email protected]");
expect(mockRepo.create).toHaveBeenCalledTimes(1);
});
Perhatikan bahwa test ini tidak menyentuh database atau layanan email sungguhan — semuanya diganti dengan mock. Ini membuat test berjalan sangat cepat dan tidak bergantung pada infrastruktur eksternal yang mungkin tidak stabil.
Lapisan Kedua: Integration Testing
Integration test menguji interaksi antara beberapa unit atau komponen sekaligus, termasuk interaksi dengan dependency nyata seperti database, cache, atau API pihak ketiga (biasanya dalam bentuk test/staging environment, bukan production). Tujuannya adalah memastikan bahwa komponen-komponen yang secara individu sudah diuji dengan unit test, benar-benar bekerja dengan baik saat digabungkan.
Contoh Integration Test dengan Database
// userRepository.integration.test.js
const { Pool } = require("pg");
const UserRepository = require("./userRepository");
describe("UserRepository (integration)", () => {
let pool;
let repository;
beforeAll(async () => {
pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
repository = new UserRepository(pool);
await pool.query(
"CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, email TEXT UNIQUE, name TEXT)"
);
});
afterEach(async () => {
await pool.query("TRUNCATE TABLE users RESTART IDENTITY");
});
afterAll(async () => {
await pool.end();
});
test("create menyimpan user baru ke database", async () => {
const user = await repository.create({ email: "[email protected]", name: "Budi" });
expect(user.id).toBeDefined();
const found = await repository.findByEmail("[email protected]");
expect(found.name).toBe("Budi");
});
test("findByEmail mengembalikan null jika tidak ditemukan", async () => {
const found = await repository.findByEmail("[email protected]");
expect(found).toBeNull();
});
});
Integration test seperti ini biasanya berjalan di database test yang terpisah dari database development atau production, sering menggunakan container Docker yang di-spin up khusus untuk keperluan CI/CD agar test tetap terisolasi dan bisa diulang (reproducible).
Integration Testing untuk API
Selain database, integration test juga sering dipakai untuk menguji endpoint API secara end-to-end di level HTTP, tapi masih dalam lingkup satu service (bukan seluruh sistem):
const request = require("supertest");
const app = require("../app");
describe("POST /api/users", () => {
test("mengembalikan status 201 dan data user saat registrasi berhasil", async () => {
const response = await request(app)
.post("/api/users")
.send({ email: "[email protected]", name: "Citra" });
expect(response.status).toBe(201);
expect(response.body).toMatchObject({ email: "[email protected]" });
});
test("mengembalikan status 400 saat email tidak valid", async () => {
const response = await request(app)
.post("/api/users")
.send({ email: "bukan-email", name: "Citra" });
expect(response.status).toBe(400);
});
});
Lapisan Ketiga: End-to-End (E2E) Testing
E2E test menguji aplikasi dari sudut pandang pengguna sungguhan, mensimulasikan interaksi nyata seperti klik tombol, mengisi form, dan navigasi antar halaman, biasanya melalui browser otomatis. Test ini memberikan kepercayaan paling tinggi karena benar-benar memvalidasi seluruh alur, dari UI hingga backend dan database, sebagaimana pengguna akan mengalaminya.
Tools Populer untuk E2E Testing
- Playwright — dikembangkan Microsoft, mendukung multi-browser (Chromium, Firefox, WebKit), auto-waiting yang canggih, dan API yang modern.
- Cypress — sangat populer karena developer experience yang baik, time-travel debugging, dan dokumentasi yang lengkap.
- Selenium — tool paling senior di kategori ini, mendukung hampir semua bahasa pemrograman, meski API-nya terasa lebih verbose dibanding tool modern.
Contoh E2E Test dengan Playwright
const { test, expect } = require("@playwright/test");
test("pengguna dapat login dan melihat dashboard", async ({ page }) => {
await page.goto("https://app.contoh.id/login");
await page.fill("input[name=email]", "[email protected]");
await page.fill("input[name=password]", "password123");
await page.click("button[type=submit]");
await expect(page).toHaveURL(/.*dashboard/);
await expect(page.locator("h1")).toHaveText("Selamat datang, User!");
});
test("checkout menampilkan error saat keranjang kosong", async ({ page }) => {
await page.goto("https://app.contoh.id/checkout");
await page.click("button#checkout-button");
await expect(page.locator(".error-message")).toContainText("Keranjang Anda kosong");
});
E2E test seperti ini sangat berharga karena menguji integrasi penuh sistem, tapi juga paling rentan terhadap flakiness — kegagalan test yang tidak konsisten akibat timing, animasi, network latency, atau elemen UI yang berubah posisi. Karena itulah E2E test sebaiknya difokuskan pada alur-alur kritis (critical path) seperti login, checkout, dan pembayaran, bukan untuk menguji setiap detail kecil UI.
Berapa Persen Test Coverage yang Ideal?
Pertanyaan ini sering muncul, dan jawabannya lebih nuanced dari sekadar angka. Banyak tim menargetkan coverage 70-80% sebagai patokan umum, tapi angka ini bisa menyesatkan jika dijadikan target mutlak. Coverage tinggi tidak otomatis berarti kualitas test yang baik — Anda bisa saja memiliki coverage 100% tapi assertion yang lemah sehingga test tidak benar-benar memvalidasi perilaku penting.
Yang lebih penting daripada angka coverage adalah:
- Apakah logika bisnis kritis (perhitungan harga, validasi, aturan keamanan) benar-benar teruji dengan baik?
- Apakah edge case dan skenario error tercakup, bukan hanya "happy path"?
- Apakah test benar-benar akan gagal ketika ada bug, bukan sekadar menjalankan kode tanpa assertion yang berarti?
Anti-Pattern: Ice Cream Cone
Kebalikan dari testing pyramid adalah "ice cream cone" anti-pattern — piramida terbalik di mana tim memiliki banyak sekali E2E test manual atau otomatis, sedikit integration test, dan sangat sedikit unit test. Pola ini sering muncul di organisasi yang masih sangat mengandalkan QA manual atau baru mulai mengadopsi automated testing tanpa strategi yang jelas.
Masalah dari ice cream cone anti-pattern:
- Test suite menjadi sangat lambat, bisa memakan waktu berjam-jam untuk seluruh suite.
- Ketika test gagal, sulit menentukan akar masalah karena satu E2E test menyentuh banyak komponen sekaligus.
- Test menjadi rapuh — perubahan kecil di UI bisa membuat puluhan E2E test gagal sekaligus.
- Feedback loop developer menjadi lambat, sehingga bug ditemukan jauh lebih lambat dari yang seharusnya.
Tabel Perbandingan Ketiga Jenis Test
| Aspek | Unit Test | Integration Test | E2E Test |
|---|---|---|---|
| Kecepatan | Sangat cepat (ms) | Sedang (detik) | Lambat (detik-menit) |
| Cakupan | Satu fungsi/unit | Beberapa komponen | Seluruh alur sistem |
| Ketergantungan eksternal | Tidak ada (di-mock) | Ada, biasanya di test environment | Ada, mendekati production |
| Kerapuhan (flakiness) | Rendah | Sedang | Tinggi |
| Jumlah ideal dalam suite | Banyak (mayoritas) | Sedang | Sedikit, fokus critical path |
Mengintegrasikan Testing ke dalam CI/CD
Test hanya berguna maksimal jika dijalankan secara konsisten dan otomatis. Praktik umum dalam pipeline CI/CD:
- Unit test dan integration test dijalankan pada setiap push atau pull request, memberikan feedback cepat dalam hitungan menit.
- E2E test dijalankan pada tahap sebelum deploy ke staging atau production, karena membutuhkan environment yang lebih lengkap dan waktu eksekusi lebih lama.
- Pipeline dikonfigurasi untuk memblokir merge jika test gagal, memastikan kode yang rusak tidak masuk ke branch utama.
- Laporan coverage dihasilkan otomatis dan bisa dipantau trennya dari waktu ke waktu, misalnya dengan tools seperti Codecov atau Coveralls.
# Contoh sederhana GitHub Actions workflow
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run test:unit
- run: npm run test:integration
- run: npm run test:e2e
Kesalahan Umum dalam Strategi Testing
- Menulis test setelah kode selesai sepenuhnya — sering kali test jadi sekadar formalitas dan tidak benar-benar menguji edge case penting.
- Mengetes implementasi, bukan perilaku — test yang terlalu bergantung pada detail internal akan rapuh setiap kali ada refactoring, meskipun perilaku publiknya tidak berubah.
- Mengabaikan test yang flaky — membiarkan test yang kadang gagal tanpa alasan jelas akan mengikis kepercayaan tim terhadap seluruh test suite, sampai akhirnya semua orang mengabaikan hasil test yang gagal.
- Terlalu banyak mocking — mock yang berlebihan bisa membuat test lulus padahal integrasi sesungguhnya rusak, karena mock tidak mencerminkan perilaku nyata dependency.
Test Doubles: Dummy, Stub, Spy, Mock, dan Fake
Istilah "mock" sering dipakai secara umum untuk menyebut semua jenis objek pengganti dalam testing, padahal sebenarnya ada beberapa kategori "test double" yang berbeda, sebagaimana dijelaskan Martin Fowler dalam tulisannya yang terkenal, Mocks Aren't Stubs. Memahami perbedaannya membantu Anda menulis test yang lebih presisi dan mudah dipahami.
- Dummy — objek yang hanya dipakai untuk mengisi parameter, tidak pernah benar-benar dipakai dalam logika test, misalnya passing objek kosong karena constructor mengharuskannya.
- Stub — objek yang mengembalikan jawaban tetap (hard-coded) terhadap pemanggilan tertentu, tidak peduli input apa yang diberikan, biasanya dipakai untuk mengontrol kondisi yang diuji.
- Spy — mirip stub, tapi juga mencatat informasi tentang bagaimana ia dipanggil (berapa kali, dengan argumen apa), sehingga bisa diverifikasi setelah test berjalan.
- Mock — objek yang sudah diprogram dengan ekspektasi spesifik sejak awal tentang bagaimana ia seharusnya dipanggil, dan akan membuat test gagal jika ekspektasi tersebut tidak terpenuhi.
- Fake — implementasi kerja yang disederhanakan, misalnya in-memory database sebagai pengganti database sungguhan, yang perilakunya mendekati aslinya tapi jauh lebih ringan dan cepat.
// Contoh Fake: in-memory repository untuk testing tanpa database sungguhan
class InMemoryUserRepository {
#users = new Map();
async create(user) {
const id = this.#users.size + 1;
const newUser = { id, ...user };
this.#users.set(id, newUser);
return newUser;
}
async findByEmail(email) {
return [...this.#users.values()].find((u) => u.email === email) || null;
}
}
test("UserService menolak registrasi dengan email duplikat", async () => {
const repo = new InMemoryUserRepository();
await repo.create({ email: "[email protected]", name: "Ada" });
const service = new UserService(repo, { sendWelcome: jest.fn() });
await expect(service.registerUser("[email protected]", "Ada Lagi")).rejects.toThrow(
"Email sudah terdaftar"
);
});
Fake seperti ini sering menjadi jalan tengah yang bagus antara unit test murni (yang terlalu banyak mocking) dan integration test penuh (yang lebih lambat) — memberi kepercayaan lebih tinggi dibanding mock sederhana, tanpa harus benar-benar menyalakan database.
Contract Testing dan Snapshot Testing sebagai Pelengkap
Selain tiga lapisan utama testing pyramid, ada beberapa teknik testing pelengkap yang layak diketahui, terutama untuk arsitektur microservices dan aplikasi frontend modern.
Contract Testing
Pada arsitektur microservices, integration test tradisional antar service sering sulit dijalankan karena membutuhkan seluruh service lain aktif secara bersamaan. Contract testing, dengan tools seperti Pact, menyelesaikan masalah ini dengan cara berbeda: consumer (misalnya frontend) mendefinisikan "kontrak" tentang bagaimana ia mengharapkan API provider merespons, lalu kontrak ini diverifikasi secara independen terhadap provider tanpa keduanya harus berjalan bersamaan dalam satu test run. Ini memungkinkan tim yang berbeda bekerja secara independen sambil tetap yakin integrasi mereka tidak akan rusak.
Snapshot Testing
Snapshot testing, populer di ekosistem React melalui Jest, bekerja dengan cara menyimpan "snapshot" dari output komponen (biasanya berupa struktur HTML/JSX yang dirender), lalu membandingkannya dengan snapshot pada test run berikutnya. Jika ada perbedaan, developer diberi tahu dan harus memutuskan apakah perubahan tersebut disengaja (lalu snapshot di-update) atau merupakan bug yang tidak diinginkan.
import { render } from "@testing-library/react";
import ProductCard from "./ProductCard";
test("ProductCard cocok dengan snapshot", () => {
const { container } = render(
<ProductCard name="Kopi Susu" price={18000} />
);
expect(container).toMatchSnapshot();
});
Snapshot testing sangat berguna untuk menangkap perubahan tak disengaja pada UI, tapi harus dipakai secara bijak — snapshot yang terlalu besar dan sering berubah cenderung diabaikan begitu saja oleh developer (langsung klik "update snapshot" tanpa benar-benar memeriksa perubahannya), sehingga kehilangan manfaat aslinya sebagai jaring pengaman.
Kesimpulan
Testing pyramid bukan aturan kaku yang harus diikuti persis 70-20-10, melainkan panduan filosofis: investasikan usaha terbesar pada test yang cepat dan murah untuk dijalankan (unit test), gunakan integration test secukupnya untuk memvalidasi interaksi antar komponen, dan sisakan E2E test untuk alur-alur paling kritis yang benar-benar butuh validasi menyeluruh dari sudut pandang pengguna.
Strategi testing yang seimbang akan memberikan Anda kepercayaan diri untuk melakukan deploy lebih sering, refactoring dengan lebih berani, dan menemukan bug jauh sebelum sampai ke tangan pengguna. Yang terpenting, ingatlah bahwa tujuan akhir testing bukan mengejar angka coverage yang tinggi, melainkan membangun kepercayaan nyata bahwa software yang Anda buat benar-benar berfungsi sebagaimana mestinya.

Ditulis oleh
@nopal
Ruang Iklan Tersedia
Hubungi Admin untuk menempatkan banner iklan atau Adsense Anda di sini.
Bagaimana pendapat Anda tentang artikel ini?
Komentar Pembaca (0)
Belum ada komentar. Jadilah yang pertama!