Playwright Academy
0 dari 17
Fase 4 · Level ProModul 13 dari 17

API Testing

Tes REST API lewat studi kasus nyata di tiga proyek (semuanya Bearer token), kontrak OpenAPI dan contract test, hybrid testing, dan network mocking.

UI test meniru pengguna tapi lambat. API test langsung bicara ke server, cepat dan stabil. Gabungan keduanya disebut hybrid testing: siapkan data lewat API, lalu cek pengalaman pengguna lewat UI.

Di dunia QA

Seperti mengetes lewat Postman, tapi otomatis dan bisa diulang. Hybrid testing seperti menyiapkan data lewat back office, lalu mengecek tampilannya dari sisi pengguna.

Kenalan singkat dengan HTTP dan REST

Setiap request punya method (jenis permintaan), dan server menjawab dengan status code yang menunjukkan hasilnya.

MethodGunanyaContoh di TokoQA
GETMengambil dataGET /api/products
POSTMengirim atau membuat data baruPOST /api/login, POST /api/cart
PUTMengganti seluruh dataPUT /api/addresses/1
PATCHMengubah sebagian dataPATCH /api/addresses/1
DELETEMenghapus dataDELETE /api/cart/2
StatusArtinya
200 OK / 201 Created / 204 No ContentBerhasil / berhasil membuat data baru / berhasil, tanpa isi balasan
400 Bad RequestPermintaannya tidak sesuai format
401 UnauthorizedBelum login, atau token-nya salah
403 ForbiddenSudah dikenali, tapi tidak punya izin
404 Not FoundData atau URL-nya tidak ada
409 ConflictBentrok dengan kondisi data (misalnya stok habis)
410 GoneDulu ada, sekarang sudah tidak berlaku
422 Unprocessable ContentFormatnya benar, tapi isinya tidak valid
429 Too Many RequestsTerlalu banyak percobaan, tunggu sesuai Retry-After
500 Server ErrorAda yang rusak di server, hampir selalu bug

API TokoQA yang bisa kamu tes

Daftar lengkap dan tempat mencobanya ada di API Lab. Ringkasnya:

EndpointButuh token?Keterangan
POST /api/loginTidakbody {username, password} → {token, user}. Opsional ttl (detik)
GET /api/productsTidakopsional q, category, sort, page, limit
GET /api/products/:idTidakdetail produk atau 404
GET /api/meYaprofil user dari token
GET · POST · DELETE /api/cartYa, customerkeranjang milik sesi token
DELETE /api/cart/:productIdYa, customerhapus satu item
POST /api/coupons/applyYa, customercek kupon, maksimal 5 percobaan per menit
GET · POST /api/orders, GET /api/orders/:idYa, customerriwayat, buat pesanan (kupon opsional), detail beserta status
GET · POST /api/addresses, GET · PUT · PATCH · DELETE /api/addresses/:idYa, customertambah, lihat, ubah, hapus alamat pengiriman
GET /api/admin/orders, GET /api/admin/summaryYa, adminpesanan semua customer dan ringkasan toko
PATCH /api/admin/orders/:idYa, adminubah status: Baru, Diproses, Dikirim, Selesai, Dibatalkan
GET /api/slow?ms=2000Tidaksengaja dibuat lambat, untuk latihan timeout

Endpoint belanja menolak token admin dengan 403, dan endpoint admin menolak token customer dengan 403. Buka /api/products di browser untuk melihat data JSON-nya.

Request pertama

def test_get_semua_produk(playwright, base_url):  # playwright dan base_url adalah fixture bawaan
    api = playwright.request.new_context(base_url=base_url)  # klien API tanpa browser
    res = api.get("/api/products")  # GET daftar produk

    expect(res).to_be_ok()                 # status 2xx
    body = res.json()  # isi respons
    assert body["total"] == 8  # ada 8 produk
    api.dispose()  # tutup klien API
test('GET semua produk', async ({ request }) => { // request = klien API bawaan Playwright Test
  // fixture `request` otomatis memakai baseURL dari config
  const res = await request.get('/api/products');

  await expect(res).toBeOK();              // status 2xx
  const body = await res.json(); // isi respons
  expect(body.total).toBe(8); // ada 8 produk
});

Supaya tidak perlu menulis new_context dan dispose di setiap test, bungkus jadi fixture dengan yield (lihat fixture api di file asli di bawah).

POST, query params, dan header token

# Query string: /api/products?q=mouse
res = api.get("/api/products", params={"q": "mouse"})

# POST JSON: dict otomatis dikirim sebagai JSON
res = api.post("/api/login", data={"username": "customer", "password": "rahasia123"})
token = res.json()["token"]  # token dari respons login

# Endpoint yang butuh login: kirim token di header Authorization
res = api.post(
    "/api/cart",
    data={"productId": 2, "qty": 2},
    headers={"Authorization": f"Bearer {token}"},
)
assert res.status == 201  # dibuat (201)
// Query string: /api/products?q=mouse
let res = await request.get('/api/products', { params: { q: 'mouse' } });

// POST JSON: object otomatis dikirim sebagai JSON
res = await request.post('/api/login', { data: { username: 'customer', password: 'rahasia123' } });
const { token } = await res.json(); // token dari respons login

// Endpoint yang butuh login: kirim token di header Authorization
res = await request.post('/api/cart', {
  data: { productId: 2, qty: 2 },
  headers: { Authorization: `Bearer ${token}` },
});
expect(res.status()).toBe(201); // dibuat (201)

Token dari /api/login berformat JWT: header.payload.signature. Bagian payload berisi username dan waktu kedaluwarsa, dan bisa dilihat di jwt.io. Jangan tempel token dari aplikasi sungguhan ke situs mana pun.

