Test-Driven Development (TDD) is a game-changer for Ethereum smart contract development. Here's why:
Key steps for TDD in Ethereum:
Tools you'll need:
Best practices:
Common mistakes to avoid:
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 |
Before jumping into TDD for Ethereum smart contracts, you'll need some skills and tools. Here's the rundown:
To start with TDD for Ethereum smart contracts, you should know:
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:
npm install @openzeppelin/contracts
npm install @chainlink/contracts
This setup will get you ready for TDD with Ethereum smart contracts.
To start using TDD for Ethereum smart contracts, you'll need to set up your environment. Here's how:
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.
Get Node.js (v12+) from nodejs.org
Run this in your terminal:
npm install -g truffle
or
npm install --save-dev hardhat
Install Ganache for a local blockchain:
npm install -g ganache-cli
Now, let's get ready to test:
Make a new project folder:
mkdir EthereumTDD && cd EthereumTDD
Start your project:
Truffle:
truffle init
Hardhat:
npx hardhat
Get testing dependencies:
npm install --save-dev @openzeppelin/test-helpers
Create your test file:
mkdir test
touch test/MyContract.test.js
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.
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.
Smart contracts need rock-solid security. Test-Driven Development (TDD) helps. Here's how to do it right:
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
});
});
});
Smart contracts are all about state changes and working with other contracts. Test these:
Test | Example |
---|---|
State changes | Token balance after transfer |
Interactions | External price feed behavior |
Access control | Only owner can call destroyFaucet() |
Don't just test when everything goes right. Look for problems:
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.
Let's dive into the key tools for Test-Driven Development (TDD) in Ethereum smart contracts:
Two main players dominate the Ethereum testing scene:
1. Truffle Suite
Truffle's been around the block. It offers:
2. Hardhat
The new kid on the block, Hardhat brings:
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 |
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);
Want to know how much of your code is tested? Enter solidity-coverage
.
Here's how to use it with Hardhat:
Install it: npm install --save-dev solidity-coverage
Add to hardhat.config.js
:
require('solidity-coverage')
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.
Let's look at some powerful techniques to level up your smart contract 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 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:
pip install echidna-fuzzer
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;
}
}
echidna-test Token.sol --contract Token
Echidna tries to find inputs that make echidna_balance_under_total()
return false, exposing potential bugs.
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.
When using TDD for Ethereum smart contracts, developers often make these mistakes:
Many devs only test the happy path. Bad idea. This leaves contracts open to unexpected failures.
How to fix it:
Remember the DAO Re-Entrancy attack in 2016? A hacker stole $70 million worth of Ether due to insufficient error testing. Yikes.
Smart contracts are hacker magnets. Ignoring malicious inputs? Recipe for disaster.
Protect your contract:
Here's a quick zero address check:
function setApprover(address _approver) public {
require(_approver != address(0), "Invalid approver address");
approver = _approver;
}
Forget about gas costs? Your contract might be too expensive to use on Ethereum.
Keep it efficient:
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);
});
Let's create a simple ERC-20 token contract using Test-Driven Development (TDD). We'll use Foundry and write our tests in Solidity.
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.
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.
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.
TDD for Ethereum smart contracts gets even better with continuous integration (CI). It catches problems early and keeps your code quality high.
Here's how to set up automated tests:
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.
Want to keep track of your project's health? Generate test reports:
Here's how to save test results in CircleCI:
- store_test_results:
path: test-results
- store_artifacts:
path: test-results
destination: test-results
To keep your test coverage high:
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
}
}
};
TDD isn't just a fancy acronym. It's a game-changer for Ethereum smart contract development.
Here's why TDD rocks:
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.