Pengujian otomatis, bukti bahwa kode Anda melakukan yang Anda kira.
Penelusuran dari prinsip dasar tentang cara engineer backend memverifikasi sistem mereka: dari satu tes unit dan kosakata test-double, lewat tes integrasi terhadap database dan handler HTTP nyata, tes kontrak antar layanan, pemeriksaan end-to-end, coverage dan flakiness, sampai TDD, load testing, dan tes di pipeline deployment. Ditulis bukan hanya menyebut apa fungsi tiap jenis tes tapi kenapa ia ada dan bagaimana menulisnya dengan baik. Contoh di Go (testing) dan Python (pytest).
Kenapa Tes Ada
Alasan jujur menulis tes otomatis bukan "kebenaran" secara abstrak, melainkan kepercayaan diri untuk mengubah kode tanpa rasa takut. Sistem tanpa tes mengapur: setiap edit berisiko memecah sesuatu yang tak terlihat, jadi orang berhenti refactor, berhenti upgrade dependensi, dan memutar mengelilingi bagian yang menakutkan. Sistem yang teruji baik tetap lunak, Anda bisa menyusun ulang secara agresif karena suite tes hijau memberi tahu Anda, dalam hitungan detik, bahwa perilaku tetap berlaku. Tes lah yang membuat codebase terus berevolusi alih-alih membusuk.
Tes juga spesifikasi yang bisa dieksekusi. Dokumen prosa menyimpang dari kenyataan (masalah yang sama yang dicatat bab gRPC soal kontrak REST); tes yang menegaskan Divide(10, 2) == 5 tidak bisa bohong, ia entah lulus terhadap kode saat ini atau tidak. Baca suite tes yang baik dan Anda mempelajari persis apa yang dijanjikan kode, dengan contoh. Dan tes adalah jaring regresi: begitu Anda menulis tes untuk sebuah bug, bug itu tidak akan pernah diam-diam kembali, jaring menangkapnya di setiap run masa depan.
Kepercayaan untuk berubah (refactor tanpa takut), spesifikasi yang bisa dieksekusi (dokumen yang tidak bisa menyimpang), perlindungan regresi (bug yang diperbaiki tetap diperbaiki), dan umpan balik lebih cepat (detik, bukan klik manual). Kebenaran efek samping; kemampuan berubah hadiahnya.
Piramida Pengujian
Tidak semua tes setara, dan keputusan strategis terpenting campurannya. Piramida pengujian heuristik pemandu: punya banyak tes unit cepat murah di dasar, lebih sedikit tes integrasi di tengah, dan sangat sedikit tes end-to-end lambat mahal di puncak. Bentuknya berasal dari kompromi yang mengalir di seluruh manual ini: saat Anda naik, tes makin realistis (ia menggerakkan lebih banyak sistem nyata) tapi juga lebih lambat, lebih rapuh, dan lebih sulit di-debug.
| Lapisan | Tes | Kecepatan | Realisme | Pakai untuk |
|---|---|---|---|---|
| Unit | Satu unit, dependensi dipalsukan | Milidetik | Rendah | Logika, kasus tepi, cabang |
| Integrasi | Beberapa unit + deps nyata (DB, cache) | Detik | Sedang | Wiring: query, handler, serialisasi |
| E2E | Seluruh sistem lewat antarmuka nyatanya | Detik-menit | Tinggi | Hanya perjalanan pengguna kritis |
Balik piramidanya, banyak tes E2E lambat, sedikit tes unit, dan Anda dapat "ice-cream cone": suite yang makan waktu lama berjalan, gagal sesekali (bagian 14), dan memberi sinyal samar (kegagalan bisa di mana saja di stack). Tim di jebakan ini berhenti memercayai suite dan berhenti menjalankannya. Dorong detail ke tes unit cepat; pakai E2E hemat untuk kepercayaan bahwa bagian-bagiannya tersambung.
Anatomi Sebuah Tes
Setiap tes yang baik, di setiap lapisan, punya struktur tiga ketukan yang sama, Arrange, Act, Assert (AAA; dunia BDD menyebutnya Given-When-Then). Arrange menyiapkan dunia dan input; Act melakukan satu operasi yang diuji; Assert memeriksa hasilnya sesuai harapan. Menjaga fase-fase ini terpisah dan menguji satu perilaku per tes membuat kegagalan langsung terbaca: Anda tahu persis perilaku mana yang rusak.
// File naming convention: foo.go is tested by foo_test.go in the same package.
// Test functions are func TestXxx(t *testing.T), the `go test` tool finds them.
func TestDiscount_AppliesPercentage(t *testing.T) {
// Arrange, set up inputs (and any doubles)
cart := Cart{Subtotal: 200}
coupon := Coupon{Percent: 10}
// Act, the ONE operation under test
total := ApplyDiscount(cart, coupon)
// Assert, state the expectation; a good message explains the failure
if total != 180 {
t.Errorf("ApplyDiscount(200, 10%%) = %d; want 180", total)
}
}# pytest discovers files named test_*.py and functions named test_*.
# A bare `assert` is enough, pytest rewrites it to show a rich failure diff.
def test_discount_applies_percentage():
# Arrange, set up inputs (and any doubles)
cart = Cart(subtotal=200)
coupon = Coupon(percent=10)
# Act, the ONE operation under test
total = apply_discount(cart, coupon)
# Assert, pytest prints actual vs expected automatically on failure
assert total == 180Apa yang membuat assertion baik
- Uji perilaku, bukan implementasi. Tegaskan hasil yang teramati (diskon diterapkan), bukan mekanik internal (helper privat ini dipanggil). Tes yang terkopel implementasi pecah di setiap refactor dan mengalahkan seluruh tujuannya (bagian 1, bagian 20).
- Satu alasan gagal. Tes yang menegaskan lima hal tak berhubungan memberi tahu sedikit saat ia merah. Prefer tes terfokus dengan nama deskriptif, namanya harus terbaca seperti kalimat tentang perilakunya.
- Deterministik. Input sama, hasil sama, setiap run, urutan apa pun, sifat yang dilindungi bagian 14.
Manual ini memakai stack utama tiap bahasa: paket testing bawaan Go dengan go test (tanpa framework; library standar adalah framework-nya), dan pytest Python, standar de facto dengan fixture, parametrize, dan penulisan-ulang assert polos. Keduanya yang akan Anda temui di repo backend nyata.
Tes Unit
Unit test memverifikasi satu potongan logika kecil, fungsi atau method, terisolasi dari sisa sistem. Tanpa database, tanpa jaringan, tanpa file system, tanpa jam: hanya input masuk, output diperiksa. Isolasi itulah yang membuatnya cepat (ribuan berjalan dalam detik) dan presisi (kegagalan menunjuk tepat satu unit). Mereka dasar piramida yang lebar (bagian 2) dan tempat sebagian besar coverage kasus tepi dan cabang Anda harus tinggal.
Target alami tes unit adalah logika domain/bisnis Anda: kalkulasi dan keputusan murni yang secara inheren tidak butuh I/O: aturan harga, validasi, transisi state, parsing. (Ini memetakan langsung ke lapisan service dari bab controllers-services-repositories; mendorong logika ke fungsi murni di sana persis yang membuatnya bisa di-unit-test di sini.)
// The unit: pure logic, no I/O, trivially testable.
func IsStrongPassword(p string) bool {
if len(p) < 8 { return false }
var hasDigit, hasUpper bool
for _, r := range p {
if r >= '0' && r <= '9' { hasDigit = true }
if r >= 'A' && r <= 'Z' { hasUpper = true }
}
return hasDigit && hasUpper
}
func TestIsStrongPassword(t *testing.T) {
if IsStrongPassword("short1A") { // 7 chars -> too short
t.Error("expected short password to be rejected")
}
if !IsStrongPassword("longEnough9") { // meets all rules
t.Error("expected valid password to be accepted")
}
}
// Run: go test ./... (-v for verbose, -run TestIsStrong to filter)# The unit: pure logic, no I/O, trivially testable.
def is_strong_password(p: str) -> bool:
if len(p) < 8:
return False
return any(c.isdigit() for c in p) and any(c.isupper() for c in p)
def test_rejects_short_password():
assert is_strong_password("short1A") is False # 7 chars -> too short
def test_accepts_valid_password():
assert is_strong_password("longEnough9") is True # meets all rules
# Run: pytest (-v verbose, -k strong to filter by name)Saat sebuah fungsi menyakitkan untuk di-unit-test, ia menjangkau global, membuka koneksi, membaca jam, atau melakukan sepuluh hal, tes itu memberi tahu Anda kodenya difaktorkan buruk, bukan bahwa testing itu sulit. Perbaikannya hampir selalu memisahkan logika murni dari I/O dan menyuntikkan dependensi (bagian 6). Testability dan desain yang baik adalah properti yang sama dilihat dari dua sudut.
Test Double
Unit nyata bergantung pada hal lain, repository, payment gateway, pengirim email. Untuk menguji unit dalam isolasi Anda mengganti kolaborator itu dengan test double: pengganti yang Anda kendalikan. "Mock" dipakai longgar dalam percakapan, tapi kosakata presisi (dari Gerard Meszaros / Martin Fowler) membedakan lima jenis, dan mengetahui perbedaannya mempertajam cara Anda menguji.
| Double | Apa itu | Anda memakainya untuk... |
|---|---|---|
| Dummy | Placeholder yang diteruskan tapi tidak pernah dipakai | memenuhi parameter wajib |
| Stub | Mengembalikan jawaban siap untuk panggilan | memberi unit input tetap (mis. "DB mengembalikan user ini") |
| Spy | Stub yang juga mencatat cara ia dipanggil | menegaskan belakangan bahwa sesuatu dipanggil, dan bagaimana |
| Mock | Diprogram di muka dengan ekspektasi yang ia verifikasi | menegaskan interaksi terjadi (mis. "email dikirim sekali") |
| Fake | Implementasi ringan yang berfungsi | mengganti dependensi nyata dengan murah (mis. DB di memori) |
Verifikasi state vs verifikasi interaksi
Perbedaan terdalam: stub/fake mendukung verifikasi state (Anda menegaskan hasilnya), sementara mock/spy mendukung verifikasi interaksi (Anda menegaskan panggilan terjadi). Prefer verifikasi state, ia kokoh terhadap refactoring. Rengkuh verifikasi interaksi hanya saat interaksi adalah perilakunya (email benar-benar harus dikirim, tagihan harus diterbitkan tepat sekali). Over-mocking interaksi penyebab utama tes rapuh (bagian 20).
// The unit depends on an interface, the seam that lets us substitute a double (sec 6).
type UserRepo interface{ FindByID(id string) (*User, error) }
type Notifier interface{ Send(to, msg string) error }
func Greet(repo UserRepo, n Notifier, id string) error {
u, err := repo.FindByID(id) // collaborator 1
if err != nil { return err }
return n.Send(u.Email, "Hi "+u.Name) // collaborator 2 (interaction we care about)
}
// STUB: returns a canned user. SPY: also records what Send received.
type stubRepo struct{ user *User }
func (s stubRepo) FindByID(string) (*User, error) { return s.user, nil }
type spyNotifier struct{ calls int; lastTo string }
func (s *spyNotifier) Send(to, msg string) error { s.calls++; s.lastTo = to; return nil }
func TestGreet_SendsEmail(t *testing.T) {
repo := stubRepo{user: &User{Email: "a@x.com", Name: "Ada"}} // arrange (stub)
spy := &spyNotifier{}
_ = Greet(repo, spy, "u1") // act
if spy.calls != 1 || spy.lastTo != "a@x.com" { // assert the interaction
t.Errorf("expected one email to a@x.com, got %d to %q", spy.calls, spy.lastTo)
}
}from unittest.mock import Mock
# The unit depends on injected collaborators (sec 6).
def greet(repo, notifier, user_id):
user = repo.find_by_id(user_id) # collaborator 1
notifier.send(user.email, f"Hi {user.name}") # collaborator 2 (the interaction)
def test_greet_sends_email():
# STUB: a Mock configured to return a canned user
repo = Mock()
repo.find_by_id.return_value = User(email="a@x.com", name="Ada")
notifier = Mock() # will record calls (acts as spy/mock)
greet(repo, notifier, "u1") # act
# assert the INTERACTION happened exactly as expected
notifier.send.assert_called_once_with("a@x.com", "Hi Ada")Go cenderung menulis double kecil manual terhadap interface (eksplisit, tanpa sihir); unittest.mock Python membuat double fleksibel yang keduanya stub return dan mencatat panggilan. Konsep sama, ergonomi berbeda.
Hindari mocking library pihak ketiga atau API HTTP eksternal langsung, mock Anda meng-encode asumsi Anda tentang perilaku mereka, yang bisa salah, jadi tes lulus sementara produksi rusak. Gantinya bungkus dependensi di balik interface Anda sendiri dan mock itu, atau pakai fake/tes kontrak (bagian 11, bagian 17). Mock seam Anda sendiri, bukan internal orang lain.
Dependency Injection demi Testability
Teknik tunggal yang membuat unit testing mungkin adalah dependency injection (DI): alih-alih unit membuat atau meraih kolaboratornya, ia menerimanya dari luar. Titik serah itu seam: tempat tes bisa menyelipkan double (bagian 5). Tanpa seam, tanpa isolasi. Inilah kenapa arsitektur controllers-services-repositories bersikeras meneruskan dependensi ke bawah: itu bukan seremoni, itulah yang menjaga kode tetap testable.
// Depend on an INTERFACE, not a concrete type, that interface is the seam.
type OrderRepo interface{ Save(o Order) error }
type OrderService struct{ repo OrderRepo } // injected, not constructed inside
func NewOrderService(r OrderRepo) *OrderService { return &OrderService{repo: r} }
func (s *OrderService) Place(o Order) error {
if o.Total <= 0 { return errors.New("invalid total") } // pure logic, easily tested
return s.repo.Save(o)
}
// In tests: a FAKE in-memory repo (no real DB needed)
type fakeRepo struct{ saved []Order }
func (f *fakeRepo) Save(o Order) error { f.saved = append(f.saved, o); return nil }
func TestPlace_RejectsInvalidTotal(t *testing.T) {
svc := NewOrderService(&fakeRepo{})
if err := svc.Place(Order{Total: 0}); err == nil {
t.Error("expected error for non-positive total")
}
}
// In production: NewOrderService(postgresRepo), same code, real dependency.# Accept the collaborator as a constructor argument, that parameter is the seam.
class OrderService:
def __init__(self, repo): # injected, not created inside
self.repo = repo
def place(self, order):
if order.total <= 0:
raise ValueError("invalid total") # pure logic, easily tested
self.repo.save(order)
# In tests: a FAKE in-memory repo (no real DB needed)
class FakeRepo:
def __init__(self): self.saved = []
def save(self, order): self.saved.append(order)
def test_place_rejects_invalid_total():
svc = OrderService(FakeRepo())
with pytest.raises(ValueError):
svc.place(Order(total=0))
# In production: OrderService(postgres_repo), same code, real dependency.DI, interface kecil, dan memisahkan logika murni dari I/O bukan "trik tes", mereka prinsip yang sama yang membuat kode modular dan mudah dirawat. Codebase yang mudah diuji hampir menurut definisi terstruktur baik. Suite tes sinyal kualitas desain yang berkelanjutan.
Tes Table-Driven & Terparametri
Kebanyakan logika perlu diperiksa terhadap banyak pasangan input/output, happy path, kasus tepi, batas, kasus error. Copy-paste satu tes per kasus itu noise. Kedua bahasa punya idiom untuk "body tes sama, banyak kasus": table-driven test Go dan parametrize pytest. Di sinilah Anda mendapat coverage murah dan padat atas cabang dan tepi, kuda beban sesungguhnya lapisan unit.
func TestApplyDiscount(t *testing.T) {
// The TABLE: each row is one case with a name.
cases := []struct {
name string
subtotal int
percent int
want int
}{
{"ten percent off 200", 200, 10, 180},
{"zero discount", 100, 0, 100},
{"full discount", 100, 100, 0},
{"rounds down", 99, 10, 90}, // boundary/edge case
}
for _, c := range cases {
// t.Run makes each row a named subtest, failures report the case name.
t.Run(c.name, func(t *testing.T) {
got := ApplyDiscount(Cart{Subtotal: c.subtotal}, Coupon{Percent: c.percent})
if got != c.want {
t.Errorf("got %d, want %d", got, c.want)
}
})
}
}
// `go test -run TestApplyDiscount/rounds_down` runs just one case.import pytest
# Each tuple is one case; ids give readable names in the output.
@pytest.mark.parametrize(
"subtotal, percent, expected",
[
(200, 10, 180),
(100, 0, 100),
(100, 100, 0),
(99, 10, 90), # boundary/edge case
],
ids=["ten-off-200", "zero", "full", "rounds-down"],
)
def test_apply_discount(subtotal, percent, expected):
got = apply_discount(Cart(subtotal=subtotal), Coupon(percent=percent))
assert got == expected
# `pytest -k rounds-down` runs just one case; each shows up as its own test.Pembayaran idiom ini bukan hanya lebih sedikit kode, tapi tiap kasus menjadi tes bernama independen, bisa dijalankan independen. Saat satu rusak, laporan menyebut kasus persisnya ("pembulatan-bawah gagal"), dan Anda bisa menjalankan ulang hanya itu. Selalu beri kasus nama deskriptif; baris anonim yang gagal hanya berbunyi "kasus 3," yang tidak membantu siapa pun.
Tes Integrasi
Tes unit membuktikan tiap bagian bekerja sendirian; tes integrasi membuktikan bagian-bagian bekerja bersama, dan yang penting, bahwa mereka berintegrasi benar dengan sistem eksternal nyata yang Anda mock di lapisan unit: database, cache, message broker, layanan lain. Mereka duduk di tengah piramida (bagian 2): lebih lambat dari tes unit karena I/O nyata terlibat, tapi menangkap kategori bug yang secara struktural tidak bisa ditangkap tes unit.
Query SQL dengan kolom typo, mapping ORM yang tidak cocok skema, ketidakcocokan serialisasi antar layanan, migrasi yang tidak jalan, connection string salah, transaksi yang tidak commit. Setiap satu dari ini hidup di seam antara kode Anda dan sistem nyata, persis seam yang di-stub tes unit. Mocking DB tidak bisa mengungkap bahwa query Anda SQL tidak valid; hanya menjalankannya terhadap database nyata yang bisa.
Tes integrasi menyentuh state bersama, jadi rawan flakiness (bagian 14) jika mereka bocor satu sama lain. Tiap tes harus mulai dari state yang dikenal dan membersihkan diri, skema segar, transaksi di-rollback, atau tabel di-truncate (bagian 9). Tes yang bergantung pada database bersama yang di-seed manual, atau pada berjalan dalam urutan tertentu, akhirnya mengkhianati Anda.
Menguji dengan Database Nyata
Pertanyaan berulang: bagaimana mendapat database "nyata" dalam tes tanpa bergantung pada server bersama yang dirawat manual? Jawaban modern Testcontainers: library yang menyalakan database sungguhan dalam container Docker sekali pakai (container bab 21) saat tes, memberi Anda connection string-nya, dan merobohkannya setelahnya. Anda menguji terhadap engine aktual (versi Postgres sama dengan prod), terisolasi penuh, tanpa apa pun yang diinstal. Untuk menjaga tes independen satu sama lain, dua taktik standar transaction rollback (bungkus tiap tes dalam transaksi, roll back di akhir) atau truncate tabel antar tes.
// Build tag keeps slow integration tests out of the fast unit run:
// //go:build integration -> run with: go test -tags=integration ./...
func TestUserRepo_SaveAndFind(t *testing.T) {
ctx := context.Background()
// Arrange: start a REAL Postgres in a throwaway container (testcontainers-go)
pg, err := postgres.Run(ctx, "postgres:16",
postgres.WithDatabase("app"), postgres.WithUsername("u"), postgres.WithPassword("p"))
if err != nil { t.Fatal(err) }
t.Cleanup(func() { pg.Terminate(ctx) }) // torn down automatically after the test
dsn, _ := pg.ConnectionString(ctx, "sslmode=disable")
db, _ := sql.Open("postgres", dsn)
runMigrations(t, db) // apply the same schema as production
repo := NewUserRepo(db)
// Act: exercise the REAL query path
_ = repo.Save(User{ID: "u1", Email: "a@x.com"})
got, err := repo.FindByID("u1")
// Assert: round-trips correctly through actual SQL
if err != nil || got.Email != "a@x.com" {
t.Fatalf("round-trip failed: %v / %+v", err, got)
}
}import pytest
from testcontainers.postgres import PostgresContainer
# A fixture spins up a REAL Postgres once per module, then tears it down.
@pytest.fixture(scope="module")
def db_url():
with PostgresContainer("postgres:16") as pg: # throwaway container
url = pg.get_connection_url()
run_migrations(url) # same schema as production
yield url
# container stopped automatically on exit
@pytest.fixture
def repo(db_url):
# function-scoped: clean state per test (truncate / rollback)
r = UserRepo(db_url)
yield r
r.truncate_all() # keep tests independent (sec 8)
@pytest.mark.integration # marker: pytest -m integration
def test_save_and_find(repo):
repo.save(User(id="u1", email="a@x.com")) # act through REAL SQL
got = repo.find_by_id("u1")
assert got.email == "a@x.com" # round-trips correctlyKeduanya mengisolasikan tes lambat di balik flag/marker (build tag Go, marker pytest) supaya suite unit milidetik tetap cepat dan tes DB yang lebih berat berjalan saat diminta atau di CI (bagian 19).
Mengganti SQLite "karena cepat" saat produksi menjalankan Postgres jebakan klasik: dialek berbeda (tipe, constraint, JSON, upsert, locking), jadi tes bisa lulus di SQLite dan query yang sama gagal di Postgres. Testcontainers ada persis supaya Anda bisa memakai engine produksi aktual dengan murah, uji yang Anda kirim.
Menguji HTTP Handler & API
Handler Anda (bab HTTP dan REST) target integrasi utama: Anda ingin menembakkan request nyata ke stack routing + handler + serialisasi Anda dan menegaskan respons nyatanya, status code, header, body, tanpa mengikat socket atau menjalankan server lewat jaringan. Kedua ekosistem menyediakan test client dalam proses yang menggerakkan handler langsung: net/http/httptest Go dan TestClient yang dikirim FastAPI/Starlette (dan padanan Flask).
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateUser_Returns201(t *testing.T) {
handler := NewRouter(deps) // the REAL router + handlers (deps may use a fake repo sec 6)
// Arrange: build a request and an in-memory response recorder (no network)
body := strings.NewReader(`{"email":"a@x.com","name":"Ada"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
// Act: drive the handler directly
handler.ServeHTTP(rec, req)
// Assert: on the real response, status, then body
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d; want 201; body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Errorf("content-type = %q", ct)
}
}from fastapi.testclient import TestClient
from app import app # the REAL FastAPI app (dependencies may be overridden with fakes sec 6)
client = TestClient(app) # drives the app in-process, no network, no running server
def test_create_user_returns_201():
# Act: fire a real request through routing + handler + serialization
resp = client.post("/api/v1/users", json={"email": "a@x.com", "name": "Ada"})
# Assert: on the real response, status, headers, body
assert resp.status_code == 201
assert resp.headers["content-type"].startswith("application/json")
assert resp.json()["email"] == "a@x.com"
# FastAPI tip: override real dependencies with fakes via app.dependency_overrides
# so the handler runs but the repo is in-memory, an integration test of the web layer.Tes ini fleksibel: rangkai fake repository dan Anda meng-integration-test hanya lapisan web (routing, decoding, status code, amplop error) cepat dan deterministik; rangkai DB nyata via testcontainers (bagian 9) dan Anda punya tes integrasi lebih luas dari seluruh jalur request. Pilih cakupannya sengaja per tes, lebih sempit lebih cepat dan presisi; lebih lebar lebih realistis.
Contract Testing
Di dunia microservice (bab gRPC dan desain API), layanan jarang hidup sendiri, ia mengonsumsi yang lain dan dikonsumsi yang lain. Bahayanya: provider mengubah bentuk responsnya, semua tesnya sendiri lulus, tapi diam-diam memecah setiap consumer. Menguji end-to-end setiap kombinasi sangat lambat. Contract testing menyelesaikannya dengan memverifikasi kedua sisi setuju pada antarmukanya, tanpa menjalankannya bersama.
Tool seperti Pact mengimplementasikan ini: tes consumer mencatat request yang ia buat dan respons yang ia harapkan ke file kontrak; CI provider memutar ulang kontrak itu terhadap provider nyata dan gagal jika tidak lagi berlaku. Untuk layanan gRPC/Protobuf, .proto plus deteksi perubahan yang memecah skema (tooling buf dari bab gRPC) memainkan peran serupa, skema adalah kontrak yang diperiksa mesin, dan CI menolak perubahan tak kompatibel.
Mereka jalan tengah pragmatis antara "mock layanan lain dan berharap mock Anda akurat" (cepat tapi bisa menyimpang dari kenyataan, peringatan bagian 5) dan "nyalakan semuanya dan uji end-to-end" (realistis tapi lambat dan rapuh). Contract test memberi kepercayaan lintas-layanan di kecepatan tes unit, itulah kenapa ia berskala ke armada layanan besar tempat E2E penuh lintas semua layanan tidak praktis.
Tes End-to-End
Tes end-to-end (E2E) menggerakkan sistem yang terakit penuh lewat antarmuka eksternal nyatanya, persis seperti pengguna atau klien, tanpa internal yang di-stub, semuanya berjalan: API, database, cache, layanan dependen. Ia menjawab satu pertanyaan yang tidak bisa dijawab lapisan lebih rendah: apakah semua bagian, dirangkai nyata, benar-benar mengirimkan hasil yang terlihat pengguna? Ia puncak piramida (bagian 2): realisme maksimum, biaya maksimum.
Tes E2E lambat, mahal dirawat, dan paling rawan flake (bagian 14): gangguan di mana pun, jaringan, timing, dependensi, menggagalkannya, dan kegagalan tidak memberi tahu Anda di mana masalahnya. Jadi simpan E2E untuk segenggam perjalanan kritis ("user daftar -> login -> menempatkan order -> pembayaran sukses"). Jangan uji kasus tepi atau setiap cabang di sini, itu milik tes unit cepat (bagian 4). E2E mengonfirmasi sistem tersambung; lapisan lebih rendah mengonfirmasi ia benar.
Kerabat dekatnya smoke test: set kecil pemeriksaan E2E yang dijalankan tepat setelah deploy untuk mengonfirmasi sistem hidup ("apakah /health hijau? bisakah aku login?"), berpasangan alami dengan health probe dan rollout bab 21. Tim matang memperluasnya dengan testing in production: synthetic monitoring, analisis canary (bagian 20 di sana), dan feature flag, karena sebagian sifat hanya menampakkan diri di bawah lalu lintas nyata.
Test Coverage
Coverage mengukur berapa banyak kode Anda yang dieksekusi tes, biasanya persentase baris atau cabang yang dijalankan selama suite. Ia berguna sebagai senter untuk menemukan kode yang tidak diuji sama sekali, tapi sangat mudah salah dibaca sebagai ukuran kualitas tes. Ia bukan. Coverage memberi tahu apa yang dieksekusi, tidak pernah apa yang ditegaskan secara bermakna.
# Built into the toolchain, no extra dependency.
go test -cover ./... # prints % per package
go test -coverprofile=cover.out ./... # write a detailed profile
go tool cover -func=cover.out # per-function breakdown
go tool cover -html=cover.out # open an annotated, line-by-line HTML view# Via the coverage.py / pytest-cov plugin.
pip install pytest-cov
pytest --cov=app # coverage for the `app` package
pytest --cov=app --cov-report=term-missing # show exactly which lines are UNcovered
pytest --cov=app --cov-report=html # browsable htmlcov/ report
pytest --cov=app --cov-branch # branch coverage, not just lineLine vs branch coverage
Line coverage bertanya "apakah baris ini berjalan?" Branch coverage bertanya pertanyaan lebih sulit "apakah setiap arah dari setiap keputusan diambil?" Satu tes bisa memukul 100% baris dari sebuah if sementara hanya pernah mengambil cabang true, jalur else tidak tereksekusi dan tidak teruji meski line coverage tampak lengkap. Branch coverage angka yang lebih jujur; prefer itu.
Anda bisa mencapai 100% coverage dengan tes yang menegaskan tidak ada apa pun, cukup panggil setiap fungsi dan periksa tidak ada exception. Tercover penuh, sama sekali tak bernilai. Begitu persentase coverage menjadi target, orang mempermainkannya dengan tes tanpa assertion (hukum Goodhart: ukuran yang menjadi target berhenti menjadi ukuran yang baik). Pakai coverage untuk menemukan area tak teruji yang layak perhatian; nilai kualitas dengan apakah tes menegaskan perilaku bermakna (bagian 3, bagian 20). Coverage tinggi dengan assertion lemah kepercayaan palsu.
Tes Flaky
Tes flaky lulus kadang dan gagal lain kali tanpa perubahan kode apa pun. Flakiness korosif: ia melatih tim mengabaikan build merah ("tinggal jalankan ulang"), dan begitu orang berhenti memercayai suite, suite mati, run hijau tidak lagi berarti apa-apa. Tes flaky boleh dibilang lebih buruk daripada tidak ada tes, karena ia menghabiskan waktu dan mengikis kepercayaan yang memberi nilai bagi tes (bagian 1).
| Penyebab flakiness | Perbaikan |
|---|---|
| Waktu / jam: "sekarang", timeout, sleep, batas tanggal | Suntikkan jam; tegaskan rentang bukan instan persis (bagian 17). Jangan pernah sleep untuk "menunggu"; poll sebuah kondisi. |
| Ketergantungan urutan tes: satu tes mengandalkan sisa tes lain | Buat tiap tes men-set up & tear down state-nya sendiri (bagian 9, bagian 15). Jalankan urutan acak untuk membuang yang begini. |
| State mutable bersama: global, baris DB bersama, singleton | Isolasikan per tes; hindari state global; pakai fixture segar. |
| Konkurensi / race: goroutine, thread, timing async | Sinkronkan pada sinyal bukan timing; jalankan dengan race detector (go test -race). |
| Jaringan nyata / API eksternal: latensi, rate limit, downtime | Mock batasnya (bagian 17); simpan panggilan nyata ke set E2E kecil yang toleran. |
Data tak terurut: iterasi map, baris DB tanpa ORDER BY | Sortir sebelum membandingkan, atau tegaskan keanggotaan set bukan urutan. |
Setiap tes flaky non-determinisme tersembunyi, waktu, urutan, konkurensi, atau state bersama yang bocor masuk. Perbaikannya selalu menghapus sumber keacakan: kendalikan jam, isolasi state, sinkronkan pada peristiwa alih-alih sleep, dan stub jaringan. Karantina tes flaky (tandai dan lewati dari run gating) hanya sebagai langkah sementara selagi menemukan penyebab sebenarnya, jangan pernah menutupinya dengan "retry sampai hijau" yang menyeluruh, itu hanya menyembunyikan bug.
Fixture & Factory
Tes butuh data dan state awal yang dikenal, dan cara Anda membangunnya menentukan apakah suite tetap terbaca atau membusuk jadi setup soup. Dua tool terkait: fixture mengelola setup dan teardown (dunia tempat tes berjalan, koneksi DB, dir temp, klien yang login), dan factory membangun objek tes dengan default masuk akal supaya tiap tes hanya menyatakan field yang benar-benar ia pedulikan.
// Go favors explicit helpers over magic. t.Helper() keeps failure line numbers useful.
// A "factory" with functional options: specify only the fields that matter to the test.
func newUser(t *testing.T, opts ...func(*User)) User {
t.Helper()
u := User{ID: "u1", Email: "default@x.com", Active: true} // sensible defaults
for _, o := range opts { o(&u) }
return u
}
func withEmail(e string) func(*User) { return func(u *User) { u.Email = e } }
// A "fixture" via t.Cleanup: setup returns the thing + auto-teardown.
func newTempStore(t *testing.T) *Store {
t.Helper()
dir := t.TempDir() // auto-removed after the test
s := OpenStore(dir)
t.Cleanup(func() { s.Close() }) // teardown runs even if the test fails
return s
}
func TestSignup(t *testing.T) {
store := newTempStore(t)
u := newUser(t, withEmail("ada@x.com")) // only the relevant field is stated
_ = store.Save(u)
// ...assert...
}import pytest
# pytest FIXTURES: setup before `yield`, teardown after. Injected by parameter name.
@pytest.fixture
def temp_store(tmp_path): # tmp_path is a built-in fixture (auto-cleaned dir)
store = Store(tmp_path)
yield store # the test runs here
store.close() # teardown, runs even if the test fails
# A FACTORY: defaults + overrides, so tests state only what they care about.
def make_user(**overrides):
return User(**{"id": "u1", "email": "default@x.com", "active": True, **overrides})
def test_signup(temp_store):
user = make_user(email="ada@x.com") # only the relevant field is stated
temp_store.save(user)
# ...assert...
# Libraries like factory_boy / model_bakery scale this up for complex object graphs.Inti factory menyembunyikan setup yang tidak relevan, bukan menyembunyikan data yang diandalkan tes. Jika tes menegaskan sesuatu tentang user premium, kata "premium" harus muncul di tes itu (make_user(tier="premium")), bukan terkubur di fixture bersama tiga file jauhnya. Pembaca harus memahami premis tes tanpa menggali. Fixture bersama yang buram dan di-share berlebihan ("mystery guest") membuat kegagalan sulit didiagnosis dan tes sulit dipercaya.
TDD: Red / Green / Refactor
Test-Driven Development membalik urutan biasa: Anda menulis tes gagal dulu, lalu kode minimum untuk meloloskan, lalu membersihkan, mengulang dalam siklus kecil. Disiplinnya loop tiga ketukan yang terkenal: Red (tulis tes, lihat gagal), Green (buat lulus sesederhana mungkin), Refactor (perbaiki desain sementara tes tetap hijau). Menulis tes dulu memaksa Anda mendefinisikan perilaku yang diinginkan dan antarmuka bersih sebelum Anda terjerat implementasi.
Manfaat nyata TDD orde kedua: ia menghasilkan kode yang testable by construction (Anda tidak bisa menulis unit yang tak teruji jika tesnya datang dulu), ia menjaga Anda fokus pada satu perilaku kecil pada satu waktu, dan langkah "refactor" aman justru karena tes yang barusan Anda tulis menjaganya. Ia teknik desain sekaligus teknik pengujian.
TDD bersinar saat kebutuhan jelas dan logika nontrivial (parser, aturan bisnis, algoritma). Ia canggung saat Anda menjelajah, spiking prototipe, membuat sketsa UI, menyelidiki API asing, tempat desainnya belum diketahui. Banyak engineer hebat menulis tes segera setelah kode alih-alih ketat sebelum. Yang penting perilakunya akhirnya teruji baik; test-first satu jalur efektif menuju itu, bukan satu-satunya yang valid.
Mocking Waktu, Keacakan & API Eksternal
Tiga sumber non-determinisme merusak tes jika dibiarkan nyata, jam, generator acak, dan jaringan, dan mereka tersangka biasa di balik flakiness (bagian 14). Obat untuk ketiganya sama: ubah dependensi tersembunyi menjadi yang disuntikkan (bagian 6) supaya tes mengendalikannya.
Waktu & keacakan: suntikkan keduanya
Kode yang memanggil time.Now() atau random() langsung tidak testable dan flaky, keluarannya berubah tiap run. Jadikan jam dan RNG dependensi yang diterima unit, lalu berikan jam tetap (selalu mengembalikan instan yang sama) atau RNG ber-seed di tes. Sekarang "token kedaluwarsa dalam 1 jam" atau "pilih shard acak" sepenuhnya deterministik dan bisa ditegaskan.
HTTP eksternal: stub batasnya
Jangan pernah memukul API pihak ketiga nyata dalam tes unit/integrasi, ia lambat, flaky, kena rate-limit, dan mungkin punya efek samping. Kedua ekosistem membiarkan Anda mencegat HTTP secara lokal: httptest.Server Go mendirikan server palsu yang Anda arahkan klien Anda; responses/respx Python menambal lapisan HTTP untuk mengembalikan respons siap. (Dan ingat bagian 5: prefer membungkus API di balik interface Anda sendiri supaya kebanyakan tes mock itu, menyisakan stub level HTTP untuk menguji adapter klien itu sendiri.)
// 1) Injected clock, deterministic time.
type Clock interface{ Now() time.Time }
type fixedClock struct{ t time.Time }
func (f fixedClock) Now() time.Time { return f.t }
func TestTokenExpiry(t *testing.T) {
clk := fixedClock{t: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)} // frozen
tok := NewToken(clk, time.Hour)
if tok.ExpiresAt != clk.Now().Add(time.Hour) {
t.Error("expiry not computed from the injected clock")
}
}
// 2) Stub an external HTTP API with httptest.Server, no real network.
func TestFetchRate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte(`{"usd_inr": 83.2}`)) // canned response
}))
defer srv.Close()
client := NewRatesClient(srv.URL) // point the client at the fake server's URL
rate, _ := client.USDINR()
if rate != 83.2 { t.Errorf("got %v", rate) }
}import responses
from datetime import datetime, timezone
# 1) Injected clock, deterministic time (freezegun is a popular alternative).
def test_token_expiry():
frozen = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)
tok = make_token(now=lambda: frozen, ttl=3600) # clock injected as a callable
assert tok.expires_at == frozen.timestamp() + 3600
# 2) Stub external HTTP with `responses`, patches the HTTP layer, no real network.
@responses.activate
def test_fetch_rate():
responses.add(
responses.GET, "https://api.rates.com/usd-inr",
json={"usd_inr": 83.2}, status=200, # canned response
)
client = RatesClient("https://api.rates.com")
assert client.usd_inr() == 83.2Tes harus menghasilkan hasil sama di setiap run, di setiap mesin, urutan apa pun. Begitu waktu nyata, keacakan nyata, atau jaringan nyata menyusup, Anda menanam kegagalan flaky masa depan. Suntikkan dan kendalikan ketiganya, ini seam DI yang sama dari bagian 6 diterapkan ke tiga hal yang berubah sendiri.
Performance & Load Testing
Tes fungsional membuktikan kode benar; tes performa membuktikan ia cukup cepat, sumbu yang sepenuhnya berbeda, dan yang sangat penting bagi kekhawatiran scaling bab 18-19. Dua rasa berbeda: benchmark (mikro: seberapa cepat fungsi/endpoint ini?) dan load test (makro: bagaimana seluruh sistem berperilaku di bawah N pengguna serentak?).
| Jenis | Pertanyaan | Tool |
|---|---|---|
| Benchmark | Seberapa cepat / berapa alokasi untuk jalur kode ini? | Go testing.B; Python pytest-benchmark |
| Load test | Throughput & latensi di bawah lalu lintas serentak berkelanjutan? | k6, Locust, Gatling, vegeta |
| Stress test | Di mana ia pecah, dan bagaimana ia gagal? | tool sama, dinaikkan melewati kapasitas |
| Soak test | Apakah ia menurun selama berjam-jam (bocor, fragmentasi)? | tool sama, durasi panjang |
// Benchmarks live beside tests; the tool tunes b.N until timing is stable.
func BenchmarkParseEvent(b *testing.B) {
payload := []byte(`{"type":"click","ts":1717000000}`)
b.ReportAllocs() // also report allocations/op
for i := 0; i < b.N; i++ { // the loop the framework times
_, _ = ParseEvent(payload)
}
}
// Run: go test -bench=. -benchmem
// BenchmarkParseEvent-8 3142051 382 ns/op 96 B/op 2 allocs/op
// Compare runs with `benchstat` to catch performance REGRESSIONS over time.# pip install pytest-benchmark, the `benchmark` fixture times the callable.
def test_parse_event_perf(benchmark):
payload = b'{"type":"click","ts":1717000000}'
result = benchmark(parse_event, payload) # runs it many times, reports stats
assert result.type == "click" # still assert correctness
# Output reports min/mean/median/stddev; --benchmark-compare flags regressions.import http from 'k6/http';
import { check } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 100 }, // ramp up to 100 virtual users
{ duration: '1m', target: 100 }, // hold
{ duration: '30s', target: 0 }, // ramp down
],
thresholds: {
http_req_duration: ['p(95)<300'], // 95th-percentile latency must stay < 300ms
http_req_failed: ['rate<0.01'], // error rate must stay under 1%
},
};
export default function () {
const res = http.get('https://staging.example.com/api/v1/health');
check(res, { 'status 200': (r) => r.status === 200 });
}
# Run: k6 run load_test.js, fails the build if a threshold is breached.
Rata-rata bohong, mean 50ms bisa menyembunyikan p99 2 detik yang merusak pengguna nyata. Selalu lacak latensi ekor (p95/p99). Dan jalankan load test terhadap lingkungan mirip-produksi, jangan pernah mesin dev Anda, dan nilai hasilnya terhadap SLO eksplisit (ambangnya), bukan getaran. Benchmark atau load test tanpa angka target cuma trivia.
Tes di CI/CD
Suite tes hanya bernilai jika ia berjalan otomatis, di setiap perubahan, sebagai gerbang. Di sinilah testing bertemu pipeline bab 21 (bagian 19 di sana): di setiap push, CI menjalankan tes, dan kegagalan memblokir merge atau deploy. Inti gradien kecepatan piramida (bagian 2) memberi umpan balik cepat di sini, jalankan tes unit milidetik di setiap commit, tes integrasi/E2E yang lebih berat di tahap yang tepat.
name: tests
on: [push, pull_request]
jobs:
unit: # fast, runs on every push
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: go test -race -cover ./... # Go: race detector + coverage
# - run: pytest -m "not integration" --cov # Python: skip slow tests here
integration: # heavier, real DB via services / testcontainers
runs-on: ubuntu-latest
needs: unit # only if unit tests passed
services:
postgres:
image: postgres:16
env: { POSTGRES_PASSWORD: p }
ports: ["5432:5432"]
steps:
- uses: actions/checkout@v4
- run: go test -tags=integration ./... # or: pytest -m integration
# A failed job fails the check -> branch protection blocks the merge.
Jika tes lambat, orang melewatkannya, jadi kecepatan fitur. Jalankan tes paralel (Go melakukannya lintas paket secara default dan di dalam tes via t.Parallel(); pytest via pytest-xdist), pisahkan gerbang unit cepat dari tahap integrasi/E2E lambat, dan cache dependensi. Suite yang kembali dalam beberapa menit dijalankan; yang makan tiga puluh dilewati, dan tes yang tidak dijalankan tidak melindungi apa pun.
Strategi Pengujian
Tes punya biaya, untuk ditulis, dijalankan, dan dirawat saat kode berubah, jadi tujuannya tidak pernah "uji semuanya" melainkan kepercayaan maksimum per unit usaha. Strategi koheren kebanyakan set keputusan penilaian tentang apa yang diuji, di lapisan mana, dan apa yang dibiarkan.
Apa yang diuji berat
- Logika & aturan bisnis: harga, validasi, state machine, apa pun dengan cabang dan kasus tepi. Tes unit murah cepat; pembayaran tertinggi (bagian 4, bagian 7).
- Bug yang pernah Anda perbaiki: setiap perbaikan dapat tes regresi supaya tidak kembali (bagian 1).
- Jalur kritis & uang: auth, pembayaran, integritas data dapat coverage di banyak lapisan, termasuk E2E tipis (bagian 12).
- Seam integrasi: query Anda, serialisasi, dan kontrak antar layanan (bagian 8-11), tempat bug halus bersembunyi.
Apa yang diuji ringan atau tidak sama sekali
- Kode sepele: getter polos, lem framework, kode hasil generasi. Mengujinya usaha tanpa wawasan.
- Library pihak ketiga: asumsikan bekerja; uji pemakaian Anda terhadapnya, bukan internalnya (bagian 5).
- Detail implementasi: helper privat dan urutan panggilan persis. Uji perilaku teramati supaya refactor tidak memicu kegagalan palsu.
Prinsip tunggal yang memisahkan suite yang membantu dari yang menyakiti: tegaskan apa yang dilakukan kode (input->output teramati dan efek sampingnya), jangan pernah bagaimana ia melakukannya secara internal. Tes fokus-perilaku selamat dari refactoring, mereka hijau selama kontrak berlaku, persis kepercayaan-untuk-berubah yang membenarkan tes sejak awal (bagian 1). Tes terkopel-implementasi pecah di setiap restrukturisasi, menghukum perbaikan, dan perlahan dihapus. Tulis tes yang akan disyukuri diri masa depan Anda, bukan dikutuk.
Crib-Sheet
Seluruh manual dipadatkan menjadi yang Anda raih saat tertekan.
| Konsep | Satu baris |
|---|---|
| Kenapa tes | Kepercayaan untuk berubah, spesifikasi yang bisa dieksekusi, jaring regresi, umpan balik cepat. |
| Piramida | Banyak unit (cepat) / beberapa integrasi / sedikit E2E (lambat). Jangan membaliknya. |
| AAA | Arrange, Act, Assert, satu perilaku per tes, nama deskriptif. |
| Tes unit | Satu unit terisolasi, tanpa I/O, milidetik, presisi. |
| Test double | Dummy / Stub / Spy / Mock / Fake. Prefer state di atas verifikasi interaksi. |
| Jangan mock | Yang tidak Anda miliki, bungkus di balik interface Anda sendiri dan mock itu. |
| DI | Suntikkan kolaborator -> seam tempat double menyelip. Tak teruji = bau desain. |
| Table/parametrize | Satu body tes, banyak kasus bernama, coverage kasus tepi padat. |
| Integrasi | DB/deps nyata dirangkai, menangkap bug SQL, mapping, serialisasi. |
| DB nyata | Testcontainers: engine sungguhan, sekali pakai, terisolasi. Bukan SQLite-untuk-Postgres. |
| Tes HTTP | httptest / TestClient, gerakkan handler dalam proses, tanpa socket. |
| Tes kontrak | Verifikasi provider/consumer setuju pada antarmuka, tanpa menjalankan keduanya (Pact, buf). |
| E2E | Seluruh sistem lewat antarmuka nyata, hanya perjalanan kritis; smoke-test setelah deploy. |
| Coverage | Menemukan kode tak teruji; bukan ukuran kualitas. Prefer branch; jangan pernah target (Goodhart). |
| Flaky | Non-determinisme: waktu, urutan, state bersama, konkurensi, jaringan. Obat = determinisme. |
| Fixture/factory | Kelola setup/teardown & bangun data dengan default; jaga data relevan terlihat. |
| TDD | Red -> Green -> Refactor. Tool desain; pakai di tempatnya, bukan dogmatis. |
| Determinisme | Suntikkan jam, seed RNG, stub jaringan, kendalikan ketiganya. |
| Perf | Benchmark (mikro) + load test (makro). Lacak p95/p99 terhadap SLO, bukan rata-rata. |
| Gerbang CI | Tes berjalan di setiap push dan memblokir merge/deploy. Jaga cepat & paralel. |
| Strategi | Kepercayaan per usaha. Uji logika, bug, uang, seam; lewati sepele & internal. |
| Aturan emas | Uji perilaku, bukan implementasi: selamat dari refactoring. |
Seluruh topik dalam satu tarikan napas: tes otomatis ada untuk memberi kepercayaan mengubah kode (bagian 1), dan bentuk strategisnya piramida, banyak tes unit cepat, lebih sedikit integrasi, lapisan E2E tipis (bagian 2). Setiap tes Arrange-Act-Assert pada satu perilaku (bagian 3). Tes unit mengisolasi unit (bagian 4) memakai test double (bagian 5) yang dimungkinkan dependency injection (bagian 6), dengan kasus table-driven dan terparametri untuk coverage padat (bagian 7). Tes integrasi merangkai dependensi nyata (bagian 8), database nyata via testcontainers (bagian 9), handler via klien dalam proses (bagian 10), dan kesepakatan antar-layanan via tes kontrak (bagian 11). E2E hanya mencakup perjalanan kritis (bagian 12); coverage senter bukan tujuan (bagian 13); flakiness non-determinisme yang harus dihilangkan (bagian 14); dan fixture serta factory menjaga setup bersih (bagian 15). TDD menggerakkan desain dalam loop red/green/refactor (bagian 16), determinisme datang dari menyuntikkan waktu, keacakan, dan jaringan (bagian 17), performa sumbu sendiri dari benchmark dan load test terhadap SLO (bagian 18), dan suite hanya membayar upah dengan berjalan sebagai gerbang cepat di CI/CD (bagian 19). Yang terpenting: uji perilaku, bukan implementasi (bagian 20).
Berpijak pada dokumentasi Go testing & pytest / Testcontainers / literatur testing-pyramid (Cohn, Fowler) / contoh Go 1.22+ / Python 3.11+.