Take your Solidity testing to the next level with state building!
With the popular new framework Foundry seeing increased adoption, developing proper testing patterns for our Solidity tests has become more important. If we have one smart contract that is particularly complex, we may not want a single contract to include all the tests for the base contract. A good example that comes to mind is MakerDAO’s vat.sol
, the central accounting system which handles functionality ranging from fungibility to settlement.
Fortunately, we can build state with our contracts to help streamline this process. This was a bit more straightforward to do in the past with Hardhat since we could use nested describe()
statements in our code but the community is adapting to test in Solidity. We’ll begin by working through an example I’ve stolen from devtooligan (who you should follow if you haven’t already!) to get us started. First we’ll demonstrate how to build state with Hardhat tests and then show how we can achieve that same functionality with Foundry.
The old approach
Whenever we write tests in Hardhat, we were able to use nested describe()
blocks with beforeEach(async () => {…})
to handle logic needed prior to the subsequent tests (this is equivalent to Foundry’s setUp()
function). Let us say (for simplicity) that we have a contract that mirrors the functionality of a website: users can first register and then login. State building for this example is quite straight-forward. We have our ZeroState
where the user is neither registered nor logged in. RegisteredState
where the user is now registered but not logged in. Lastly, LoggedInState
where we’re both registered and logged in and can perform whatever actions we want to once inside the platform, etc. Writing our tests in Hardhat would look like this:
describe('Zero State', async function () {
beforeEach(async () => {...})
it('cannot vote if not logged in', async () => {...})
it('cannot login if not registered', async () => {...})
it('can register', async () => {...})
describe('Registered State', async function () {
beforeEach(async () => {})
it('cannot register if already registered', async () => {...})
it('can login', async () => {...})
describe('Logged In State', async function () {
beforeEach(async () => {})
it('cannot vote if already voted', async () => {...})
it('can vote', async () => {...})
}
This is fine and all, but if we can achieve this same functionality within Foundry, why shouldn’t we?
A Rudimentary Example
Within Foundry, we can mirror this functionality by creating abstract contracts for each of our states. In this case: ZeroState
, RegisteredState
, and LoggedInState
. We can then have these inherit from each other creating the same hierarchical pattern as in the previous example through calling super.setUp()
in the child’s setUp()
function. This is equivalent to beforeEach
as previously stated. At Yield, we like to test all the negative cases first and then all the positive ones so that convention will be maintained in our Foundry example.
We can now begin rewriting our example from Hardhat in Foundry like so:
import "../Website.sol";
abstract contract ZeroState {
Website public website;
function setUp() public {
website = new Website();
}
}
abstract contract RegsiteredState is ZeroState {
// any variables specific to this state go here
...
function setUp() public {
super.setUp(); // ZeroState functionality
website.register(...);
...
}
}
abstract contract LoggedInState is RegisteredState {
...
function setUp() public {
super.setUp(); // RegisteredState functionality
website.logIn(...);
....
}
}
contract ZeroStateTest is ZeroState public {
function testCannotVoteIfNotLoggedIn() public {...}
function testCannotLoginIfNotRegistered() public {...}
function testRegister() public {...}
}
contract RegisteredTest is RegisteredState {
function testCannotRegisterIfAlreadyRegistered() public {...}
function testLogIn() public {...}
}
contract LoggedInTest is LoggedInState {
function testCannotVoteIfAlreadyVoted() public {...}
function testVote() public {...}
}
Perhaps a bit verbose, but very intuitive! Hopefully it’s now clear how we can layer our tests between different levels of state. To dive into this a little more, see this example by Paul Razvan Berg:
For circumstances like this where there is little overhead with no beforeEach(),
we can omit creating the abstract contracts and put our setUp()
functions directly into our contracts and have them inherit each other. Another thing to mention is that if our contract has a sufficiently large number of states, we could consider moving the abstract contracts to a separate file to reduce clutter.
A note on more complex codebases
For even more complex codebases, we can break this down even further to increase modularity. For instance, we could have all our base logic in a file called TestCore
such as our state variables, events, errors, importing forge-std and so forth. Next, we could have TestCore
inherited by the various components of our application. If it has say oracles, pools, and vaults, we could have ZeroStateOracles
, ZeroStatePools
, and ZeroStateVaults
respectively and build our state for each of those separate pieces.
Concluding remarks
In summation, we are able to create various patterns for the state of our smart contract tests that not only are able to mimic our current understanding of how to do them in JavaScript/TypeScript with Hardhat, but also greatly increase the modularity and readability of our testing code.
That is all for today! Thank you for reading! If you have any suggestions or feedback, please feel free to let me know either in the comments below or on Twitter.