How to write unit tests for your smart contract in Solidity with Brownie

In this tutorial, we are going to learn how to write Python unit tests for your smart contract in Solidity using the Brownie library.

In this tutorial, we are going to learn how to write Python unit tests for your smart contract in Solidity using the Brownie library.

Brownie provides you a development environment for Ethereum that allows you to create smart contracts in Solidity, compile them and run scripts and tests in Python that interact with your smart contracts and the blockchain.

Unit tests allow you to make sure that your smart contract works as expected in every situation without having to deploy your smart contract and test it manually.

It allows you to have confidence in your code when you deploy it, if the unit tests cover all the situations.

Brownie uses the pytest framework to allow you to write the unit tests. We're not going to see all the features in that library and in Brownie but we'll see the most important things and what you'll use the most.

The structure of the tests

Your unit tests will be contained in a Python file inside the tests folder in the root of your project folder.

It's mandatory that your files match the following naming , that file is named test_*.py or *_test.py (and you replace * with the name of the smart contract you test or with the name of your test).

Inside that file, don't forget to import pytest.

Within the test files, you're going to create functions that contain a unit test and that represent a specific test case, a situation. These functions can either be directly in the file or inside a class

Functions that contain the unit tests have 2 rules:

  1. Functions outside of a class need to have a name starting with test.
  2. Functions inside a class need to have a name prefixed with test, and the name of the class must be prefixed with Test and it should not include an __init__ method.

Inside these functions you'll always need to redeploy your smart contract, otherwise you won't be able to interact with it. There are solutions to avoid repeated code using fixtures.

How to deploy a smart contract in the unit tests

First, you'll need to import your smart contract from brownie (assuming it's compiled):

import pytest
from brownie import MyContract, accounts

def test_example():
    if len(MyContract) == 0:
        return MyContract.deploy({"from": accounts[-1]})
    return MyContract[len(MyContract) - 1]

To deploy the contract, you just call the deploy function on that contract and in the parameters you pass an options object that contains the account that you want to send the transaction from.

Brownie gives you accounts to send the transactions from so you can test calling the functions with different accounts.

In the code above, we check that the smart contract is not already deployed to avoid deploying it multiple times. If it hasn't been deployed before, we deploy it and return it, otherwise we return the previously deployed contract.

You can deploy a new contract every time if you want to reset the state of your smart contract before every test. But it's not recommended at all to do that on a testnet or a mainnet, you should only do that on your local node (like Ganache).

Interacting with the blockchain and the contracts in the unit tests

How to get the value of a public variable

To get the value of a public value in the smart contract you're testing, you can call it as if it was a function on the smart contract:

// in the smart contract
contract Foo {
    uint256 public myValue;
    
    constructor() {
        myValue = 1;
    }
}
// in the unit tests

import pytest
from brownie import Foo, accounts

def test_example():
    contract = MyContract.deploy({"from": accounts[-1]})
    // get myValue
    myValue = contract.myValue()

How to call a public function

To call a function from a smart contract, you can call it like a normal Python method on the contract object and pass the parameters to that function:

// in the smart contract
contract Foo {
    uint256 public myValue;
    
    constructor() {
        myValue = 1;
    }
    
    function setMyValue(uint256 _myValue) public {
        myValue = _myValue;
    }
}
// in the unit tests

import pytest
from brownie import Foo, accounts

def test_example():
    contract = MyContract.deploy({"from": accounts[-1]})
    // call function
    contract.setMyValue(2)

Alternatively, you can also call the function and in the parameters you pass the contract to call the function from, the account to call the transaction with and the parameters of the function:

// in the unit tests

import pytest
from brownie import Foo, accounts

def test_example():
    contract = MyContract.deploy({"from": accounts[-1]})
    // call function (contract, account, function parameters)
    transaction = setMyValue(contract, accounts[-1], 2)

Sending ETH along with calling a function

To send ETH when calling a function, you have to pass an extra parameter to the method you're calling in the smart contract. That parameter is an object that contains transaction options. It will return a transaction object:

// in the smart contract
contract Foo {
    uint256 public myValue;
    
    constructor() {
        myValue = 1;
    }
    
    function setMyValue(uint256 _myValue) public payable {
        myValue = _myValue;
    }
}
// in the unit tests

import pytest
from brownie import Foo, accounts

def test_example():
    account = accounts[-1]
    contract = MyContract.deploy({"from": account})
    // call function
    ethAmount = "1000000000000000000" // 1 ETH in Wei
    transaction = contract.setMyValue(2, {"from": account, "value": ethAmount})

Note that the amount of ETH to send is in Wei (ie. a number with 18 decimals). You can pass that value as a string or a BigNumber.

Then, you can wait for the transaction to be completed, meaning the function was called and the ETH is sent by calling the .wait method on the transaction object:

// in the unit tests

import pytest
from brownie import Foo, accounts

def test_example():
    account = accounts[-1]
    contract = MyContract.deploy({"from": account})
    // call function
    ethAmount = "1000000000000000000" // 1 ETH in Wei
    transaction = contract.setMyValue(2, {"from": account, "value": ethAmount})
    // wait for the transaction
    transaction.wait(1)

Using different accounts to call the functions

To use a different account to call the functions you can pass an extra parameter to the function you're calling and change the "from" value of the object passed:

// in the unit tests

import pytest
from brownie import Foo, accounts

def test_example():
    account = accounts[-1]
    contract = MyContract.deploy({"from": account})
    // call function

    transaction = contract.setMyValue(2, {"from": account})
    // wait for the transaction
    transaction.wait(1)

Using pytest with Brownie

As explained above, to make the unit test, you create a function and inside that function you:

  1. set up the contract and the state you want
  2. act on the smart contract
  3. expect something to happen or to not happen

We saw the first 2 parts in the sections above, now we're going to see the last part.

How to test that a variable has a specific value

To test that a variable has a specific value and make the test pass if it has the correct value and fail otherwise, we use the assert keyword and then we put a condition:

// in the smart contract
contract Foo {
    uint256 public myValue;
    
    constructor() {
        myValue = 1;
    }
}
// in the unit tests

import pytest
from brownie import Foo, accounts

def test_example():
    contract = MyContract.deploy({"from": accounts[-1]})
    // get myValue
    myValue = contract.myValue()
    // expect my value to equal 1
    assert myValue == 1

Using assert in the code above, the test will pass if myValue equals 1 and it will fail otherwise.

How to test that a function reverts

Besides testing that variables have the correct values, you also want to test that functions revert when a condition is not met.

To check that a function reverts, we use the with keyword with the reverts function:

// in the smart contract
contract Foo {
    uint256 public myValue;
    
    constructor() {
        myValue = 1;
    }
    
    function setMyValue(uint256 _myValue) public {
        require(_myValue != 1, "The value cannot be 1");
    }
}
// in the unit tests

import pytest
from brownie import Foo, accounts

def test_example():
    contract = MyContract.deploy({"from": accounts[-1]})
    
    // expect to revert with the message "The value cannot be 1"
    with reverts("The value cannot be 1"):
        transaction = contract.setMyValue(1)
        transaction.wait(1)

In the example smart contract above, the setMyValue function reverts if the parameter it receives is 1.

In our unit test, we call setMyValue function with 1 in the parameter so the the function will revert. Since we call that function inside a with reverts(""):, the test will pass if the function reverts and the test will fail if the function does not revert.

Running the unit tests with Brownie

To run the unit tests with brownie, here is the command:

brownie test

This will run all the test files and all the tests.

You can also pass a path to a test file to only run that specific test file:

brownie test tests/test_example.py

And that's it 🎉

Thank you for reading this article