Validasi bentuk respons dengan JSON Schema

Mengecek satu atau dua field belum cukup, misalnya kalau price tiba-tiba dikirim sebagai teks. JSON Schema menuliskan bentuk data yang benar, lalu library mengecek apakah respons cocok.

from jsonschema import validate   # uv add jsonschema

PRODUCT_SCHEMA = {  # bentuk produk yang diharapkan (JSON Schema)
    "type": "object",
    "required": ["id", "name", "category", "price", "stock"],
    "properties": {
        "id": {"type": "integer"},
        "name": {"type": "string"},
        "category": {"enum": ["Elektronik", "Furnitur", "Aksesoris"]},
        "price": {"type": "integer", "minimum": 0},
        "stock": {"type": "integer", "minimum": 0},
    },
}

validate(instance=res.json(), schema=PRODUCT_SCHEMA)   # error jelas kalau tidak cocok
import { Ajv } from 'ajv';   // npm i -D ajv

const productSchema = { // bentuk produk yang diharapkan (JSON Schema)
  type: 'object',
  required: ['id', 'name', 'category', 'price', 'stock'],
  properties: {
    id: { type: 'integer' },
    name: { type: 'string' },
    category: { enum: ['Elektronik', 'Furnitur', 'Aksesoris'] },
    price: { type: 'integer', minimum: 0 },
    stock: { type: 'integer', minimum: 0 },
  },
};

const validate = new Ajv().compile(productSchema); // siapkan validator dari schema
expect(validate(await res.json()), JSON.stringify(validate.errors)).toBe(true); // cocok dengan schema, kalau gagal tampilkan error-nya

File asli: API testing

tests/academy/test_m13_api.pytests/academy/m13-api.spec.tsLulus test
Memuat tests/academy/test_m13_api.py…
uv run pytest tests/academy/test_m13_api.py -v
Memuat tests/academy/m13-api.spec.ts…
npx playwright test m13-api --project=chromium

Hybrid testing: API + UI

Tiga cara gabungan yang paling sering dipakai:

CaraContohUntungnya
Login lewat APIAmbil token, lalu pasang ke browserHemat beberapa detik per test, dan tidak bergantung pada form login
Siapkan data lewat APIIsi keranjang lewat API, lalu cek tampilannya di UITahap persiapan jadi cepat dan stabil
Cek hasil lewat APICheckout lewat UI, lalu cek pesanannya lewat APIMemastikan data benar-benar tersimpan

TokoQA menyimpan token di localStorage dengan kunci tokoqa_token. Untuk melewati form login, taruh token di sana sebelum halaman dimuat dengan add_init_script / addInitScript.

token = api.post("/api/login", data={...}).json()["token"]  # login lewat API

page.add_init_script(f"window.localStorage.setItem('tokoqa_token', '{token}')")  # simpan token sebelum halaman dimuat
page.goto("/shop.html")          # langsung masuk, tanpa form login!
expect(page.get_by_test_id("greeting")).to_have_text("Halo, Rina Wulandari")  # browser sudah login tanpa form
const { token } = await (await request.post('/api/login', { data: { /* ... */ } })).json(); // login lewat API

await page.addInitScript((t) => window.localStorage.setItem('tokoqa_token', t), token); // simpan token sebelum halaman dimuat
await page.goto('/shop.html');   // langsung masuk, tanpa form login!
await expect(page.getByTestId('greeting')).toHaveText('Halo, Rina Wulandari'); // browser sudah login tanpa form
  • Aplikasi yang menyimpan sesi di cookie (bukan di localStorage) memakai context.add_cookies([...]) / context.addCookies([...]).
  • Tetap sisakan minimal satu test smoke untuk alur login lewat UI.

Network mocking: mengendalikan respons server

page.route meng-intercept request dari browser lalu membalasnya sendiri. Gunanya:

  • Menguji tampilan saat server error atau datanya kosong, yang susah dibuat di server asli.
  • Tetap bisa menguji tampilan walaupun backend belum siap atau sedang tidak stabil.
  • Memblokir hal yang tidak perlu (iklan, analytics) supaya test lebih cepat.
# Balas dengan data mock
page.route("**/api/products", lambda route: route.fulfill(
    json={"total": 1, "data": [{"id": 99, "name": "Produk Palsu"}]}
))

# Simulasi server error
page.route("**/api/products", lambda route: route.fulfill(status=500, json={"error": "boom"}))

# Blokir gambar
page.route("**/*.{png,jpg,jpeg,webp}", lambda route: route.abort())

# Ubah sebagian respons asli
def diskon(route):
    response = route.fetch()  # teruskan request ke server sungguhan
    body = response.json()  # isi respons asli
    body["data"][0]["price"] = 1  # ubah harga produk pertama
    route.fulfill(response=response, json=body)  # kirim respons yang sudah diubah ke halaman

page.route("**/api/products", diskon)  # pasang pencegat untuk /api/products
// Balas dengan data mock
await page.route('**/api/products', (route) =>
  route.fulfill({ json: { total: 1, data: [{ id: 99, name: 'Produk Palsu' }] } }), // balas dengan data palsu
);

// Simulasi server error
await page.route('**/api/products', (route) => route.fulfill({ status: 500, json: { error: 'boom' } }));

// Blokir gambar
await page.route('**/*.{png,jpg,jpeg,webp}', (route) => route.abort());

// Ubah sebagian respons asli
await page.route('**/api/products', async (route) => {
  const response = await route.fetch();
  const body = await response.json(); // isi respons asli
  body.data[0].price = 1; // ubah harga produk pertama
  await route.fulfill({ response, json: body }); // kirim respons yang sudah diubah ke halaman
});

