TDD for Ethereum Smart Contracts: Guide

September 24, 2024

Test-Driven Development (TDD) is a game-changer for Ethereum smart contract development. Here's why:

  • Catches bugs early
  • Improves code quality
  • Boosts security
  • Can speed up development

Key steps for TDD in Ethereum:

  1. Write a test
  2. Watch it fail
  3. Code to make it pass
  4. Test again
  5. Clean up if needed

Tools you'll need:

Best practices:

  • Write clear tests
  • Test state changes and interactions
  • Check for errors and edge cases

Common mistakes to avoid:

  • Not testing enough error cases
  • Ignoring malicious inputs
  • Forgetting about gas costs

Remember: TDD might feel odd at first, but it's worth it. Your future self (and users) will thank you.

Feature Truffle Hardhat
Age Older (2016) Newer (2019)
Testing Mocha, Chai Mocha, Chai
Debugger Built-in Plugin
TypeScript Manual Out-of-box
Local Chain Needs Ganache Built-in

What you need to know first

Before jumping into TDD for Ethereum smart contracts, you'll need some skills and tools. Here's the rundown:

Required skills

To start with TDD for Ethereum smart contracts, you should know:

  • Solidity programming
  • Basic Ethereum concepts
  • General programming

Necessary tools

Here's what you'll need:

Tool Purpose Setup
Truffle or Hardhat Development framework npm install -g truffle or npm install --save-dev hardhat
Ganache Local Ethereum blockchain Download UI or npm install -g ganache-cli
Node.js JavaScript runtime Download from nodejs.org
Solidity compiler Compiles smart contracts Comes with Truffle/Hardhat
Text editor or IDE Writing code and tests Try VS Code with Solidity extension

Setting up your environment:

  1. Install Node.js
  2. Pick Truffle or Hardhat
  3. Set up Ganache
  4. Install extras:
npm install @openzeppelin/contracts
npm install @chainlink/contracts

This setup will get you ready for TDD with Ethereum smart contracts.

Setting up your work environment

To start using TDD for Ethereum smart contracts, you'll need to set up your environment. Here's how:

Pick a development framework

Truffle and Hardhat are two popular choices. Let's compare:

Feature Truffle Hardhat
Project structure Built-in Flexible
Testing framework Mocha and Chai Mocha and Chai
Deployment Built-in scripts Customizable tasks
Debugging Limited Advanced
Community support Large Growing

Hardhat's flexibility and debugging tools make it a solid pick for TDD.

Install the software

  1. Get Node.js (v12+) from nodejs.org

  2. Run this in your terminal:

    npm install -g truffle
    

    or

    npm install --save-dev hardhat
    
  3. Install Ganache for a local blockchain:

    npm install -g ganache-cli
    

Set up for testing

Now, let's get ready to test:

  1. Make a new project folder:

    mkdir EthereumTDD && cd EthereumTDD
    
  2. Start your project:

    Truffle:

    truffle init
    

    Hardhat:

    npx hardhat
    
  3. Get testing dependencies:

    npm install --save-dev @openzeppelin/test-helpers
    
  4. Create your test file:

    mkdir test
    touch test/MyContract.test.js
    
  5. Set up your config file. For Truffle, it might look like this:

    module.exports = {
      networks: {
        development: {
          host: "127.0.0.1",
          port: 8545,
          network_id: "*"
        }
      },
      mocha: {
        timeout: 100000
      }
    };
    

You're now ready to write and run tests for your Ethereum smart contracts using TDD.

TDD steps for smart contracts

Let's dive into Test-Driven Development (TDD) for Ethereum smart contracts:

1. Write your first test

Start with a test that outlines your smart contract's desired behavior. Here's an example for a price feed contract:

import { expect } from "chai";
import { ethers } from "hardhat";

describe("Price Feed", () => {
  it("Gets the PriceFeed contract", async () => {
    const PriceFeed = await ethers.getContractFactory("PriceFeed");
    expect(PriceFeed);
  });
});

This test checks if we can get the PriceFeed contract.

