An In-Depth Guide to Testing Ethereum Smart Contracts
Part Three: Writing Basic Tests
This article is part of a series. If you haven’t yet, check out the previous articles:
Part One: Why we Test
Part Two: Core Concepts of Testing
Part Three: Writing Basic Tests
Part Four: Running Your Tests
Part Five: Tools and Techniques for Effective Testing
Part Six: Parametrization and Property-Based Testing
Part Seven: Stateful Testing
It’s time to write our first tests! We will be testing functionality in a simple ERC20 contract. If you want to follow along using Solidity you can create a local copy of the contract with the following command:
brownie bake token
or if you prefer Vyper:
brownie bake vyper-token
Note that each of these projects come with a complete test suite. Feel free to look around at the files in the
tests/ directory for some more examples.
Writing our First Tests
Let’s start with a simple test:
- In line 1 we are importing the Brownie objects that we require for the test.
accountsgive us access to funded and unlocked accounts, and
Tokenis used to deploy our ERC20 contract.
- Line 3 is the start of our test. When using pytest, all functions that begin with
test_are considered a test.
- In lines 4 and 5 we perform our test setup. First we deploy a
Tokencontract, and then we keep a record of the balance of
accountswhich we will use later in our assertion.
- In line 7 we perform the action we are testing, transferring 10¹⁸ tokens from
- In line 9 we make our assertion: the token balance of
accountsshould have decreased by exactly 10¹⁸.
There is a fair amount happening here, but once you get the hang of it the syntax is quite simple.
Now, time for a second test:
The key difference between this test and the first is that in the first one we took a “happy path” — one where the code executed successfully; but in this test we are expecting a transaction to revert.
The important section here is lines 7 and 8, where we use the
brownie.reverts context manager. In order for the test to pass, the transaction within the context manager must revert with the given error message. Note that specifying an error message is optional — we could simply say
brownie.reverts() as a broad catch-all for any reverting transaction.
We have only scratched the surface of testable behaviors around the transfer function. Our test verified a change in the sender’s balance; an obvious next step is to verify that the receiver’s balance also changes.
Our new test will be nearly identical to the first one, so you might be tempted to copy-paste and make a few modification:
This works, but we’re repeating some code here. We’ll now have three tests that start by deploying a
Token contract. Later on if we modify the constructor arguments for our contract, we will have to change this line in every one of our tests. In a large test suite that could be a lot of work!
Fortunately pytest offers a solution to this problem, known as a fixture. Fixtures are functions that are applied to one or more tests, and which are called prior to the execution of each test. They are used to setup initial conditions that are required for a test.
To create a fixture, we apply the
@pytest.fixture decorator to a function. Let’s create a fixture to handle deploying our contract:
We then pass the result of the fixture into our tests by adding the fixture name as a test input argument:
The duplicated code is no more, and make our tests are now easier to maintain!
Sharing Fixtures across Test Modules
When you require the same fixture in multiple test files, you can move it into a file named
conftest.py within the same folder or a common parent folder. Pytest automatically discovers these fixtures and makes them available — you don’t have to import anything manually.
Along with passing fixtures into test functions, you can also pass fixtures into other fixtures. In this way you can build complex test setup processes with minimal repeated code.
As an example, let’s create a fixture to distribute tokens, that requires our original token fixture:
We can then add
distribute_tokens to any test where we require an initial token balance in many accounts.
Brownie provides us with a set of builtin fixtures to access various functionality. As an example, let’s further refactor our current tests to use the builtin
We’ve managed to remove the
import statement by instead accessing the same functionality via builtin fixtures.
Most core Brownie components and project objects are available via fixtures — the Brownie documentation contains a complete list.
The default behavior for a fixture is to execute each time it is required for a test. By adding the
scope parameter to the decorator, we can alter how frequently the fixture executes:
By setting our fixture to module scope, it now only deploys a single
Token contract, passing the same value into both tests. With complex test suites this can save you a significant amount of time.
Fixture of higher scopes (such as
module) are always instantiated before lower-scoped fixtures (such as
function). The execution order of fixtures of the same scope is determined by the order they are declared as fixture and test input arguments. The only exception to this rule is isolation fixtures, which we’ll explore next.
As we discussed previously, isolation is a key component of a well written test suite. In almost all cases you should isolate your tests from one another so the actions performed in one test cannot affect the outcome of the tests that come after it.
Brownie provides two fixtures that are used to handle isolation:
module_isolationis a module scoped fixture. It resets the local chain before and after completion of the test module, ensuring a clean environment for the module and that the results of it will not affect subsequent modules.
fn_isolationis function scoped. It additionally takes a snapshot of the chain before running each test, and reverts to it when the test completes. This fixture allows you to define a common chain state for each test, reducing repetitive transactions and thus making your tests complete quicker.
Isolation fixtures are always the first fixture within their scope to execute. You can be certain that all module-scoped fixtures will be executed before the isolation snapshot, and all function-scoped fixtures will run after the snapshot.
To apply an isolation fixture to all tests in a module, require it in another fixture and include the
autouse keyword argument:
Bringing it all Together
Let’s do one last refactor of our tests, bringing together everything that we’ve discussed so far:
- Lines 5–7 we declare a fixture that deploys the
Tokencontract. We set it as module-scoped so that it only deploys one contract for all of the tests.
- Lines 10–12 we add the
fn_isolationfixture to ensure that our tests are properly isolated. A snapshot of the local blockchain will be taken immediately after the
tokenfixture executes, and each test will start from this snapshot.
- Lines 15–19 are our first test. We are using the builtin
accountsfixture as well as the
tokenfixture we declared earlier. In this test we transfer some tokens, and make an assertion about the balance of the sender.
- Lines 22–26 are our second test. Very similar to the previous test, except this time we make an assertion about the receiver’s balance.
- Lines 29–31 are our third and final test. In this test we use the
brownie.revertscontext manager to confirm that a transaction reverts when we attempt to transfer too many tokens.
Save the tests as
tests/test_first.py inside the token project. Then, run them with the following command:
brownie test tests/test_first.py
You should receive an output similar to this:
Each green dot represents one of our tests. Everything passed! 🎉
In “Part Four: Running Your Tests”, we learn the ins-and-outs of running our test suite, understanding the output, and debugging failed tests.
You can also follow the Brownie Twitter account, read my other Medium articles, and join us on Gitter.