File asli: hybrid & mocking

tests/academy/test_m13_hybrid.pytests/academy/m13-hybrid.spec.tsLulus test
Memuat tests/academy/test_m13_hybrid.py…
uv run pytest tests/academy/test_m13_hybrid.py -v
Memuat tests/academy/m13-hybrid.spec.ts…
npx playwright test m13-hybrid --project=chromium

Studi kasus API

Tujuh situasi yang sering ditemui di pekerjaan nyata. Buka satu per satu, coba manual di API Lab, lalu lihat versi otomatisnya di file test di akhir bagian ini.

Untuk setiap endpoint, tanyakan empat hal: kalau semua benar, kalau datanya salah, kalau tidak punya izin, dan kalau sampai di batas (halaman terakhir, stok habis, batas percobaan).

Studi kasus 1: CRUD alamat pengiriman201 · 200 · 204 · 404

Pengguna bisa menyimpan sampai 5 alamat. Kita uji satu data dari lahir sampai dihapus: Create (buat), Read (baca), Update (ubah), Delete (hapus).

LangkahRequestYang dicek
BuatPOST /api/addresses201, ada header Location, alamat pertama otomatis jadi alamat utama
BacaGET /api/addresses/{id}datanya sama dengan yang dikirim
Ganti semuaPUT /api/addresses/{id}semua field wajib dikirim, hasilnya 200
Ubah sebagianPATCH /api/addresses/{id}hanya field yang dikirim yang berubah
HapusDELETE /api/addresses/{id}204 tanpa isi, lalu GET jadi 404
res = api.post("/api/addresses", data=ALAMAT_RUMAH, headers=auth)  # Create
assert res.status == 201  # dibuat (201)
alamat = Address.model_validate(res.json())          # Pydantic: validasi + objek bertipe
assert res.headers["location"] == f"/api/addresses/{alamat.id}"  # header Location menunjuk alamat baru

res = api.patch(f"/api/addresses/{alamat.id}", data={"label": "Rumah Ibu"}, headers=auth)  # Patch: ubah sebagian
assert res.json()["label"] == "Rumah Ibu"  # labelnya berubah

assert api.delete(f"/api/addresses/{alamat.id}", headers=auth).status == 204  # Delete: 204
assert api.get(f"/api/addresses/{alamat.id}", headers=auth).status == 404  # sesudah dihapus: 404
let res = await request.post('/api/addresses', { data: ALAMAT_RUMAH, headers: auth }); // Create
expect(res.status()).toBe(201); // dibuat (201)
const alamat = Address.parse(await res.json());      // Zod: validasi + tipe TypeScript
expect(res.headers()['location']).toBe(`/api/addresses/${alamat.id}`); // header Location menunjuk alamat baru

res = await request.patch(`/api/addresses/${alamat.id}`, { data: { label: 'Rumah Ibu' }, headers: auth }); // Patch: ubah sebagian
expect((await res.json()).label).toBe('Rumah Ibu'); // labelnya berubah

expect((await request.delete(`/api/addresses/${alamat.id}`, { headers: auth })).status()).toBe(204); // Delete: 204
expect((await request.get(`/api/addresses/${alamat.id}`, { headers: auth })).status()).toBe(404); // sesudah dihapus: 404

PUT mengganti seluruh data, jadi field yang tidak dikirim dianggap hilang (di TokoQA ditolak dengan 422). PATCH hanya mengubah field yang dikirim.

Studi kasus 2: Validasi input dan pesan error422 · 409

API yang baik menolak data yang salah dengan status yang tepat, dan memberi tahu field mana yang salah. TokoQA membalas dengan 422 plus objek fields.

Data yang dikirimStatusfields berisi
postalCode: "4011" (4 digit)422postalCode
phone: "12345" (tidak diawali 08)422phone
label: "" (kosong)422label
field tidak dikenal, misalnya warna422warna
alamat ke-6409(pesan: maksimal 5 alamat)
@pytest.mark.parametrize(("ubah", "field_error"), [  # jalankan dengan banyak data salah
    ({"postalCode": "4011"}, "postalCode"),
    ({"phone": "12345"}, "phone"),
])
def test_validasi_alamat_422(api, auth, ubah, field_error):  # satu test per baris data
    res = api.post("/api/addresses", data={**ALAMAT_RUMAH, **ubah}, headers=auth)  # kirim alamat dengan satu field yang salah
    assert res.status == 422  # ditolak 422
    assert list(res.json()["fields"]) == [field_error]   # hanya field yang salah
for (const { judul, ubah, field } of kasus) { // satu test per kasus
  test(`422: ${judul}`, async ({ request }) => {
    const res = await request.post('/api/addresses', { data: { ...ALAMAT_RUMAH, ...ubah }, headers: await login(request) });
    expect(res.status()).toBe(422); // ditolak 422
    expect(Object.keys((await res.json()).fields)).toEqual([field]); // hanya field yang salah
  });
}
Studi kasus 3: Pagination dan sorting200 · 400

Endpoint daftar mengirim data per halaman. Bug yang sering lolos: data muncul dua kali atau terlewat di pergantian halaman, dan urutannya berubah-ubah.

SkenarioRequestExpected
Halaman pertamaGET /api/products?page=1&limit=3total: 8, totalPages: 3, 3 item
Kumpulkan semua halamanloop page = 1..totalPages8 id, tidak ada yang dobel
Urutkan hargaGET /api/products?sort=price_ascharga selalu naik
Parameter salahsort=termahal, page=0, limit=100400
first = api.get("/api/products", params={"page": 1, "limit": 3}).json()  # halaman pertama, 3 per halaman
semua = []  # kumpulkan semua produk
for page in range(1, first["totalPages"] + 1):  # halaman 1 sampai terakhir
    semua += api.get("/api/products", params={"page": page, "limit": 3}).json()["data"]  # tambahkan isi halaman itu
