The Tech Trends Web3 Smart Contracts Testing Smart Contracts: 12 Steps for Unit Tests with Truffle and Hardhat
Web3 Smart Contracts

Testing Smart Contracts: 12 Steps for Unit Tests with Truffle and Hardhat

Testing Smart Contracts: 12 Steps for Unit Tests with Truffle and Hardhat

Testing smart contracts means verifying that each function, modifier, and state transition behaves exactly as you intend under normal and adversarial conditions. In practice, unit tests run on a local Ethereum Virtual Machine (EVM) using frameworks like Truffle or Hardhat, with Mocha as the test runner and Chai assertions to express expectations. Done well, testing smart contracts reduces regressions, hardens security assumptions, and documents how your protocol should behave. This guide gives you a practical, end-to-end path to build a rock-solid unit test suite that’s fast, readable, and reliable.

Quick definition: Smart contract unit tests are automated checks that run against isolated pieces of contract logic on a local EVM, validating expected outputs, state changes, events, and errors.

Skimmable step list (you’ll find each step in detail below):

  1. Set your test strategy and scope
  2. Scaffold the project and essential tooling
  3. Configure a deterministic local chain
  4. Write testable Solidity code
  5. Build fixtures and deployments
  6. Structure readable, isolated tests
  7. Mock external contracts and time
  8. Add property-based and fuzz tests
  9. Assert events, errors, and access control
  10. Track gas and performance budgets
  11. Enforce coverage and test quality
  12. Wire tests into CI with fast feedback

You’ll finish with a suite that catches logic bugs early, surfaces gas hot spots, and guards against dangerous edge cases—before any transaction touches real funds.

1. Decide What You’ll Test: Strategy, Scope, and Boundaries

Start by defining what “done” looks like for your unit tests. Your goal is to prove contract behavior at the smallest practical scope, so you can pinpoint failures quickly and maintain speed. A clear strategy sets your expectations for coverage, outlines which invariants must always hold, and identifies where mocks are appropriate. For a token with transfer restrictions, for example, unit tests should cover balance accounting, revert reasons on invalid transfers, proper event emission, and role-gated functions. Integration tests (separate from this article’s focus) can then validate how modules collaborate—DEX interactions, oracle reads, or cross-contract calls. Decide early how you’ll partition tests between unit and integration, and write a one-page testing charter: what you will test, what you won’t, acceptable flakiness (ideally zero), and a target runtime (e.g., under 30 seconds locally). For teams, set PR gates (tests must pass, coverage above threshold) and establish a naming convention for tests, fixtures, and mocks so everyone contributes consistently. This alignment prevents sprawling, brittle suites and speeds reviews.

Numbers & guardrails

  • Coverage target: typical unit test suites aim for ≥ 80% line coverage, with 100% for critical libraries (math, auth). Use coverage as a guide, not a goal.
  • Runtime budget: keep local unit tests under 30–60 seconds to encourage frequent runs.
  • Flakiness: 0 tolerated; use deterministic seeds and reset state between tests.

Synthesis: A written strategy keeps your suite lean, fast, and aligned with risk. It also gives you objective criteria to decide when a feature is ready to ship.

2. Scaffold the Project and Essential Tooling

