Python Smart Contract Testing — Deep Dive
The testing pyramid for smart contracts
Smart contract testing benefits from a layered strategy:
- Unit tests: Individual function behavior with controlled inputs. Fast, deterministic, run in milliseconds.
- Integration tests: Multi-contract interactions, realistic deployment sequences, cross-contract calls.
- Forked mainnet tests: Interactions with real deployed protocols using snapshotted state.
- Fuzzing/invariant tests: Automated exploration of the input space to find unexpected failures.
- Formal verification: Mathematical proof that certain properties always hold (typically done outside Python, but results inform test design).
Each layer catches different bug classes. Unit tests find logic errors. Integration tests find interface mismatches. Fuzz tests find edge cases humans would never write.
Ape framework deep dive
Ape provides a plugin-based architecture that separates concerns cleanly:
# conftest.py
import pytest
@pytest.fixture(scope="session")
def deployer(accounts):
return accounts[0]
@pytest.fixture(scope="session")
def attacker(accounts):
return accounts[9]
@pytest.fixture
def vault(project, deployer):
return deployer.deploy(project.Vault)
@pytest.fixture
def token(project, deployer, vault):
t = deployer.deploy(project.MockERC20, "Test", "TST", 18)
t.mint(deployer, 10**24, sender=deployer)
t.approve(vault, 10**24, sender=deployer)
return t
Ape compiles contracts automatically, injects the project fixture containing all compiled contract types, and manages account objects with built-in signing.
Testing reverts and events
import ape
def test_unauthorized_withdrawal(vault, token, deployer, attacker):
# Deposit first
vault.deposit(token, 1000, sender=deployer)
# Attacker should be rejected
with ape.reverts("Ownable: caller is not the owner"):
vault.withdraw(token, 1000, sender=attacker)
def test_deposit_emits_event(vault, token, deployer):
receipt = vault.deposit(token, 5000, sender=deployer)
events = vault.Deposited.from_receipt(receipt)
assert len(events) == 1
assert events[0].amount == 5000
assert events[0].depositor == deployer.address
Checking revert messages is critical — a function might revert for the wrong reason (e.g., insufficient balance instead of access control), and without message verification you’d get a false pass.
Stateful testing with Hypothesis
Hypothesis’s stateful testing module lets you define a state machine that represents valid contract interactions. The library then generates random sequences of operations to find invariant violations.
from hypothesis import settings
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant
class VaultStateMachine(RuleBasedStateMachine):
def __init__(self):
super().__init__()
self.vault = deploy_vault()
self.token = deploy_mock_token()
self.balances = {}
@rule(amount=integers(min_value=1, max_value=10**18))
def deposit(self, amount):
user = get_test_account()
mint_tokens(self.token, user, amount)
self.vault.deposit(amount, sender=user)
self.balances[user] = self.balances.get(user, 0) + amount
@rule()
def withdraw_all(self):
for user, expected in self.balances.items():
if expected > 0:
self.vault.withdrawAll(sender=user)
self.balances[user] = 0
@invariant()
def total_deposits_match(self):
on_chain = self.token.balanceOf(self.vault.address)
expected = sum(self.balances.values())
assert on_chain == expected
TestVault = VaultStateMachine.TestCase
This approach discovered the infamous bZx vulnerability pattern in reproduction: specific sequences of borrow/repay/liquidate that individually look valid but combine to drain funds.
Forked mainnet testing in practice
Testing against real DeFi protocol state requires careful setup:
# ape-config.yaml
ethereum:
mainnet_fork:
default_provider: foundry
transaction_acceptance_timeout: 600
# In tests
@pytest.fixture(scope="module")
def mainnet_fork(networks):
with networks.ethereum.mainnet_fork.use_provider("foundry"):
yield
def test_uniswap_swap(mainnet_fork, accounts):
whale = accounts["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"]
router = Contract("0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D")
# Impersonate a whale account
weth = Contract("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")
dai = Contract("0x6B175474E89094C44Da98b954EedeAC495271d0F")
weth_before = weth.balanceOf(whale)
router.swapExactTokensForTokens(
10**18, 0, [weth, dai], whale, 2**256 - 1,
sender=whale
)
assert dai.balanceOf(whale) > 0
Impersonation lets you send transactions as any address without its private key — essential for testing interactions with contracts owned by others. Anvil supports anvil_impersonateAccount for this.
Pitfalls of forked testing:
- State is frozen at fork block; new blocks don’t arrive unless you mine them explicitly.
- RPC calls for uncached storage slots hit the remote node, making tests slower.
- Token approvals and balances reflect the fork point, not current state.
Gas profiling and optimization
Smart contract gas costs directly affect user experience and economic viability. Python testing frameworks can measure gas per function call:
def test_batch_transfer_gas(token, deployer, accounts):
recipients = [accounts[i] for i in range(1, 11)]
amounts = [100] * 10
receipt = token.batchTransfer(recipients, amounts, sender=deployer)
gas_used = receipt.gas_used
# Single transfers for comparison
total_single = 0
for r, a in zip(recipients, amounts):
single_receipt = token.transfer(r, a, sender=deployer)
total_single += single_receipt.gas_used
savings = (1 - gas_used / total_single) * 100
assert savings > 30, f"Batch should save >30% gas, saved {savings:.1f}%"
Track gas usage across test runs to catch regressions. A storage refactor that saves 2000 gas per call adds up when the function is called millions of times.
Reentrancy testing
Reentrancy is the most exploited vulnerability class in smart contracts. Test it by deploying a malicious contract that calls back into the victim during execution:
def test_reentrancy_protection(vault, token, deployer, project):
# Deploy attacker contract that tries to re-enter on withdrawal
attacker = deployer.deploy(project.ReentrancyAttacker, vault.address)
token.transfer(attacker, 10000, sender=deployer)
# Attacker deposits legitimately
attacker.deposit(10000, sender=deployer)
# Attack should fail due to reentrancy guard
with ape.reverts():
attacker.attack(sender=deployer)
# Vault balance should be intact
assert token.balanceOf(vault) == 10000
The ReentrancyAttacker contract is a Solidity test helper that overrides its receive() or fallback function to call vault.withdraw() again before the first withdrawal completes.
CI pipeline integration
Smart contract tests should run on every pull request. A typical GitHub Actions setup:
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
- name: Install Python deps
run: pip install eth-ape pytest hypothesis
- name: Compile contracts
run: ape compile
- name: Run tests
run: ape test --gas -s
env:
WEB3_ALCHEMY_API_KEY: ${{ secrets.ALCHEMY_KEY }}
For forked tests, cache the chain state to avoid hitting RPC limits. Anvil supports --fork-block-number to pin the fork point, making tests deterministic across runs.
Coverage measurement
Solidity coverage tools integrated with Python frameworks show which contract lines are exercised by tests. Ape supports coverage reporting through plugins. Target 100% branch coverage for functions that handle funds — untested branches in financial logic are ticking time bombs.
Coverage gaps to watch for:
- Fallback and receive functions (often untested)
- Self-destruct paths
- Overflow conditions in unchecked blocks
- Multi-sig threshold edge cases
One thing to remember
Smart contract testing with Python combines the expressiveness of pytest and Hypothesis with blockchain-specific tools like forked state, impersonation, and reentrancy simulation — building confidence that immutable code will behave correctly when real money is at stake.
See Also
- Python Blockchain Data Analysis How Python detectives read the blockchain's public ledger to find patterns, explained with a library guest book analogy.
- Python Crypto Trading Bots How Python programs trade cryptocurrency automatically while you sleep, explained with a lemonade stand price watcher.
- Python Defi Protocol Integration How Python connects to decentralized finance protocols, explained through a self-service banking analogy.
- Python Ipfs Integration How Python stores and retrieves files on the decentralized web using IPFS, explained through a neighborhood library network.
- Python Nft Metadata Generation How Python creates the descriptions and images behind NFT collections, told through a trading card factory story.