ids = [p["id"] for p in semua]  # nomor id semua produk
assert len(ids) == len(set(ids)) == 8      # tidak dobel, tidak terlewat
const first = await (await request.get('/api/products', { params: { page: 1, limit: 3 } })).json(); // halaman pertama, 3 per halaman
const semua: { id: number }[] = []; // kumpulkan semua produk
for (let page = 1; page <= first.totalPages; page++) { // halaman 1 sampai terakhir
  semua.push(...(await (await request.get('/api/products', { params: { page, limit: 3 } })).json()).data);
}
expect(new Set(semua.map((p) => p.id)).size).toBe(8); // tidak dobel, tidak terlewat
Studi kasus 4: Autentikasi dan token kedaluwarsa401 · 403

Ada tiga alasan berbeda yang sama-sama menghasilkan 401, dan aplikasi yang baik bisa membedakannya. TokoQA memberi tahu alasannya lewat field reason.

KondisiCara membuatnyareason
Tanpa tokentidak kirim header Authorizationmissing
Token diubahganti 2 karakter terakhir tokeninvalid
Token kedaluwarsalogin dengan ttl: 0 (fitur latihan)expired
kedaluwarsa = login(api, ttl=0)                 # token langsung kedaluwarsa, tanpa menunggu
res = api.get("/api/me", headers=kedaluwarsa)  # token kedaluwarsa
assert res.status == 401  # ditolak 401
assert res.json()["reason"] == "expired"  # alasannya expired
const kedaluwarsa = await login(request, 'customer', 'rahasia123', { ttl: 0 }); // tanpa menunggu
const res = await request.get('/api/me', { headers: kedaluwarsa }); // token kedaluwarsa
expect(res.status()).toBe(401); // ditolak 401
expect((await res.json()).reason).toBe('expired'); // alasannya expired

Jangan menunggu token kedaluwarsa sungguhan. Minta developer menyediakan jalan pintas di server test, misalnya parameter TTL seperti di TokoQA, atau kendalikan waktu dengan page.clock di sisi UI.

Lanjutan: otorisasi berdasarkan role (401 vs 403)

Autentikasi menjawab "kamu siapa?", otorisasi menjawab "kamu boleh apa?". Di TokoQA, admin boleh membuka /api/admin/orders, sedangkan customer sudah login tapi tidak punya akses.

KondisiStatusArtinya
Buka /api/admin/orders tanpa token401Belum login
Buka dengan token customer403Sudah login, tapi role-nya tidak punya akses
Buka dengan token admin200Punya akses, dan mendapat pesanan semua akun
assert api.get("/api/admin/orders").status == 401  # tanpa token: 401
res = api.get("/api/admin/orders", headers=auth)      # auth = login sebagai customer
assert res.status == 403  # customer: 403

semua = api.get("/api/admin/orders", headers=login(api, "admin", "admin123")).json()["data"]  # admin bisa melihat semua pesanan
expect((await request.get('/api/admin/orders')).status()).toBe(401); // tanpa token: 401
const res = await request.get('/api/admin/orders', { headers: await login(request) }); // customer
expect(res.status()).toBe(403); // customer: 403

const { data } = await (await request.get('/api/admin/orders', { headers: await login(request, 'admin', 'admin123') })).json(); // admin bisa melihat semua pesanan

Hak akses sebaiknya diuji di API (server menolak) dan di UI (menunya tidak ditampilkan). Test dashboard admin hanya untuk admin di Modul 17 mengecek sisi UI-nya.

Studi kasus 5: Kupon dan rate limit200 · 404 · 410 · 422 · 429

Satu endpoint, banyak kemungkinan hasil. Supaya kode kupon tidak bisa ditebak, percobaan dibatasi 5 kali per menit (rate limit).

KodeKondisiStatus
hemat10valid (huruf kecil juga diterima)200, diskon 10%
KADALUARSAsudah berakhir410 Gone
TIDAKADAtidak terdaftar404
HEMAT50Ksubtotal di bawah Rp 500.000422
percobaan ke-6 dalam semenitrate limit429 + header Retry-After
statuses = [api.post("/api/coupons/apply", data={"code": "TIDAKADA"}, headers=auth).status for _ in range(5)]  # 5 kali salah berturut-turut
assert statuses == [404] * 5  # semuanya 404

res = api.post("/api/coupons/apply", data={"code": "HEMAT10"}, headers=auth)  # percobaan keenam
assert res.status == 429  # ditolak 429
assert int(res.headers["retry-after"]) > 0    # server memberi tahu kapan boleh mencoba lagi
for (let i = 0; i < 5; i++) { // 5 kali salah berturut-turut
  expect((await request.post('/api/coupons/apply', { data: { code: 'TIDAKADA' }, headers: auth })).status()).toBe(404);
}
const res = await request.post('/api/coupons/apply', { data: { code: 'HEMAT10' }, headers: auth }); // percobaan keenam
expect(res.status()).toBe(429); // ditolak 429
expect(Number(res.headers()['retry-after'])).toBeGreaterThan(0); // header Retry-After: tunggu berapa detik
Studi kasus 6: Alur pesanan end-to-end lewat API201 · 404

