Elixir untuk Technical Leader — Bab 7 dari 10
Bug yang ketemu di test = gratis. Bug yang ketemu user = mahal. LabaBersih v1 punya 0 test. v2 punya 337+.
Coba bayangkan ini: kamu punya function ship_order yang harus melakukan 5 hal sekaligus — ubah status, potong stok, generate jurnal penjualan, generate jurnal HPP, dan catat audit trail. Kalau salah satu gagal, semuanya harus rollback.
Di v1, kamu cuma bisa tau ini jalan dengan benar kalau Nelly coba manual di production. Bug ditemukan oleh user, bukan oleh developer.
Rule LabaBersih #5: "Setiap function WAJIB punya test." Gak ada pengecualian.
Elixir punya framework test built-in bernama ExUnit. Gak perlu install library tambahan, gak perlu config apapun. Langsung jalan.
npm install --save-dev jestmix test — selesai.
Jalankan semua test:
$ mix test
# Output: "337 tests, 0 failures" ← ini yang kamu mau lihat
Setiap test punya 3 bagian: describe (kelompok), test (skenario), dan assert (verifikasi).
describe "ship_order/2" do
test "ship order berhasil → status + stok + jurnal" do
# 1. ARRANGE — siapkan data
ctx = setup_full!()
order = create_order!(ctx)
# 2. ACT — jalankan function yang di-test
{:ok, result} = Orders.ship_order(order.id, "nelly@bestari.id")
# 3. ASSERT — verifikasi hasilnya
assert result.order.fulfillment_status == "shipped"
assert result.order.shipped_at != nil
product = Inventory.get_product!(ctx.product.id)
assert product.stok == 98
end
end
describe "ship_order/2" — kita lagi nge-test function ship_order yang terima 2 argumentest "ship order berhasil..." — skenario: kirim order yang valid, harusnya berhasilassert result.order.fulfillment_status == "shipped" — cek: statusnya HARUS jadi "shipped"assert product.stok == 98 — cek: stok HARUS turun dari 100 ke 98 (kirim 2 item)assert gagal, test GAGAL. Berarti ada bug.
File test mirror file source code. Kalau kamu tau ada file lib/lababersih/orders/orders.ex, kamu otomatis tau test-nya di test/lababersih/orders/orders_test.exs.
| Source Code | File Test |
|---|---|
lib/lababersih/orders/orders.ex | test/lababersih/orders/orders_test.exs |
lib/lababersih/accounting/accounting.ex | test/lababersih/accounting/accounting_test.exs |
lib/lababersih/inventory/inventory.ex | test/lababersih/inventory/inventory_test.exs |
lib/lababersih/returns/returns.ex | test/lababersih/returns/returns_test.exs |
lib/lababersih/customers/customers.ex | test/lababersih/customers/customers_test.exs |
Polanya selalu: lib/ → test/, dan nama file ditambah _test.
Setiap test butuh data untuk di-test. Di LabaBersih, setup_full!() bikin data lengkap: user, organisasi, toko, produk, stok.
defp setup_full!() do
# Buat user + organisasi
{:ok, %{user: user, organization: org}} =
Accounts.register_user(%{
email: "test-#{System.unique_integer([:positive])}@example.com",
name: "Test User",
password: "password123"
})
# Buat akun akuntansi minimal
# Buat toko + fee mapping
# Buat produk + stok awal 100
# Return semua data
%{org: org, user: user, store: store, product: product}
end
Kenapa ini penting? Karena test yang saling mengganggu = nightmare. "Test A pass sendiri tapi gagal kalau bareng test B" — itu gak pernah terjadi dengan sandbox.
Assertion = klaim yang HARUS benar. Kalau salah, test gagal. Ada beberapa jenis:
# Cek status order jadi "shipped"
assert order.fulfillment_status == "shipped"
# Cek stok berkurang
assert product.stok == 98
# Cek shipped_at terisi (bukan nil)
assert order.shipped_at != nil
# Cek order BUKAN status "dibuat"
refute order.order_status == "dibuat"
# Cek akun TIDAK aktif
refute account.is_active
# Cek return value cocok pattern {:ok, ...}
assert {:ok, result} = Orders.ship_order(order.id, "nelly@bestari.id")
# Cek error spesifik
assert {:error, :already_shipped} = Orders.ship_order(order.id, "nelly@bestari.id")
# Cek error dengan detail
assert {:error, {:insufficient_available_stock, _, _, _}} =
Orders.create_order(big_order_attrs, items)
# Cek kalau order gak ada → raise error
assert_raise Ecto.NoResultsError, fn ->
Orders.get_order!("order-yang-gak-ada")
end
| Assertion | Artinya | Kapan pakai |
|---|---|---|
assert X == Y | X harus sama dengan Y | Paling sering — cek hasil |
assert X | X harus truthy (bukan nil/false) | Cek field terisi |
refute X | X harus falsy | Cek field TIDAK ada / false |
assert {:ok, _} = func() | Return harus match pattern | Cek function berhasil |
assert {:error, reason} = func() | Return harus error | Cek function gagal dengan benar |
assert_raise Error, fn | Harus raise exception | Cek data tidak ditemukan |
Setiap function penting punya minimal 3 test: happy path, error path, dan edge case. Ini bukan teori — ini dari kode LabaBersih yang asli.
test "ship order → atomic: status + stok + jurnal + audit" do
ctx = setup_full!()
order = create_order!(ctx)
{:ok, result} = Orders.ship_order(order.id, "nelly@bestari.id")
# Status berubah
assert result.order.fulfillment_status == "shipped"
assert result.order.shipped_at != nil
# Stok berkurang (2 pcs dari 100)
product = Inventory.get_product!(ctx.product.id)
assert product.stok == 98
# HPP dari FIFO lot (2 x Rp50.000 = Rp100.000)
assert Decimal.eq?(result.hpp, Decimal.new(100_000))
# Jurnal tercipta (penjualan + HPP)
assert length(result.journal_ids) == 2
end
test "create order stok gak cukup → rollback" do
ctx = setup_full!()
# Coba buat order 999 pcs (stok cuma 100)
assert {:error, {:insufficient_available_stock, _, _, _}} =
Orders.create_order(
%{org_id: ctx.org.id, platform: "tiktok", store_id: ctx.store.id,
total_harga: 5_000_000, customer_name: "Big Buyer"},
[%{product_id: ctx.product.id, product_name: "Forbest",
sku: "FB-001", quantity: 999, price: 5_000}]
)
# Stok TETAP 100 (rollback)
product = Inventory.get_product!(ctx.product.id)
assert product.stok == 100
assert product.reserved_stock == 0
end
test "ship order yang sudah dikirim → idempotent skip" do
ctx = setup_full!()
order = create_order!(ctx)
# Ship pertama → berhasil
{:ok, _} = Orders.ship_order(order.id, "nelly@bestari.id")
# Ship kedua → skip, bukan error crash
assert {:error, :already_shipped} = Orders.ship_order(order.id, "nelly@bestari.id")
end
test "jurnal penjualan WAJIB balanced (debit = credit)" do
ctx = setup_full!()
order = create_order!(ctx)
{:ok, result} = Orders.ship_order(order.id, "nelly@bestari.id")
# Ambil jurnal yang baru dibuat
journals = Accounting.list_journal_entries(
ctx.org.id, source_type: "order", source_id: order.id)
for je <- journals do
lines = Accounting.list_journal_entry_lines(je.id)
total_debit = Enum.reduce(lines, Decimal.new(0), &Decimal.add(&1.debit, &2))
total_credit = Enum.reduce(lines, Decimal.new(0), &Decimal.add(&1.credit, &2))
assert Decimal.eq?(total_debit, total_credit)
end
end
Ini test paling penting di akuntansi. Jurnal yang gak balance = laporan keuangan salah = keputusan bisnis salah.
$ mix test
# Test orders saja
$ mix test test/lababersih/orders/orders_test.exs
# Test yang ada di baris 122
$ mix test test/lababersih/orders/orders_test.exs:122
$ mix test --failed
$ mix test
..................................
Finished in 4.2 seconds (0.1s async, 4.1s sync)
337 tests, 0 failures
Setiap titik (.) = 1 test yang pass. 0 failures = semua aman. Ini yang kamu mau lihat.
$ mix test
............................F......
1) test ship order → status + stok + jurnal (Lababersih.OrdersTest)
test/lababersih/orders/orders_test.exs:122
Assertion with == failed
code: assert product.stok == 98
left: 100 ← NILAI YANG DIDAPAT
right: 98 ← NILAI YANG DIHARAPKAN
Finished in 4.5 seconds
337 tests, 1 failure
orders_test.exs:122 — tepat di manaleft: 100 (stok gak berkurang), right: 98 (harusnya berkurang). Berarti logic potong stok ada bug.Ini jumlah test per context module saat ini:
| Context Module | File Test | Jumlah Test |
|---|---|---|
| Accounts (auth, org) | accounts_test.exs + password_reset_test.exs | 35+ |
| Accounting (COA, jurnal) | accounting_test.exs + daily_journal_test.exs | 30 |
| Orders (pesanan, packing) | orders_test.exs + xlsx_import_test.exs + reservation_test.exs | 35 |
| Inventory (produk, stok) | inventory_test.exs + master_data_test.exs + warehouse_operations_test.exs | 53 |
| Customers (pelanggan) | customers_test.exs | 22 |
| Returns (RTS, klaim) | returns_test.exs + rts_management_test.exs + return_enhancement_test.exs | 28 |
| Sales (toko, fee) | sales_test.exs | 10 |
| Purchasing (PO) | purchasing_test.exs | 8 |
| Reconciliation | reconciliation_test.exs | 4 |
| Fulfillment | fulfillment_test.exs | 11 |
| Cross-domain / edge cases | business_cases_test.exs + advanced_test.exs + lainnya | 100+ |
| TOTAL | 337+ | |
Enum.map jalan. Itu tanggung jawab Elixir, bukan kamu.Repo.insert benar. Yang di-test: apakah changeset validasi kamu benar.calculate_hpp, test itu. Tapi jangan test apakah Decimal.add bisa nambah. Itu tanggung jawab library Decimal.
Credo = alat yang cek kualitas kode secara otomatis. Dia gak jalankan kode, tapi baca kode dan cari yang gak rapi.
$ mix credo
| Kategori | Contoh temuan | Analogi |
|---|---|---|
| Consistency | "Kadang pakai String.upcase, kadang String.downcase di tempat serupa" | Consistency — seragam gak? |
| Readability | "Function ini terlalu panjang (> 50 baris)" | Kebersihan — rapih gak? |
| Refactoring | "Variable ini gak pernah dipakai" | Sampah — ada yang gak perlu? |
| Warning | "Module belum punya @moduledoc" | Dokumentasi — ada penjelasan? |
| Design | "Function ini terlalu banyak parameter" | Arsitektur — strukturnya bagus? |
$ mix credo
Checking 84 source files ...
┃ Warnings - pleaseass these before submitting
┃
┃ [W] ↗ Modules should have a @moduledoc tag.
┃ lib/lababersih/workers/daily_sales_journal_worker.ex:1:20 #(Lababersih.Workers.DailySalesJournalWorker)
Analysis took 0.5s (0.03s to load, 0.47s running 52 checks on 84 files)
77 mods/funs, found 1 warning.
Sobelow = alat yang cari celah keamanan di kode Phoenix.
$ mix sobelow
| Kategori | Contoh temuan | Dampak kalau gak di-fix |
|---|---|---|
| SQL Injection | Query database pakai string concatenation | Hacker bisa baca/hapus semua data |
| XSS | Render input user tanpa escape | Hacker bisa inject script berbahaya |
| Hardcoded Secret | API key langsung di kode, bukan env var | Secret bocor ke GitHub |
| CSRF | Form tanpa token CSRF | Hacker bisa kirim form atas nama user |
| Insecure Config | SSL disabled, secret key lemah | Data gak terenkripsi |
| Tool | Command | Cek apa | Analogi |
|---|---|---|---|
| ExUnit | mix test | Apakah kode JALAN dengan benar? | Ujian — jawaban benar? |
| Credo | mix credo | Apakah kode DITULIS dengan benar? | Editor — tulisan rapi? |
| Sobelow | mix sobelow | Apakah kode AMAN dari serangan? | Security guard — ada celah? |
Setiap kali Claude bikin function baru atau update function lama, cek ini:
| # | Pertanyaan | Cari di mana | Red flag |
|---|---|---|---|
| 1 | Ada test untuk function baru? | File _test.exs yang sesuai | Function ada, test gak ada |
| 2 | Ada test happy path? | test "... berhasil" | Hanya test error, gak test sukses |
| 3 | Ada test error path? | test "... gagal" atau assert {:error, ...} | Hanya test sukses, gak test gagal |
| 4 | Ada test edge case? | Idempotent, boundary, rollback | Cuma 1 test untuk function kompleks |
| 5 | mix test pass? | Output: "X tests, 0 failures" | Ada failures |
| 6 | mix credo clean? | Output: "found 0 issues" | Banyak warnings |
| 7 | Jurnal balanced di test? | assert Decimal.eq?(total_debit, total_credit) | Jurnal dibuat tapi gak dicek balance |
Kalau kamu lihat hal-hal ini, tanya Claude:
# Ada function baru di orders.ex
def cancel_order(order_id) do
# ... logic ...
end
# TAPI gak ada di orders_test.exs
# ← RED FLAG! Minta Claude bikin test-nya
test "ship order" do
ctx = setup_full!()
order = create_order!(ctx)
Orders.ship_order(order.id, "nelly@bestari.id")
# ← GIT GAK ADA assert! Test ini selalu pass.
# Gak berguna. Minta Claude tambah assertions.
end
test "ship order berhasil" do
ctx = setup_full!()
order = create_order!(ctx)
{:ok, _} = Orders.ship_order(order.id, "nelly@bestari.id")
# Cuma cek {:ok, _} tapi gak cek stok, gak cek jurnal, gak cek status
# ← RED FLAG! Ship order punya 5 side effect, semua harus dicek
end
@tag :skip
test "ship order" do
# ...
end
# ← RED FLAG! Test di-skip = test gak ada.
# Tanya: kenapa di-skip? Kapan di-enable?
mix test — 1 command, semua terverifikasi.0 failures. Kalau ada failure, baca left vs right.Pertanyaan sederhana yang bisa kamu tanya ke Claude setiap session:
"mix test masih pass semua?"
"Function baru ini sudah ada test-nya?"
"Ada happy path, error path, dan edge case?"
Tiga pertanyaan ini saja sudah cukup untuk menjaga kualitas kode LabaBersih.