2. Run tests and make changes

Run your test:

yarn hardhat test

It'll fail. Why? We haven't created the PriceFeed contract yet. Let's fix that:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract PriceFeed {
  constructor() {}
}

Run the test again. It should pass now.

3. Improve your code

Time to add more features. Let's add a function to get the Ether price feed:

AggregatorV3Interface internal ethPriceFeed;

constructor() {
  ethPriceFeed = AggregatorV3Interface(0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419);
}

function getEthPriceFeed() public view returns (AggregatorV3Interface) {
  return ethPriceFeed;
}

Now, write a test for this new function:

it("Gets the Ether price feed", async () => {
  const PriceFeed = await ethers.getContractFactory("PriceFeed");
  const priceFeed = await PriceFeed.deploy();
  expect(await priceFeed.getEthPriceFeed()).to.not.be.undefined;
});

Run the tests again. If they pass, you're on the right track.

TDD Step Action Result
Write test Create test file Test fails
Write code Implement minimum code Test passes
Refactor Improve code Tests still pass

That's TDD for smart contracts in a nutshell. Write a test, make it pass, then improve your code. Rinse and repeat.

TDD best practices for smart contracts

Smart contracts need rock-solid security. Test-Driven Development (TDD) helps. Here's how to do it right:

Write clear tests

Make your tests easy to understand:

describe("Token Contract", () => {
  describe("Minting", () => {
    it("mints tokens to an address", async () => {
      // Test code
    });

    it("increases total supply after minting", async () => {
      // Test code
    });
  });
});

Test states and interactions

Smart contracts are all about state changes and working with other contracts. Test these:

  1. State changes after function calls
  2. How contracts interact
  3. Who can do what (access control)
Test Example
State changes Token balance after transfer
Interactions External price feed behavior
Access control Only owner can call destroyFaucet()

Test errors and edge cases

Don't just test when everything goes right. Look for problems:

  • Try invalid inputs
  • Make sure errors are handled properly
  • Test for attacks like reentrancy

Remember the DAO hack in 2016? $50 million gone. Good tests might have stopped it. Here's how to test for reentrancy:

it("stops reentrancy attacks", async () => {
  // Deploy malicious contract
  // Try the attack
  // Check that it fails
});

TDD isn't just about catching bugs. It's about building better, safer smart contracts from the start.

sbb-itb-a178b04

Tools for TDD in Ethereum

Ethereum

Let's dive into the key tools for Test-Driven Development (TDD) in Ethereum smart contracts:

Test Runners: Truffle vs. Hardhat

Truffle

Two main players dominate the Ethereum testing scene:

1. Truffle Suite

Truffle's been around the block. It offers:

  • Smart contract compilation
  • Mocha and Chai for testing
  • A full-featured debugger

2. Hardhat

The new kid on the block, Hardhat brings:

  • Flexibility
  • Built-in Ethereum network
  • TypeScript support

Here's a quick comparison:

Feature Truffle Hardhat
Age Older (2016) Newer (2019)
Testing Mocha, Chai Mocha, Chai
Debugger Built-in Plugin
TypeScript Manual Out-of-box
Local Chain Needs Ganache Built-in

Assertion Libraries

Chai is the go-to for checking if your contracts work right. Here's how it looks:

In Truffle:

it("mints tokens", async () => {
  const token = await Token.deployed();
  const result = await token.mint(accounts[0], 100);
  assert.equal(result.logs[0].event, "Transfer", "Should transfer");
});

Hardhat's got some neat tricks:

expect(await token.balanceOf(user.address)).to.equal(100);

Code Coverage

Want to know how much of your code is tested? Enter solidity-coverage.

Here's how to use it with Hardhat:

  1. Install it: npm install --save-dev solidity-coverage

  2. Add to hardhat.config.js:

    require('solidity-coverage')
    
  3. Run: npx hardhat coverage

You'll get a report showing:

Metric What It Means
% Stmts Statements run
% Branch Branches hit
% Funcs Functions called
% Lines Lines executed