Rangkai request seperti pengguna sungguhan, tanpa browser. Tekniknya disebut chaining: hasil satu request dipakai di request berikutnya.

  1. Tambah Keyboard Mekanik (Rp 750.000) dan 2 Lampu Meja LED (2 x Rp 120.000).
  2. Buat pesanan dengan kupon HEMAT10, lalu cek subtotal Rp 990.000, diskon Rp 99.000, dan total Rp 891.000.
  3. Ikuti header Location untuk membaca detail pesanan.
  4. Pastikan pesanan muncul di GET /api/orders dan keranjang sudah kosong.
  5. Keamanan: sesi lain tidak boleh bisa membaca pesanan ini. Jawabannya 404, bukan 403, supaya orang lain bahkan tidak tahu pesanan itu ada.
res = api.post("/api/orders", data={"name": "Rina", "address": "Bandung", "payment": "ewallet", "coupon": "HEMAT10"}, headers=auth)  # buat pesanan dengan kupon
order = Order.model_validate(res.json())  # bentuk respons dicek dengan model Pydantic
assert (order.subtotal, order.discount, order.total) == (990_000, 99_000, 891_000)  # subtotal, diskon, total

detail = api.get(res.headers["location"], headers=auth)        # chaining lewat header Location
assert detail.json()["id"] == order.id  # detail pesanan bisa dibuka
assert api.get("/api/cart", headers=auth).json()["items"] == []  # keranjang kosong setelah pesanan dibuat
const res = await request.post('/api/orders', { // buat pesanan dengan kupon
  data: { name: 'Rina', address: 'Bandung', payment: 'ewallet', coupon: 'HEMAT10' }, headers: auth,
});
const order = Order.parse(await res.json()); // bentuk respons dicek dengan schema Zod
expect([order.subtotal, order.discount, order.total]).toEqual([990_000, 99_000, 891_000]); // subtotal, diskon, total

const detail = await request.get(res.headers()['location'], { headers: auth }); // chaining
expect((await detail.json()).id).toBe(order.id); // detail pesanan bisa dibuka
Studi kasus 7: Timeout dan waktu responstimeout

Endpoint /api/slow?ms=2000 sengaja dibuat lambat. Kita pastikan request yang kelamaan gagal dengan pesan yang jelas, dan respons normal tetap cepat.

with pytest.raises(Error, match="Timeout"):  # test lulus kalau request berhenti karena timeout
    api.get("/api/slow", params={"ms": 2000}, timeout=500)  # server lambat 2 detik, batas tunggu 0,5 detik
await expect(request.get('/api/slow', { params: { ms: 2000 }, timeout: 500 })).rejects.toThrow(/Timeout/); // request harus gagal karena timeout

Pengecekan "respons di bawah 500 ms" berguna sebagai alarm awal, tapi bukan load test. Untuk mengukur server saat dipakai banyak orang, pakai k6 atau JMeter.

File lengkap tujuh studi kasus

tests/academy/test_m13_case_studies.pytests/academy/m13-case-studies.spec.tsLulus test
Memuat tests/academy/test_m13_case_studies.py…
uv run pytest tests/academy/test_m13_case_studies.py -v
Memuat tests/academy/m13-case-studies.spec.ts…
npx playwright test m13-case-studies --project=chromium

Proyek pasangan lain: KlinikQA dan PerpusQA

Ketiga proyek login dengan cara yang sama: Bearer token (JWT) di header Authorization. Yang berbeda adalah aturan bisnis dan perannya:

ProyekToken disimpan diYang paling banyak dilatih
KlinikQAlocalStorage klinikqa_tokenlogout yang mematikan token di server, dua peran (pasien dan dokter), kalender slot dan nomor antrean, batas ubah jadwal, agenda dokter per hari, CRUD catatan pemeriksaan
PerpusQAlocalStorage perpusqa_tokendua peran (pustakawan dan anggota), CRUD katalog, anggota, dan ulasan, aturan bisnis (batas, stok, blokir), antrean buku yang habis, denda berbasis tanggal dan pelunasannya, alur dua peran API dan UI

Semua endpoint-nya bisa dicoba di API Lab: pilih proyeknya di bagian atas halaman.

Cara test API memegang login:

  1. Login lewat endpoint login aplikasinya: /api/login, /api/klinik/login, atau /api/perpus/login.
  2. Ambil field token dari respons.
  3. Kirim di setiap request: header Authorization: Bearer <token>. Supaya tidak ditulis berulang, pasang sekali lewat extra_http_headers / extraHTTPHeaders, satu request context per peran.

Token satu aplikasi tidak berlaku di aplikasi lain. Token TokoQA yang dikirim ke KlinikQA ditolak 401 dengan reason: "invalid".

KlinikQA 1: Token sesi200 · 401

Pastikan login memberi token, logout benar-benar mematikan token di server, dan token palsu ditolak. Perhatikan field reason: missing berarti tidak ada header Authorization sama sekali, invalid berarti ada token tapi tidak diterima server.

KondisiStatusreason
Login berhasil200 + token dan expiresIn
Token yang sama setelah logout401invalid
Token dikarang sendiri401invalid
Token TokoQA dikirim ke KlinikQA401invalid
KlinikQA 2: Slot bentrok dan validasi409 · 422

Jam yang sudah dipesan tidak boleh dipesan lagi (409). Data yang salah ditolak per field (422): dokter tidak dikenal, tanggal hari Minggu (klinik tutup), jam di luar jadwal, dan keluhan yang terlalu pendek.

KlinikQA 3: Hak akses pasien dan dokter403 · 409

Pasien punya CRUD janji temu: membuat, melihat, mengubah jadwal (PATCH berisi date, time, atau complaint), dan membatalkan. Dokter menandai hasil (PATCH berisi status), melihat agenda per hari (GET /api/klinik/agenda) dan data pasien (/appointments/{id}/patient), serta punya CRUD catatan pemeriksaan (/appointments/{id}/notes dan /notes/{id}) yang bisa dibaca pasien. Masing-masing ditolak 403 kalau mencoba pekerjaan peran lain, dan janji temu yang sudah selesai tidak bisa diubah atau dibatalkan (409).

