Smart Contract Testing (Hardhat) — Web3

Smart Contract Testing (Hardhat) Hardhat adalah development environment untuk Ethereum. Fitur utamanya: local blockchain node, testing framework, dan…

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:test untuk PoinRaka, KaosEdisiKopi dan jam chain, semuanya hijau, coverage 100%. Lalu ada bug: redeem memancarkan Redeemed dengan 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));
  });
});

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)],
  );
});

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

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.

Yang akan kamu pelajari