High coverage is good, but remember: 100% doesn't mean no bugs. It just means all lines ran during tests.

Advanced TDD methods for smart contracts

Let's look at some powerful techniques to level up your smart contract testing:

Property-based testing

Property-based testing takes TDD up a notch. Instead of specific test cases, you define properties your code should always meet. The framework then generates random inputs to test these properties.

Here's a Solidity QuickCheck example:

contract Sum {
    function sum(uint[] memory numbers) public pure returns (uint) {
        uint total = 0;
        for (uint i = 0; i < numbers.length; i++) {
            total += numbers[i];
        }
        return total;
    }
}

property "sum is equal to first half plus second half" {
    uint[] numbers <- generateRandomArray();
    uint mid = numbers.length / 2;
    uint[] firstHalf = numbers[0:mid];
    uint[] secondHalf = numbers[mid:];

    assert(Sum.sum(numbers) == Sum.sum(firstHalf) + Sum.sum(secondHalf));
}

This test runs hundreds of times with different random arrays, catching edge cases you might miss.

Fuzzing tests

Fuzzing throws random, weird inputs at your contract to find bugs. It's like having a mischievous tester trying to break everything.

To fuzz test Ethereum contracts:

  1. Install Echidna: pip install echidna-fuzzer
  2. Write a contract with properties to test:
contract Token {
    mapping(address => uint) balances;
    uint totalSupply;

    function transfer(address to, uint amount) public {
        require(balances[msg.sender] >= amount);
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }

    function echidna_balance_under_total() public view returns (bool) {
        return balances[msg.sender] <= totalSupply;
    }
}
  1. Run Echidna: echidna-test Token.sol --contract Token

Echidna tries to find inputs that make echidna_balance_under_total() return false, exposing potential bugs.

Gas optimization tests

Gas costs money on Ethereum, so efficient contracts matter. Here's how to test gas usage:

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Token", function() {
  it("should use less than 50,000 gas for transfer", async function() {
    const Token = await ethers.getContractFactory("Token");
    const token = await Token.deploy();
    await token.deployed();

    const [owner, addr1] = await ethers.getSigners();

    await token.mint(owner.address, 100);

    const tx = await token.transfer(addr1.address, 50);
    const receipt = await tx.wait();

    expect(receipt.gasUsed).to.be.below(50000);
  });
});

This test keeps your transfer function gas-efficient. Run it often to catch any changes that might increase costs.

Common mistakes and how to avoid them

When using TDD for Ethereum smart contracts, developers often make these mistakes:

Not testing enough error cases

Many devs only test the happy path. Bad idea. This leaves contracts open to unexpected failures.

How to fix it:

  • Test a bunch of scenarios, including edge cases
  • Use property-based testing for random inputs
  • Try fuzzing tests to uncover hidden bugs

Remember the DAO Re-Entrancy attack in 2016? A hacker stole $70 million worth of Ether due to insufficient error testing. Yikes.

Testing for malicious inputs

Smart contracts are hacker magnets. Ignoring malicious inputs? Recipe for disaster.

Protect your contract:

  • Check for zero addresses in functions
  • Test with weird or harmful data
  • Use fuzzing tools like Echidna

Here's a quick zero address check:

function setApprover(address _approver) public {
    require(_approver != address(0), "Invalid approver address");
    approver = _approver;
}

Including gas costs in tests

Forget about gas costs? Your contract might be too expensive to use on Ethereum.

Keep it efficient:

  • Set gas usage limits in tests
  • Compare gas costs for different implementations
  • Use gas optimization tools

Check out this gas usage test:

it("should use less than 50,000 gas for transfer", async function() {
    const tx = await token.transfer(recipient.address, amount);
    const receipt = await tx.wait();
    expect(receipt.gasUsed).to.be.below(50000);
});

Example: TDD for a basic token contract

Let's create a simple ERC-20 token contract using Test-Driven Development (TDD). We'll use Foundry and write our tests in Solidity.

Testing token creation

First, let's make a basic ERC-20 contract:

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol";