tests/proyek/test_klinik_api.pytests/proyek/klinik-api.spec.tsLulus test
Memuat tests/proyek/test_klinik_api.py…
uv run pytest tests/proyek/test_klinik_api.py -v
Memuat tests/proyek/klinik-api.spec.ts…
npx playwright test klinik-api --project=chromium
KlinikQA 4: Antrean, kalender, dan agenda dokter201 · 403 · 409

Aturan bisnis KlinikQA yang paling sering salah di aplikasi sungguhan ada di sini. Nomor antrean mengikuti urutan jam (dr. Sari jam 10:00 = U-05) dan ikut berubah saat jadwal diubah. Jadwal hanya boleh diubah 2 kali. Kalender menandai hari Minggu sebagai tutup dan hari Sabtu drg. Maya sebagai penuh. Dokter melihat agenda per hari lengkap dengan ringkasan jumlah pasien.

KondisiYang dicek
Buat janji jam 10:00 dengan dr. Sari201, queueNo = U-05
Ubah jadwal ketiga kalinya409
drg. Maya di hari Sabtu409, kalender menandai penuh
Pasien membuka agenda dokter403
Ringkasan agendacocok dengan isi agenda di respons yang sama

Agenda dokter berisi janji dari semua sesi pasien, termasuk test lain yang berjalan paralel. Karena itu test ringkasan tidak menghitung selisih "sebelum dan sesudah", tapi mencocokkan summary dengan isi data di respons yang sama. Teknik ini berguna setiap kali kamu mengetes data yang dipakai bersama.

tests/proyek/test_klinik_alur.pytests/proyek/klinik-alur.spec.tsLulus test
Memuat tests/proyek/test_klinik_alur.py…
uv run pytest tests/proyek/test_klinik_alur.py -v
Memuat tests/proyek/klinik-alur.spec.ts…
npx playwright test klinik-alur --project=chromium
PerpusQA 1: Login, peran, dan isolasi data401 · 403 · 404

Akun demo (pustakawan / pustaka123, anggota / baca123) enak untuk dicoba manual, tapi dipakai bersama. Karena itu setiap test membuka perpustakaannya sendiri dengan username acak, jadi katalog, anggota, dan pinjamannya tidak tercampur dengan test lain. Yang dicek: login memberi token, logout mematikannya, tanpa token dan token salah ditolak dengan reason berbeda, peran yang salah ditolak 403 dengan reason: "role", dan data milik anggota lain atau perpustakaan lain dibalas 404.

KondisiStatusreason
Tanpa header Authorization401missing
Token setelah logout401invalid
Token dikarang sendiri401invalid
Anggota menambah buku, pustakawan meminjam403role
Membaca pinjaman anggota lain404
PerpusQA 2: CRUD per peran: katalog, anggota, ulasan403 · 409

Setiap peran punya data yang dikelolanya sendiri:

PeranCRUDAturan yang dicek
PustakawanBuku: POST, GET, PATCH, DELETE /booksjudul dan penulis dobel 409, buku yang masih dipinjam tidak boleh dihapus atau dikurangi eksemplarnya (409)
PustakawanAnggota: POST, GET, PATCH, DELETE /membersusername dobel 409, password baru langsung berlaku, anggota yang masih meminjam tidak boleh dihapus (409)
AnggotaUlasan: POST /books/{id}/reviews, GET, PATCH, DELETE /reviews/{id}satu ulasan per buku (409), ulasan orang lain bisa dibaca tapi tidak bisa diubah (403, reason: "owner"), pustakawan boleh menghapus untuk moderasi
PerpusQA 3: Aturan peminjaman403 · 409

Batas 3 buku, buku yang sama tidak bisa dipinjam dua kali, dan stok dihitung untuk seluruh perpustakaan: buku dengan 1 eksemplar yang dipinjam Rina tidak bisa dipinjam Budi. Tiga alasan itu sama-sama 409 dengan pesan berbeda. Kartu yang diblokir pustakawan ditolak 403 dengan reason: "blocked", dan perpanjangan hanya boleh sekali, tidak untuk pinjaman yang sudah lewat jatuh tempo.

PerpusQA 4: Denda: dua peran, API dan UI201 · 200

Menunggu 10 hari supaya buku terlambat jelas tidak mungkin. Server latihan menyediakan jalan pintas: field borrowedAt untuk memundurkan tanggal pinjam. Anggota meminjam lewat API dengan tanggal 10 hari lalu, pustakawan masuk lewat UI dan melihat status Terlambat 3 hari, denda Rp 3.000, lalu menerima bukunya lewat modal. Terakhir, cek lewat API bahwa dendanya tercatat di pinjaman, di profil anggota, dan di data anggota milik pustakawan.

tests/proyek/test_perpus_api.pytests/proyek/perpus-api.spec.tsLulus test
Memuat tests/proyek/test_perpus_api.py…
uv run pytest tests/proyek/test_perpus_api.py -v
Memuat tests/proyek/perpus-api.spec.ts…
npx playwright test perpus-api --project=chromium
PerpusQA 5: Antrean dan pelunasan denda409

Buku yang habis bisa diantre. Saat eksemplar kembali (atau pustakawan menambah eksemplar), antrean terdepan berubah menjadi Siap diambil dan eksemplarnya disimpan untuk dia, jadi anggota lain tidak bisa menyerobot. Denda yang muncul saat buku diterima tercatat sebagai unpaidFine sampai pustakawan menandainya lunas.