Set up a clean project with opinionated defaults. With Hardhat, initialize the project, add ethers.js, Mocha/Chai, and @nomicfoundation/hardhat-chai-matchers for expressive assertions. Chai matchers let you write test intent in human-readable form (e.g., expect(tx).to.emit(…)). Truffle ships with Mocha and Chai support; truffle test runs JavaScript or Solidity tests out of the box. Use TypeScript for safer test code and autocompletion via TypeChain if you prefer typed bindings. Organize tests by contract or feature, and separate unit and integration tests (e.g., test/unit/**, test/integration/**). Install dotenv for secrets, and create npm scripts for common flows: test, test:gas, coverage, and test:fork. If you’re migrating an existing project, align versions of ethers, Hardhat plugins, and Node to minimize tooling churn.

Tools/Examples

  • Hardhat + Chai matchers provide Ethereum-specific assertions like revertedWithCustomError, changeTokenBalance, and event checks.
  • Truffle uses Mocha + Chai; truffle test discovers tests under ./test.
  • ethers.js is the standard library for interacting with contracts in tests.

Synthesis: A predictable toolchain and folder layout make tests discoverable, reduce onboarding time, and keep your suite maintainable.

3. Configure a Deterministic Local Chain

Determinism is the antidote to flaky tests. Both Hardhat Network and Ganache can mine blocks instantly, provide funded accounts, and let you control time and state so tests execute repeatably. In Hardhat, configure chain behavior (e.g., default hardfork, gas) in hardhat.config. Prefer Hardhat Network for built-in helpers and tight integration with ethers and Chai matchers. For Truffle projects, Ganache (GUI or CLI) offers a quick local blockchain that mines instantly and supports stable account mnemonics; you can also attach Truffle to Hardhat Network if you want consistent behavior. If you do long-running test groups, snapshot and restore chain state to avoid expensive redeployments. For tests involving real protocol interactions, use mainnet forking with Hardhat—impersonate accounts, set balances, and verify against live-contract bytecode while keeping execution local and free.

Numbers & guardrails

  • Block time: default to auto-mine; only throttle if you need to test pending behavior.
  • State reset: reload a fresh snapshot for every it or every describe depending on cost.
  • Forking: isolate forked tests; avoid mixing with unit tests to keep run times predictable. Hardhat supports forking plus helpers like setBalance and impersonateAccount.
  • Config sanity: document the chosen hardfork in your config to avoid future surprises. Hardhat

Citations: Ganache overview and CLI options document deterministic local chains and configuration switches.

Synthesis: A well-tuned local chain removes randomness, shortens feedback loops, and lets you model complex scenarios—including forked mainnet states—without touching real funds.

4. Write Testable Solidity: Small, Observable, and Explicit

Unit tests are only as good as the code under test. Favor small, single-purpose functions with explicit visibility (external/public/internal), predictable side effects, and events that narrate state changes. Emit events for externally observable results (Transfer, RoleGranted, Paused) so your tests can assert that important transitions happened and with the correct parameters. Use custom errors and clear revert strings for negative paths; unit tests should expect the precise reason contracts fail. Keep business logic in libraries where practical; library functions are easier to unit test and reuse. Minimize reliance on block.timestamp and block.number by abstracting time into helpers or reading once and caching. When depending on external protocols, design interfaces that are easy to mock in tests. Finally, guard admin functions with role checks—tests should prove that unauthorized callers cannot mutate protected state.

Mini-checklist

  • Observability: emit events for meaningful changes.
  • Specific failures: use custom errors (or strings) per failure path.
  • Interfaces: codify dependencies in interface contracts for mocking.
  • Purity: prefer pure/view where possible for predictable behavior.
  • Access control: centralize modifiers; assert them in tests.

Synthesis: Clean boundaries and explicit behavior make tests simpler, clearer, and harder to break accidentally.

5. Build Fixtures and Repeatable Deployments

Fixtures create a known world for each test: deployed contracts, initialized values, and seeded balances. In Hardhat, loadFixture (from the Network Helpers) deploys once and snapshots chain state; each test reloads that snapshot in milliseconds, giving you speed and consistency. Truffle users can seed state via migrations or per-test setup, and they can still benefit from Hardhat’s local network by running Truffle against it. Keep fixtures minimal—only deploy what the test needs—and centralize common setup (e.g., a token + vault + roles). Return handles to contracts, signers, and constants from the fixture function so tests remain thin. For tricky initialization, write miniature deployment scripts that your fixtures can call to mirror production flows.

How to do it

  • Hardhat: use loadFixture to deploy once and snapshot for reuse; combine with helpers to set balances or timestamps.
  • Truffle: provide a clean deployment in beforeEach or reuse scripted migrations; run with truffle test.

Numeric mini case

A vault with a deposit cap of 1,000 ether and fee of 50 bps (0.5%) can be seeded in a fixture: mint 1,200 ether to a user, attempt to deposit 1,100 ether, and assert revert with “CapExceeded”; then deposit 1,000 ether and assert a net credited balance of 995 ether (after the fee). This pattern proves both a negative and a positive path with realistic figures.

Synthesis: Fixtures let you test complex flows quickly without redeploying every time, turning slow suites into snappy feedback.

6. Structure Readable, Isolated Tests

Organize tests the way you explain behavior to another developer. Use Arrange–Act–Assert (AAA) or Given/When/Then to make the intent pop at a glance. Each describe block should frame a feature or function; each it should state a concrete, observable behavior. Keep one assertion theme per test—prefer multiple small tests over one mega-test with mixed concerns. Use the Hardhat Chai matchers to make assertions expressive: to.emit, to.be.revertedWithCustomError, to.changeTokenBalance, and more. Short helper functions (e.g., as(user).deposit(…)) keep tests fluent and DRY. Reset state between tests using snapshots or fixtures to avoid hidden coupling. Finally, name your tests with user-facing language: “reverts when cap exceeded,” “emits Transfer on successful mint,” and “denies non-admin pause.”

Compact reference table (assertions)

Behavior targetHelpful matcherExample
Reverts with custom errorrevertedWithCustomErrorawait expect(tx).to.be.revertedWithCustomError(vault, “CapExceeded”)
Emits event with argsemitawait expect(tx).to.emit(token, “Transfer”).withArgs(a, b, amount)
Balance changeschangeTokenBalance(s)await expect(tx).to.changeTokenBalance(token, user, amount)
Ether transferchangeEtherBalanceawait expect(tx).to.changeEtherBalance(user, -fee)

Citations: Chai matchers are provided by the official plugin.

Synthesis: Clear structure and expressive assertions make failures obvious and speed up debugging and reviews.

7. Mock External Contracts and Manipulate Time

Most real-world contracts depend on other contracts—price feeds, routers, bridges. Unit tests should replace those dependencies with mocks so you can deterministically script responses and edge cases. With smock you can create Hardhat-friendly mocks entirely in JavaScript/TypeScript, stub return values, and even poke storage for advanced scenarios. For time-based logic (vesting cliffs, auctions), you don’t wait; you advance time on the local chain. Hardhat provides JSON-RPC methods and Network Helpers (e.g., time.increase, time.increaseTo) that make this simple. Remember to mine a block after changing time so the new timestamp takes effect on the next transaction. Keep your time travel utilities in one helper to standardize usage across tests.

Tools/Examples

  • smock: a Solidity mocking library and Hardhat plugin for creating and controlling mocks.
  • Hardhat time helpers and JSON-RPC calls like evm_increaseTime and evm_setNextBlockTimestamp advance timestamps for time-dependent tests.

Numeric mini case

If a vesting schedule requires a 90-day cliff, call time.increaseTo(start + 90 days + 1) and assert that claim() now succeeds; assert that at start + 89 days it reverts with “CliffNotReached”.

Synthesis: Mocks isolate your unit under test; controlled time makes temporal logic testable and fast.

8. Add Property-Based and Fuzz Tests

Beyond fixed examples, property-based and fuzz tests explore many random inputs to uncover edge cases you didn’t think of. Invariants like “total supply never exceeds cap,” “sum of balances equals total supply,” or “collateral ratio stays above X” can be asserted across many sequences of operations. While Hardhat’s standard stack focuses on example-based tests, you can layer property tools alongside it. Some teams use external tools (e.g., dedicated fuzzers) or custom generators over Mocha to randomize sequences. Keep fuzz expectations tight and stateless when possible; for stateful fuzzing, snapshot/restore around each run to avoid cascading state pollution. Log minimal diagnostics on failures (seed, sequence) so you can reproduce quickly.

Why it matters

  • Properties catch arithmetic and state-machine errors that single examples miss.
  • Fuzzing often reveals reentrancy-like sequences or underflow boundaries in edge domains.

Numbers & guardrails

  • Start with 100–500 cases per property and scale up only if runtime permits.
  • Keep fuzz runs < 5 seconds each; prioritize the most critical invariants.

Synthesis: Properties and fuzzing discover “unknown unknowns,” complementing example-based unit tests for stronger assurance.

9. Assert Events, Revert Reasons, and Access Control

Events are your public audit trail; tests should confirm that the right events fire with the right parameters on success and that the right errors fire on failure. Use Chai matchers to assert event names and arguments, and to check revertedWith or revertedWithCustomError for failures. Verify role checks with positive and negative paths: authorized callers succeed; unauthorized callers fail. When testing tokens, pair event assertions with balance change matchers to ensure state matches logs. For Truffle, pair assertions with OpenZeppelin Test Helpers to check reverts and other behaviors in a readable way.

Tools/Examples

  • Event and revert matchers in Hardhat Chai matchers make assertions concise and expressive.
  • OpenZeppelin Test Helpers offer expectRevert, expectEvent, and more in Truffle/ethers tests.

Mini-checklist

  • Assert event emission and argument values on every state-changing success.
  • Assert specific revert reasons for each failure path.
  • Test both sides of access control: allowed vs. denied.

Synthesis: Tight assertions around events and errors ensure your contract tells the truth about what happened—and fails loudly and specifically when it should.

10. Track Gas and Performance Budgets

Gas costs influence UX and viability; make them visible in tests. With Hardhat, use hardhat-gas-reporter to print per-method gas consumption and even translate it into estimated currency costs. Compare deltas when refactoring and add simple budget checks for critical functions (e.g., “deposit should stay under 100,000 gas with default path”). If your project targets L1 and L2s, run the reporter across configurations to understand variance. Keep gas assertions realistic: allow a buffer to absorb compiler or dependency changes, and avoid over-fitting to today’s exact figures.

Tools/Examples

  • hardhat-gas-reporter integrates with your test run and outputs gas summaries by function and deployment.
  • For optimization passes, review gas reports after each change; pair with coverage to ensure you’re not measuring dead paths.

Numbers & guardrails

  • Pick budgets for hot paths (e.g., transfers under 60–70k; complex state updates under 120–180k) and track trendlines.
  • Keep test runtime acceptable by limiting gas reporting to specific test sets when needed.

Synthesis: Gas visibility during testing prevents regressions and lets you trade code clarity and cost with data, not guesses. docs.base.org

11. Enforce Coverage and Test Quality

Coverage isn’t the whole story, but it’s a strong signal. Use solidity-coverage (or built-in coverage if available in your toolchain) to generate reports and identify untested branches. Require a threshold in CI so new code doesn’t quietly reduce assurance. Combine coverage with mutation-style thinking: write tests that would fail if you changed a comparison operator, removed a require, or skipped an event. Keep an eye on false comfort: 95% coverage with weak assertions is still weak. Prefer precise, behavior-oriented checks that would break if the logic changed incorrectly.

Tools/Examples

  • solidity-coverage plugs into Hardhat to show line and branch coverage for your contracts.
  • Some stacks now provide built-in coverage flags to run tests with coverage seamlessly. Hardhat

Numbers & guardrails

  • Target ≥ 80% line coverage, ≥ 90% on critical math/auth libraries.
  • Fail CI if coverage drops > 2 percentage points on a PR.

Synthesis: Coverage enforces discipline and keeps blind spots from creeping back in as your code evolves.

12. Wire It All into CI and Keep Feedback Fast

Automation closes the loop. Configure your CI (GitHub Actions, GitLab CI) to install dependencies, compile contracts, and run the unit test matrix. Cache node_modules and Artifacts to speed cold starts. Run unit tests and linters on every push; consider a separate nightly job for forked integration tests and extended fuzzing. Publish gas and coverage artifacts for easy review. If your repo includes multiple networks or sub-packages, shard tests across runners to keep PR feedback under a few minutes. Make failures easy to triage: surface the failing test names, seed for fuzz failures, and links to artifacts. Finally, document the developer workflow—how to run the same jobs locally with npm scripts—so everyone can reproduce CI behavior with a single command.

Mini-checklist

  • Matrix: Node versions, Solidity compiler settings if relevant.
  • Artifacts: coverage HTML, gas reports, junit output.
  • Secrets: keep RPC keys in CI secrets; never commit them.
  • Policies: block merges on red builds and coverage drops.

Synthesis: A tight CI loop makes testing a habit and stops regressions before they reach main.


Conclusion

A reliable unit test suite is your smartest insurance policy in smart contract development. By clearly defining scope, scaffolding with the right tools, and insisting on determinism, you convert complex, state-dependent logic into fast, repeatable checks. Mocks isolate external risk; controlled time and fixtures make even temporal behavior trivial to validate. Gas budgets and coverage keep you honest about performance and thoroughness, while CI automation ensures every change meets the same bar. If you adopt these twelve steps, you’ll ship with fewer regressions, clearer intent, and a test harness that scales with your protocol’s complexity. Copy this approach into your next repo and make testing a first-class feature—starting today.

CTA: Add the scripts from this guide to your package.json and run your unit tests now.

FAQs

1) What’s the difference between unit, integration, and end-to-end tests for smart contracts?
Unit tests focus on a single contract or function in isolation, often with dependencies mocked. Integration tests check interactions across multiple contracts or with real protocol components (e.g., a DEX router on a forked chain). End-to-end tests validate the whole system—including off-chain parts like UIs or keepers—against a realistic environment. Start with unit tests because they’re fastest and provide the clearest failure signals.

2) Should I write tests in JavaScript/TypeScript or Solidity?
JavaScript/TypeScript tests (Hardhat, Truffle) offer rich tooling, powerful assertions, and easy mocking. Solidity tests (supported in Truffle) can be useful for certain low-level cases but are less ergonomic for orchestrating complex scenarios. Most teams prefer JS/TS for productivity and ecosystem support, and they add TypeChain for typed contract bindings.

3) How do I test time-dependent logic without waiting?
Use Hardhat’s Network Helpers or JSON-RPC methods (evm_increaseTime, evm_setNextBlockTimestamp) to advance time, and then mine a block so the new timestamp is effective. Wrap these in a helper function to keep tests consistent and easy to read. This approach lets you validate cliffs, vesting unlocks, or auction endings in milliseconds.

4) What if my contract depends on an external protocol like a price oracle?
Mock it. Replace external reads/writes with a controllable fake that returns scripted responses and reverts on cue. With smock, you can define return values and failures per call and even tweak storage for advanced cases. Keep integration tests on a forked chain to double-check interactions with the real protocol later. GitHub

5) Do I need 100% coverage?
No. Aim for high, meaningful coverage—≥ 80% is a common baseline—and require near-complete coverage on critical math and authorization paths. Coverage is a guide; combine it with strong, behavior-focused assertions and negative tests to ensure quality rather than chasing a number. docs.base.org

6) How do I check that events are emitted correctly?
Use Chai matchers to assert events and their arguments in a single line: await expect(tx).to.emit(contract, “Event”).withArgs(a, b). Pair event checks with state assertions (balances, counters) to confirm the log aligns with reality. These matchers are part of Hardhat’s official plugin.

7) Can I test on live data without risking funds?
Yes—mainnet forking lets you clone chain state locally so you can read live contracts, impersonate accounts, and simulate interactions safely. Keep these tests in a separate suite because they’re slower and rely on a remote RPC. This is ideal for strategies that integrate with on-chain protocols.

8) What tools measure and improve gas costs during testing?
Run your test suite with hardhat-gas-reporter enabled to see per-function consumption and deployment costs. Use these numbers as budgets, track deltas in PRs, and only add hard thresholds where it makes sense. Gas awareness during testing prevents accidental cost spikes from refactors.

9) How do I run tests with Truffle?
Place your tests under ./test and run truffle test. Truffle uses Mocha and Chai, supports both JavaScript and Solidity tests, and can attach to Ganache or another local EVM. Keep migrations simple for unit tests; treat complex deployments as fixtures or scripted setup.

10) What causes flaky tests and how do I eliminate them?
Common culprits include shared state across tests, non-deterministic ordering, and implicit time assumptions. Use fixtures or snapshots to reset state between tests, run with a deterministic local chain, and avoid depending on block numbers or timestamps unless you set them explicitly. Keep each test self-contained and mock external dependencies for consistency.

References

  1. hardhat-chai-matchers — Plugin docs, Hardhat (Nomic Foundation), publication date available on docs site. Hardhat
  2. Write JavaScript tests, Truffle Suite Docs, date not listed. archive.trufflesuite.com
  3. Test your contracts (truffle test), Truffle Suite Docs, date not listed. archive.trufflesuite.com
  4. Hardhat Network Helpers — Reference, Hardhat (Nomic Foundation), publication date available on docs site. Hardhat
  5. Hardhat Forking — Guide, Base Docs, date not listed. docs.base.org
  6. hardhat-gas-reporter — README, cgewecke (GitHub), date not listed. GitHub
  7. solidity-coverage — Plugin, sc-forks (GitHub), date not listed. GitHub
  8. OpenZeppelin Test Helpers, OpenZeppelin (GitHub), date not listed. GitHub
  9. Ganache — Overview, Truffle Suite Docs, date not listed. archive.trufflesuite.com
  10. Ethers.js — Documentation v5, ethers.org, date not listed. docs.ethers.org

Leave a Reply

Your email address will not be published. Required fields are marked *

Exit mobile version