contract MyERC20 is ERC20 {
    constructor() ERC20("Name", "SYM") {
        this;
    }
}

Now, let's test if it's created correctly:

function testTokenCreation() public {
    MyERC20 token = new MyERC20();
    assertEq(token.name(), "Name");
    assertEq(token.symbol(), "SYM");
}

Run this test. If it passes, we're on the right track.

Testing token transfers

Time to test transfers. First, a failing test:

function testTransfer() public {
    MyERC20 token = new MyERC20();
    address bob = address(2);
    uint256 amount = 100;

    token.transfer(bob, amount);
    assertEq(token.balanceOf(bob), amount);
}

This will fail. Why? No tokens minted yet. Let's fix our contract:

contract MyERC20 is ERC20 {
    constructor() ERC20("Name", "SYM") {
        _mint(msg.sender, 1000);
    }
}

Now, update the test:

function testTransfer() public {
    MyERC20 token = new MyERC20();
    address bob = address(2);
    uint256 amount = 100;

    token.transfer(bob, amount);
    assertEq(token.balanceOf(bob), amount);
    assertEq(token.balanceOf(address(this)), 900);
}

Run it again. It should pass now.

Testing token allowances

Finally, let's test allowances:

function testAllowance() public {
    MyERC20 token = new MyERC20();
    address alice = address(1);
    address bob = address(2);
    uint256 amount = 100;

    token.approve(alice, amount);
    assertEq(token.allowance(address(this), alice), amount);

    vm.prank(alice);
    token.transferFrom(address(this), bob, amount);
    assertEq(token.balanceOf(bob), amount);
    assertEq(token.allowance(address(this), alice), 0);
}

This test checks approvals, transfers, and allowance spending.

Combining TDD with continuous integration

TDD for Ethereum smart contracts gets even better with continuous integration (CI). It catches problems early and keeps your code quality high.

Setting up automated tests

Here's how to set up automated tests:

  1. Pick a CI platform (GitHub Actions, CircleCI, etc.)
  2. Make a workflow file
  3. Set up your testing environment

Check out this GitHub Actions workflow for a Hardhat project:

name: Smart Contract Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Use Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '14.x'
      - run: npm ci
      - run: npx hardhat compile
      - run: npx hardhat test

This runs your tests automatically whenever you push or make a pull request.

Creating test reports

Want to keep track of your project's health? Generate test reports:

  1. Use a test reporter plugin
  2. Save reports as artifacts in your CI
  3. Use a dashboard service to visualize results

Here's how to save test results in CircleCI:

- store_test_results:
    path: test-results
- store_artifacts:
    path: test-results
    destination: test-results

Maintaining high test coverage

To keep your test coverage high:

  • Set coverage thresholds in your CI pipeline
  • Use tools like solidity-coverage
  • Make the build fail if coverage drops too low

Here's a Hardhat config for solidity-coverage:

module.exports = {
  solidity: "0.8.4",
  networks: {
    // ...
  },
  gasReporter: {
    // ...
  },
  mocha: {
    // ...
  },
  coverage: {
    exclude: ["contracts/mocks", "contracts/libraries"],
    threshold: {
      statements: 90,
      branches: 90,
      functions: 90,
      lines: 90
    }
  }
};

Conclusion

TDD isn't just a fancy acronym. It's a game-changer for Ethereum smart contract development.

Here's why TDD rocks:

  • It catches bugs EARLY. No more last-minute panic fixes.
  • Your code gets better. Cleaner, easier to manage.
  • Security? Boosted. You spot weak spots before they become real issues.
  • Surprisingly, it can speed things up. Less time debugging = more time building cool stuff.

Want to jump on the TDD train? Here's how:

1. Start with a plan. What should your contract do? Write it down.

2. Tests come first. Create tests that check if your contract does what it should.

3. Code bit by bit. Write just enough to pass each test. It's like solving a puzzle, one piece at a time.

4. Refactor without fear. Your tests have your back when you make changes.

TDD might feel weird at first. But stick with it. Your future self (and your users) will thank you.

Related posts

Recent posts