KondisiYang dicek
Antre buku yang masih tersedia409
Eksemplar kembali, ada antreanantrean terdepan Siap diambil, yang lain naik satu posisi
Anggota yang masih menunggu mencoba meminjam409 berisi posisi antreannya
Antrean ketiga409 (maksimal 2)
Pustakawan melunasi dendaunpaidFine jadi 0, totalFine tetap

Studi kasus ini melibatkan tiga anggota sekaligus (Rina meminjam, Budi dan Sari antre). Dengan fixture daftar_anggota / daftarAnggota, setiap anggota punya request context sendiri, jadi alurnya tetap pendek.

tests/proyek/test_perpus_antrean.pytests/proyek/perpus-antrean.spec.tsLulus test
Memuat tests/proyek/test_perpus_antrean.py…
uv run pytest tests/proyek/test_perpus_antrean.py -v
Memuat tests/proyek/perpus-antrean.spec.ts…
npx playwright test perpus-antrean --project=chromium
TokoQA: CRUD kupon oleh admin201 · 409

Customer di TokoQA sudah punya CRUD alamat (Studi kasus 1). Admin juga punya: CRUD kupon promo di /api/admin/coupons. Kupon buatan admin langsung bisa dipakai customer, sedangkan kupon bawaan (HEMAT10, HEMAT50K, KADALUARSA) dikunci (409) supaya test lain yang memakainya tidak terganggu. Kode kupon unik di seluruh toko, jadi test memakai kode acak.

tests/proyek/test_toko_kupon.pytests/proyek/toko-kupon.spec.tsLulus test
Memuat tests/proyek/test_toko_kupon.py…
uv run pytest tests/proyek/test_toko_kupon.py -v
Memuat tests/proyek/toko-kupon.spec.ts…
npx playwright test toko-kupon --project=chromium
TokoQA: Pesanan dari checkout sampai ulasan403 · 409 · 422

Alur pesanan TokoQA punya aturan yang jelas: status hanya bisa maju (Baru, Diproses, Dikirim, Selesai), customer hanya bisa membatalkan selama status masih Baru, ongkir Express Rp 25.000 dan tidak bisa dibayar COD, dan ulasan hanya boleh ditulis setelah pesanan Selesai. Admin juga bisa menambah stok (restock), tapi tidak bisa mengubah harga, karena harga produk dipakai bersama oleh semua test.

KondisiStatus
Admin memundurkan status Dikirim ke Diproses409
Customer membatalkan pesanan yang sudah Diproses409
COD dengan pengiriman Express422
Ulasan sebelum pesanan Selesai403
Restock produk yang dikunci (Tas Laptop)409
tests/proyek/test_toko_pesanan.pytests/proyek/toko-pesanan.spec.tsLulus test
Memuat tests/proyek/test_toko_pesanan.py…
uv run pytest tests/proyek/test_toko_pesanan.py -v
Memuat tests/proyek/toko-pesanan.spec.ts…
npx playwright test toko-pesanan --project=chromium

Kontrak API dan contract testing

Di tim sungguhan, perilaku API ditulis di kontrak API: endpoint apa saja yang ada, siapa yang boleh memanggil, data apa yang dikirim, dan semua kemungkinan jawabannya.

Formatnya hampir selalu OpenAPI (dulu disebut Swagger). Ketiga proyek latihan punya kontrak lengkap di halaman API Spec, dan file aslinya bisa diunduh: /spec/tokoqa.json, /spec/klinikqa.json, /spec/perpusqa.json.

Bagian kontrakIsinyaJadi test apa
Peran dan matriks hak aksesSiapa yang boleh memanggil endpointTest 401 dan 403 untuk peran yang salah
ParameterNama, tipe, wajib atau tidak, batas nilaiTest 400 untuk nilai di luar batas
Body requestField wajib dan opsional, aturan tiap fieldTest 422 per field
Respons per statusKapan tiap status keluar, header wajib, contoh bodyAssert status, header Location, dan isi body
Aturan bisnisBatas, urutan pengecekan, status yang finalTest 409 dan alur bisnis
Skema dataBentuk setiap objek, tanpa field tambahanContract test

Contract test memastikan bentuk respons asli sama dengan kontrak: status-nya terdaftar, header wajibnya ada, field wajibnya lengkap, tipenya benar, dan tidak ada field tambahan. Helper kecil di utils/kontrak mengambil skema yang tepat dari kontrak untuk setiap kombinasi endpoint dan status, jadi test-nya cukup satu baris per request.

from utils.kontrak import muat_kontrak  # helper contract test

def test_kontrak_pinjam(api, anggota_api):  # anggota_api = klien API anggota yang sudah login
    kontrak = muat_kontrak(api, "perpusqa")  # unduh kontrak PerpusQA
    res = anggota_api.post("/api/perpus/loans", data={"bookId": 13})  # pinjam buku
    kontrak.cek("POST", "/api/perpus/loans", res)  # 201 terdaftar, header Location ada, body cocok skema Loan
import { muatKontrak } from '../../utils/kontrak'; // helper contract test

test('kontrak pinjam', async ({ request, anggotaApi }) => { // anggotaApi = klien API anggota yang sudah login
  const kontrak = await muatKontrak(request, 'perpusqa');
  const res = await anggotaApi.post('/api/perpus/loans', { data: { bookId: 13 } }); // pinjam buku
  await kontrak.cek('POST', '/api/perpus/loans', res); // 201 terdaftar, header Location ada, body cocok skema Loan
});

Contract test menjawab "apakah bentuk jawabannya benar?", test fungsional menjawab "apakah aturannya benar?". Contract test tetap lolos walau denda dihitung salah, jadi suite yang baik punya keduanya.

