## An Optimistic Guide to Smart Contracts Testing Testing is an important activity when writing software. In regular software development, testing is paramount for making sure the application works as intended. Thus how important are tests for smart contracts development? In smart contract development, the code and its execution is immutable and the value per line of code can be huge. Thus it is imperative to check the functionality and security through **thorough reviews**. Even if automatic testing becomes secondary in importance, it still serves a function as important as it serves for regular software development. So why write automatic tests instead of testing manually? Testing manually can also work if done right and you'll do it at some point anyway. But imagine you only do this in development. You test your code for hours, you find some small bugs that you fix with no problem. Then after some time you decide to refactor something. Because execution costs, usually **smart contract code is limited in its modularity and a single codebase can be quite monolithic**. This brings the risk of a change in the code altering the functionality of other parts of the contract. Once you made changes, you might not be motivated to meticulously go through all the manual testing again, which would not be efficient at all. You might also be biased towards testing only the functionality you made the alteration for. Writing automatic tests brings comfort and clarity to what you test and it's an activity practiced by any serious project. Tests can vary on multiple dimensions. Most importantly, **they can vary on the breadth of scope that you want to test**. Here, a test can belong anywhere between two extremes: - **Unit tests** - consisting in **testing individual low-level parts of the system**. These can be quite simple and allow for a cleaner intuition of the different combinations to test. **Usually unit tests for smart contracts require focusing on one contract and mocking the other components it interacts with** (e.g. other contracts). - **End-to-end tests** - consisting in **replicating a complete flow of the application the users could would go through**. These tests are very useful but they can be hard to alter when the code has changed in the development phase. Also, if you want to test as many flows as possible, the process of doing it can be painstaking. - There is also a category around the middle of these called **integration tests**, involving testing the interaction between multiple components from the system. Thus the scope of a test can be on the scale between unit and end-to-end. **It is recommended to test on a varied range of tests covering multiple complexities of code**. While finding edge cases to check out and make sure they're covered by the logic of the code is an essential part of reviewing, tests should challenge you to come up with all kinds of cases/flows to test as well. When picking your strategy for writing tests, you have to consider how complex is your code or how the complexity can increase during development. The scope of your testing, from unit to e2e, affects the meticulousness with which you have to test in order to obtain good coverage of your code. You can think of it something like this formula (though not perfect): $${maximal \ meticulousnes} = {complexity} ^ {scope \ of \ testing}$$ This shouldn't discourage you to chase a broader scope, but it should tell you that in order to get good coverage, smaller scope tests should be in your arsenal. The larger scope tests are also harder to modify when altering some logic in your system, but they might also provide a better opportunity to find breaking changes in the complex interaction. You don't have to make writing tests a nightmare you have to go through everytime you add/change something. It requires you to pick and organize your tests intelligently. The process of writing tests is thus more of an art than a science (especially as your tests scope increases), that requires some practice. **When a test errors out, it can have two causes: either the test is wrong or the code you're testing is wrong, the latter being the reason for the effort put into writing tests.** Another test dimension is randomness. Usually you want to keep your tests to be deterministic and varied, in order to make debugging easier. However fuzzing, a technique involving testing using random variables/data, is an important tool when wanting to stress test your system. You can also test for example for metrics, such as performance (or cost of execution in the case of smart contracts), acceptance etc. but that's for another discussion. **It is usually favorable to keep your tests verbose. This can be useful when debugging, but it's also useful for others to check your tests, e.g. when someone wants to better understand the contract.** Now that we talked some general principles, we can get into more into implementation principles. It is usually not good to be too opinionated on implementation as variation and testing from multiple targets is beneficial when writing tests. However not all tests are created equal and sometimes you need to collaborate with others to write tests. Thus it can be beneficial to suggest some consistent practices. A good practice when learning something is to learn from others. That's why I recommend checking the work of others and understand what they do good. If you are familiar with a popular smart contract's code, why not check the testing done around it. Here are a few examples of repos with good testing: - [HiFi-finance](https://github.com/hifi-finance/hifi/tree/main/packages/amm/test) (related [twitter thread](https://twitter.com/PaulRBerg/status/1467253180484317186)) - [Uniswap](https://github.com/Uniswap/v3-core) There are various tools you can use to build tests (for example [dapptools](https://github.com/dapphub/dapptools), [echidna](https://github.com/crytic/echidna) etc.). You can also write tests in several programming languages. What I'll focus next is close to the Schelling point of testing stacks: - **Typescript** - superset of JavaScript adding static typing - [**Typechain**](https://github.com/dethcrypto/TypeChain) - providing typescript bindings for smart contracts - **Ethers.js** - library for interacting with the blockchain - **Hardhat** - local development environment - **Mocha** and **Chai** - testing frameworks **Developer experience** When writing tests, the energy you want to spend is on thinking of the cases you want to cover to achieve good coverage. However it happens extremely frequently to interrupt your flow for small things. Typescript with Typechain helps deal with a large set of troubles that might arrise when writing code that interacts with smart contracts. By having static typing and bidings for your smart contract function, you make your life easier in cases such as when you made a typo or you provided the wrong parameters to a function (by mistake or after changing the smart contract) or don't remember the name of a property of an object etc. It also assists you with code completion and provides a minimal documentation for the objects you interact with. Once you get used to it (which doesn't take long), you and anyone dealing with your code has a much better experience. **Convenience** Adding to the previous principle, code duplication can be annoying. You can use libraries/frameworks to abstract some of the complicated logic you'd have to write over and over. Here are 2 examples of libraries that extends Chai matchers: - [ethereum-waffle](https://ethereum-waffle.readthedocs.io/) - [chai-as-promised](https://www.chaijs.com/plugins/chai-as-promised/) You can get creative and write your own (simple!) abstractions (or Chai matchers). Let's say for example you use functions from the smart contract that change its state and you want to check the changes for every state. Maybe the state change involves lots of parameters from this struct: ```Solidity struct Round { Vote[] votes; uint256 winningChoice; mapping(uint256 => uint256) counts; bool tied; uint256 totalVoted; uint256 totalCommitted; mapping(uint256 => uint256) paidFees; mapping(uint256 => bool) hasPaid; mapping(address => mapping(uint256 => uint256)) contributions; uint256 feeRewards; uint256[] fundedChoices; uint256 nbVotes; } ``` Thus when you write the test you might have something like this: ```ts const round = await contract.round(x) expect(round.votes).to.equal(expectedResult) expect(round.winningChoice).to.equal(expectedResult) expect(round.tied).to.equal(expectedResult) expect(round.totalVoted).to.equal(expectedResult) expect(round.totalCommitted).to.equal(expectedResult) expect(round.counts).to.equal(expectedResult) ``` This is quite unpleasant to deal with, especially if you want to check those variables every time you interact with the round. What if you want to check the state of other variables as well? Horrifying... How about we have an utility function specific to round? ```ts // { // votes: expectedResult, // tied: expectedResult // etc... // } // function checkRound(roundID, expectedParams) { const round = await contract.round(x) for (const param of params) { expect(round[param]).to.equal(params[param]) } } ``` Here expectedParams can be an object with `key:value` of `propertyName:expectedValue`. If you create it well, you can have it check a any combination of parameters you want and also have it with static typing. You can also have it more verbose, more explicit on errors or make it as a Chai matcher. Now everytime you want to check how the state of a round changed you'd have something like this: ```ts checkRound(x, { votes: expectedResult, winningChoice: expectedResult, tied: expectedResult, totalVoted: expectedResult, totalCommitted: expectedResult, counts: expectedResult }) ``` Looks much better to me :) There are some steps that you have to do everytime you write tests (e.g. deploying contracts). You can get inspiration from how others do it and once you've set yourself a good standard you'll not have to worry about it in the future. For example, everytime I write tests I have to get some signers and everytime I do it like this: ```ts let [governor, alice, bob, mallory]: SignerWithAddress[] = []; context("Start testing", () => { before("Initialize wallets", async () => { [governor, alice, bob, mallory] = await ethers.getSigners() }) }) ``` For deploying contracts you can write your own function or, as I prefer, use typechain generated function, which also has typing for maximum satisfaction (I know, ugly case): ```ts const proofOfHumanity = await new ProofOfHumanity__factory(governor).deploy(...constructor params) ``` Try to use variables as much as possible. Define the variable you expect to receive from the test beforehand and make sure you don't put a variable there without understanding its value just to make the test run successfully :) When dealing with enums from the smart contracts declare your own in Typescript as well and use those instead of the uint. ```ts chooseMove(1) // vs chooseMove(Move.ROCK) ``` **Verbosity** As mentioned earlier, verbosity is good in tests. That's why Mocha and Chai are the most popular Javascript testing frameworks. These invite you to give context to what you're testing at each moment. Mocha allows to categorize your tests very nicely in a tree-like structure. Chai allows you to be verbose both through code through the chai properties (to, be, have, eventually, equal) and through the assertion error message. You can have a combination between them depending on your structure. For example, if you have longer functions in your tests, chai messages are good for debugging. Mocha verbosity can be great if you manage to structure your code in small blocks. ```ts it("Should do a lot of stuff", () => { ... expect(answer, 'Wrong answer').to.equal(42) ... }) // vs context("Doing a lot of stuff", () => { it("Should give correct answer", () => { expect(answer).to.equal(42); }) }) ``` You should plan the tests you write beforehand, sort of make a blueprint of what you plan to test and the cases you want to touch. ```ts context("Voting finished", () => { ... context("Requester funded appeal", () => { context("Challenger funds an appeal", () => { it("Paid LESS than required", () => { // call fundAppeal function with value<required // check relevant state changes: // - appeal not created // - contribution saved }) it("Paid EXACTLY required", () => { // call fundAppeal function with value=required // check relevant state changes: // - appeal created // - contribution saved }) it("Paid MORE than required", () => { // call fundAppeal function with value>required // check relevant state changes: // - appeal created // - contribution saved // - extra funds returned }) }) }) ... }) ``` ##### A flow of writing tests - Choose your tests scope (unit, integration, end-to-end) - Define default values for deploying, interaction etc. - Write you deploying scripts - Create the blueprints for what/how you want to check, be verbose (e.g. define Mocha functions, write comments) - Write the tests you planned in blueprints, making utility functions where it adds convenience - Run your tests - Alter between the last 3 steps until you're finished. Simple! **Don't disregard tests for very basic but important functions in your contract. It doesn't hurt to check correct initialization of variables and simple functionality such as function access (for governor etc.).**