Testing smart contract artinya menjalankan contract di blockchain lokal yang dibuang setelah tes, lalu mengecek otomatis bahwa tiap fungsi berperilaku sesuai janji. Bayangin Raka sebelum menyablon 100 Kaos Edisi Kopi: ia selalu nyoba sablon di kain perca dulu, lalu mengecek warna, posisi, dan ejaannya. Kain perca boleh salah dan dibuang; kaos yang sudah disablon gak bisa diedit. Contract juga begitu: setelah di-deploy, kodenya gak bisa diubah, jadi semua kesalahan harus ketemu di "kain perca", yaitu chain lokal Hardhat.
🧪 Tebakan cepat: Raka punya 9 test
node:testuntuk PoinRaka, KaosEdisiKopi dan jam chain, semuanya hijau, coverage 100%. Lalu ada bug:redeemmemancarkanRedeemeddengan sisa saldo, bukan jumlah yang ditukar. Apakah 9 test itu menangkapnya? Tebak dulu, jawabannya ada di bagian "Coverage 100% belum tentu aman".
Hardhat 3 dan pilihan test
Hardhat adalah development environment untuk smart contract Ethereum: compile, deploy, test, dan debug, lengkap dengan blockchain lokal di dalam proses (Hardhat Network). Lesson ini memakai Hardhat 3.19.0 (Oktober 2026). npx hardhat --init menawarkan dua template TypeScript:
| Template | Test runner | Library | Assertion |
|---|---|---|---|
hardhat-toolbox-viem (yang direkomendasikan docs Hardhat) |
node:test bawaan Node.js |
viem | viem.assertions.* |
hardhat-toolbox-mocha-ethers |
Mocha | ethers | Chai: expect(...).to.be.revertedWith(...) |
Keduanya juga menjalankan Solidity test (*.t.sol, gaya forge-std). Lesson ini memakai template viem, dengan PoinRaka versi ERC-20 dan KaosEdisiKopi dari lesson sebelumnya di contracts/, dan test di test/.
Test pertama dan fixture
// test/PoinRaka.ts
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { network } from "hardhat";
import { getAddress, parseUnits } from "viem";
describe("PoinRaka", async function () {
const { viem, networkHelpers } = await network.create();
// Fixture: deploy sekali, lalu tiap test mulai dari snapshot yang sama
async function deployPoin() {
const [raka, dinda, yoga] = await viem.getWalletClients();
const poin = await viem.deployContract("PoinRaka");
return { poin, raka, dinda, yoga };
}
it("earn memberi 1 POIN per Rp 1.000", async function () {
const { poin, dinda } = await networkHelpers.loadFixture(deployPoin);
await poin.write.earn([dinda.account.address, 160_000n]);
assert.equal(await poin.read.balanceOf([dinda.account.address]), parseUnits("160", 18));
});
});
network.create()membuat chain lokal baru khusus file test ini, dengan 20 akun test publik Hardhat (akun #0 Raka, #1 Dinda, #2 Yoga). Kuncinya publik, jadi jangan pernah dipakai di chain sungguhan.viem.deployContract("PoinRaka")meng-compile lalu men-deploy dari akun #0, jadi Raka jadi owner.loadFixture(deployPoin): panggilan pertama menjalankandeployPoinlalu mengambil snapshot chain; panggilan berikutnya cuma mengembalikan chain ke snapshot itu. Tiap test mulai dari keadaan bersih, dan cepat.assert.equalmembandingkanbigint: Rp 160.000 harus jadiparseUnits("160", 18).
Menguji revert dan event
it("cuma Raka yang boleh earn", async function () {
const { poin, dinda } = await networkHelpers.loadFixture(deployPoin);
await viem.assertions.revertWithCustomErrorWithArgs(
poin.write.earn([dinda.account.address, 1_000_000n], { account: dinda.account }),
poin, "OwnableUnauthorizedAccount", [getAddress(dinda.account.address)],
);
});
it("earn memancarkan Earned(Dinda, 160 POIN)", async function () {
const { poin, dinda } = await networkHelpers.loadFixture(deployPoin);
await viem.assertions.emitWithArgs(
poin.write.earn([dinda.account.address, 160_000n]),
poin, "Earned", [getAddress(dinda.account.address), parseUnits("160", 18)],
);
});
{ account: dinda.account }mengirim transaksinya dari Dinda, bukan Raka. Test kegagalan sama pentingnya dengan test keberhasilan.revertWithCustomErrorWithArgslulus cuma kalau transaksi revert dengan custom error itu dan argumennya cocok. Test yang mirip mengecek Yoga (115 POIN) menukar 200:ERC20InsufficientBalance(Yoga, 115 POIN, 200 POIN).- Untuk revert dengan teks dari
require(seperti"Only owner"di PoinRaka versi pertama) pakaiviem.assertions.revertWith(promise, "Only owner"). - Di template Mocha + ethers bentuknya Chai:
await expect(tx).to.be.revertedWith("Only owner"),.revertedWithCustomError(poin, "OwnableUnauthorizedAccount").withArgs(dinda.address), dan.to.emit(poin, "Earned").withArgs(...).
Menjalankan test
$ npx hardhat test
Running Solidity tests
contracts/PoinRaka.t.sol:PoinRakaTest
✔ test_EarnDinda160()
✔ testFuzz_PoinFor(uint256) (runs: 256)
Running node:test tests
KaosEdisiKopi
✔ mint #1 untuk Dinda memancarkan Transfer dari alamat nol
...
PoinRaka
✔ earn memberi 1 POIN per Rp 1.000
✔ earn memancarkan Earned(Dinda, 160 POIN)
✔ cuma Raka yang boleh earn
✔ redeem 100 dari 160 menyisakan 60
✔ redeem melebihi saldo ditolak dengan ERC20InsufficientBalance
...
11 passing (2 solidity, 9 nodejs)
Kalau Raka lupa menulis _burn di redeem, dua test langsung merah, dan pesannya menunjuk baris yang salah:
1) PoinRaka
redeem 100 dari 160 menyisakan 60:
AssertionError: Expected values to be strictly equal
- 60000000000000000000n
+ 160000000000000000000n
at TestContext.<anonymous> (test/PoinRaka.ts:46:12)
Kenapa tiap test butuh fixture
Raka mencoba menghapus loadFixture dan memakai satu contract untuk semua test. Test "redeem 100 dari 160 menyisakan 60" mendadak gagal: + 380000000000000000000n. Dua test sebelumnya sudah memberi Dinda 160 + 160 POIN, lalu test ini menambah 160 dan menukar 100, jadi sisanya 380. Test yang saling menitip keadaan lulus atau gagal tergantung urutan. Fixture memastikan tiap test berdiri sendiri.
Coverage 100% belum tentu aman
npx hardhat test --coverage menghitung baris contract yang dijalankan test:
║ contracts/PoinRaka.sol │ 100.00 │ 100.00 │ - ║
║ contracts/KaosEdisiKopi.sol │ 100.00 │ 100.00 │ - ║
Kalau test redeem dihapus, baris 20-21 PoinRaka (_burn dan emit Redeemed) muncul di kolom Uncovered Lines dan angkanya turun ke 66,67%. Tapi dijalankan bukan berarti dicek. Dengan bug emit Redeemed(msg.sender, balanceOf(msg.sender)), kesembilan test tetap hijau: 9 passing. Gak ada test yang membaca event Redeemed. Itu jawaban tebakannya. Begitu Raka menambah test emitWithArgs untuk Redeemed(Dinda, 100 POIN), bug-nya ketahuan:
AssertionError: The event arguments do not match the expected ones.
- 100000000000000000000n,
+ 60000000000000000000n,
Solidity test dan fuzzing
// contracts/PoinRaka.t.sol
import {Test} from "forge-std/Test.sol";
import {PoinRaka} from "./PoinRaka.sol";
contract PoinRakaTest is Test {
PoinRaka poin;
function setUp() public {
poin = new PoinRaka();
}
function testFuzz_PoinFor(uint256 rupiah) public view {
rupiah = bound(rupiah, 0, 1e30); // belanja di atas Rp 10^30 gak masuk akal
assertEq(poin.poinFor(rupiah), (rupiah / 1000) * 1e18);
}
}
Fungsi yang namanya diawali testFuzz_ dan punya parameter dijalankan 256 kali dengan nilai acak (fuzzing). Sebelum ada baris bound, fuzzer menemukan angka yang membuat poinFor revert: panic code 0x11 (Arithmetic operation overflowed ...) dengan rupiah sekitar 3,133 × 10^76. Revert-nya aman (Solidity 0.8 mengecek overflow), dan belanja sebesar itu mustahil, jadi Raka membatasi inputnya. Alternatif populer untuk gaya ini adalah Foundry (v1.8.5, Oktober 2026): forge test menjalankan test .t.sol yang sama tanpa Node.js.
📌 Time travel dan laporan gas
PoinRaka dan KOPI belum bergantung waktu, tapi kalau nanti ada promo yang berakhir 7 hari lagi, test gak perlu menunggu 7 hari:
await networkHelpers.time.increase(7 * 24 * 60 * 60); // block berikutnya 604.800 detik lebih lambat
npx hardhat test --gas-stats mencetak gas per fungsi dari semua panggilan di test: earn 73.204, redeem 35.679, mint 62.359 sampai 96.559, deploy PoinRaka 1.229.456.
⚠️ Jebakan yang sering ketemu
- Cuma menguji jalur sukses. Bug paling mahal ada di jalur gagal: siapa yang boleh memanggil, saldo kurang, batas 100. Tiap
revertdi contract layak punya test. - Mengejar coverage, bukan pengecekan. 100% artinya tiap baris dijalankan, bukan tiap hasil dicek. Event, saldo dan argumen error harus di-
assert. - Test yang bergantung urutan. Tanpa fixture, keadaan dari test sebelumnya bocor (60 jadi 380).
- Lupa satuan.
160_000nadalah rupiah,parseUnits("160", 18)adalah POIN. Membandingkan160ndengan saldo akan selalu gagal.
TL;DR: Hardhat adalah development environment untuk compile, deploy, test, dan debug contract di chain lokal. Hardhat 3.19.0 merekomendasikan node:test + viem (hardhat-toolbox-viem); Mocha + ethers dengan Chai matchers juga tersedia, dan keduanya menjalankan Solidity test. Pakai loadFixture supaya tiap test mulai dari snapshot yang sama. Uji revert dengan viem.assertions.revertWithCustomErrorWithArgs (atau await expect(tx).to.be.revertedWith("...") di ethers) dan event dengan emitWithArgs. npx hardhat test menjalankan semuanya; --coverage menunjukkan baris yang belum dijalankan, --gas-stats gas per fungsi, time.increase memajukan jam chain, dan fuzzing di .t.sol (atau forge test di Foundry 1.8.5) mencoba ratusan input acak. Coverage 100% belum berarti aman: yang menangkap bug adalah assert yang mengecek hasilnya.