tests/proyek/test_kontrak_api.pytests/proyek/kontrak-api.spec.tsLulus test
Memuat tests/proyek/test_kontrak_api.py…
uv run pytest tests/proyek/test_kontrak_api.py -v
Memuat tests/proyek/kontrak-api.spec.ts…
npx playwright test kontrak-api --project=chromium
utils/kontrak.pyutils/kontrak.tsLulus test
Memuat utils/kontrak.py…
Memuat utils/kontrak.ts…
Lebih jauh (opsional)Trik komunitas
  • Zod (TypeScript) dan Pydantic (Python) banyak dipakai sebagai pengganti JSON Schema biasa: sekali tulis, kamu dapat pengecekan data sekaligus tipe datanya. Keduanya dipakai di studi kasus di atas.
  • Rekam dan putar ulang HAR: route_from_har() / routeFromHAR() merekam respons asli sekali, lalu memutarnya lagi saat test. Test UI tetap stabil walaupun backend sedang mati.
  • Coba dulu manual, baru diotomasi. Coba endpoint di API Lab, Bruno, atau Postman, baru tulis test-nya.
  • Contract testing (misalnya Pact) dipakai tim microservice untuk memastikan perubahan API tidak merusak aplikasi lain yang memakainya.
# Rekam sekali (update=True), lalu jalankan ulang tanpa update untuk memakai rekaman
page.route_from_har("data/products.har", url="**/api/products*", update=True)
// Rekam sekali (update: true), lalu jalankan ulang tanpa update untuk memakai rekaman
await page.routeFromHAR('data/products.har', { url: '**/api/products*', update: true });

Latihan

Latihan memakai ketiga proyek. Semuanya login dengan Bearer token, jadi pola yang sama dipakai berulang. Semua endpoint bisa dicoba dulu di API Lab.

Latihan 1

Profil admin lewat API

Login sebagai admin / admin123 lewat POST /api/login, lalu panggil GET /api/me dengan token itu.

Hasilstatus 200 dan role bernilai admin

Latihan 2

Alur pesanan lengkap lewat API

Login sebagai customer, tambahkan produk id 2 dan id 7 ke keranjang, lalu buat pesanan lewat POST /api/orders. Cek status, id, status pesanan, dan totalnya.

Hasil201, id berawalan TQA-, status Baru, total 305000

Latihan 3

Checkout lewat UI, cek lewat API

Login lewat UI, tambah Mouse Wireless, checkout sampai berhasil. Ambil token dari localStorage (tokoqa_token), lalu panggil GET /api/cart.

Hasilkeranjang di API kosong (items: [])

Latihan 4

Mock daftar produk kosong

Intercept **/api/products dengan balasan {"total": 0, "data": []}, buka Arena, tekan Ambil produk (jurus 14), lalu cek jumlah produknya.

Hasil0 produk, dan daftarnya kosong

Latihan 5

KlinikQA: token dan jam bentrok

Brief: KlinikQA memberi token saat login, sama seperti TokoQA.

  1. Panggil GET /api/klinik/me tanpa token. Pastikan 401.
  2. Login sebagai pasien / sehat123, ambil token dari respons, lalu panggil /api/klinik/me dengan header Authorization: Bearer <token>.
  3. Buat janji temu, lalu kirim janji yang sama persis sekali lagi.

Hasil401, lalu role: "pasien", lalu 201, lalu 409 dengan pesan "sudah terisi"

Latihan 6

PerpusQA: dua peran dengan login

Brief: di PerpusQA, login lewat API memberi token untuk header Authorization. Pustakawan mengelola, anggota meminjam.

  1. Pastikan GET /api/perpus/me tanpa token ditolak 401.
  2. Buka perpustakaan lewat POST /api/perpus/libraries dengan username acak, login sebagai pustakawan, lalu daftarkan anggota lewat POST /api/perpus/members dan login sebagai anggota itu.
  3. Dengan token anggota, pinjam buku bookId 4. Dengan token pustakawan, pastikan pinjaman itu muncul di GET /api/perpus/loans?status=aktif.
  4. Dengan token anggota, coba tambah buku lewat POST /api/perpus/books. Pastikan ditolak.
  5. Dengan token pustakawan, terima pengembalian bukunya, lalu pastikan pinjaman aktif anggota kembali 0.
  6. Logout sebagai anggota, lalu pastikan token-nya tidak berlaku lagi.

Hasil401, 201, pinjaman terlihat oleh pustakawan, 403 dengan reason: "role", activeLoans 0, lalu 401 dengan reason: "invalid"

Latihan 7

Baca kontrak, lalu buktikan

Brief: baca kontrak sebelum menulis test. Buka API Spec untuk PATCH /api/perpus/books/{id}.

  1. Dari kontrak, cari tahu: peran mana yang boleh memanggilnya, dan kapan keluar status 422.
  2. Login dengan akun demo pustakawan, kirim PATCH dengan body kosong, cek respons dengan helper kontrak, lalu pastikan fields berisi kunci _.
  3. Login dengan akun demo anggota, kirim PATCH berisi {"copies": 5}. Cek dengan helper kontrak dan pastikan reason-nya role.
  4. Ambil detail buku itu (GET) dan cek juga dengan helper kontrak. Tidak ada data yang diubah, jadi aman memakai akun demo.

Hasil422, 403, lalu 200, dan ketiganya lolos pengecekan kontrak

Latihan 8

Dua peran sekaligus: admin dan customer

Brief: di TokoQA, admin memproses pesanan customer. Uji alur dua orang ini dalam satu test dengan dua browser context.

  1. Siapkan pesanan customer lewat API.
  2. Di context kedua, login sebagai admin lewat UI dan ubah status pesanan itu menjadi Dikirim.
  3. Di context customer, buka Pesanan saya.

Hasilcustomer melihat status Dikirim di pesanannya