An In-Depth Guide to Testing Ethereum Smart Contracts
Part Two: Core Concepts of Testing
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
Before we get into actually writing tests, it’s good to review some core testing concepts. We’re going to focus on unit testing initially — integration testing follows many of the same principles, but it is a more complex and nuanced subject that is better approached once we have a handle on unit tests.
We begin with a definition. What is a unit test?
“A unit test is a test that verifies a single behavior or component within your code.”
Properties of Unit Tests
There are many properties that set a good unit test apart from a bad one. Try to keep all of these in mind as you write your own tests.
- Simple: Each unit test should be evaluating a single behavior. When a test fails it should be immediately obvious why. It is better to write many short tests with a single assertion, than one long test with many assertions.
- Repeatable: A unit test should always produce the same result, as long as the code being tested has not changed. Tests should never be influenced by some uncontrollable parameter. This is important so that when a test fails, you can repeat it and observe the result to help you find the issue.
- Isolated: Tests should not depend upon or affect other tests You should be able to run your tests in any order, together or independently, and always receive the same outcome.
- Readable: Use long, descriptive variable names and don’t be afraid of adding comments to your tests. Once you have finished writing a test, it’s likely you’ll never look at that code again - until it fails. When that does happen, you should not have to spend time trying to understand what the test is doing.
- Fast: Unit tests should be written with speed in mind. Each time you add or change your code you should run your tests to confirm that everything is still working. Having a quick test suite ensure you get this feedback sooner.
We’ll explore each of these ideas in greater depth throughout this tutorial.
When writing tests, there is a commonly referenced design principle known as Arrange-Act-Assert (AAA):
- First, you arrange the initial conditions required
- Next, you act by calling the function to be tested
- Finally, you assert the result of the action
Following this pattern ensures that your tests are readable and not overly complex. If you find yourself performing many actions and assertions in a single test, or making an assertion mid-test and then following it with more actions — consider refactoring into multiple tests. Remember, we want our tests to be simple and easy to understand when they fail!
What to Test
Well… everything! Take the following example, a simple ERC20 transfer function written in Vyper:
Generally, we want to start by considering the possible paths through the function. In this case there are two outcomes:
- The token transfer is successful. In this case, testable behaviors include the sender and receiver balances being correctly adjusted, the
Transferevent firing and the function returning
- The transfer is not successful. In this case, we expect that the transaction reverts with an error message of
"Insufficient Funds"when the sender does not have a sufficient balance.
Going further we might also want to test edge cases, such as:
- A transfer of 0 tokens
- A transfer where the sender and receiver are the same address
In some cases we might also write tests to perform assertions about behaviors that should not occur, such as a change in the total supply.
So you can see, from this single function we can produce many unit tests!
In “Part Three: Writing Basic Tests”, we explore the basics of pytest and write our first tests!