Journey Through Cairo IX— Ultimate Guide To Testing Your Contracts With Protostar

Darlington Nnam
9 min readSep 28, 2022

--

Welcome to the ninth article, in our series, “Journey through Cairo”. In our last article we got to finally write and deploy our first Starknet contract. Today we’d be writing tests for our deployed contract! This article is more of a continuation to the eight article, so please go through it here before reading this.

Unit Testing

The term unit testing, is a widely-used term in software engineering, and more in smart contract development. So before we proceed, let’s take out few lines to first understand what unit testing really is.

Unit Testing is a type of software testing where individual units or components of a software are tested. Unit tests are carried out during the development phase of a software application, and ensures that all parts of a certain program works as expected.

Whilst they are commonly used in every field of software development, they are even way more important when writing smart contracts.

Now, it’s a known fact that when you write large chunks of codes, there’s always a very high chance that some existing functions might be buggy or not perform as expected, and most times, these bugs can exist in your contracts and they’d still compile without any issues.

Whilst i find most developers avoid writing tests, or try to write tests with little coverage, writing good tests provides a lot of advantages:

  1. Unit tests help to fix bugs early in the development cycle and save millions of dollars that would have probably been exploited.
  2. It helps developers understand the testing code base and enables them to make changes quickly.
  3. Good unit tests can serve as a project documentation.

Having finally shown you reasons why you shouldn’t joke with writing good tests, let’s now dive into how you can write tests for your Cairo contracts!

Protostar Tests

Thanks to the hard-work of the team at Protostar, writing unit tests becomes easier. Similar to how Foundry enables Solidity developers write unit tests in Solidity, Protostar enables Cairo developers write unit tests in Cairo!

Test Syntax

@externalfunc test_increase_balance{syscall_ptr: felt*, range_check_ptr, pedersen_ptr: HashBuiltin*}() {   let (result_before) = balance.read();   assert result_before = 0;   increase_balance(42);   let (result_after) = balance.read();   assert result_after = 42;   return ();}

Like i said above, with Protostar, we get to write our tests in pure Cairo. Above is an example of a test case in Protostar. From this piece of code, you can notice the following about writing unit tests:

  1. As you can see, all test cases are external functions and are prefixed with test_
  2. You’d also notice we do not pass arguments to our functions here, as we provide all test arguments we need manually.
  3. You can also notice that we have access to the assert keyword, which we can easily use for comparison.

NB: Using the assert keyword in Cairo, automatically assigns the right hand to the left hand, if the left hand variable is not already set, therefore it’s safe practice to ensure that our constant which we want to compare is always at the left hand side. To further explain this, assuming we have a constant:

const NUMBER = 30;

And we want to get the return value of a function and check if it equals our constant, we want to make sure our constant is on the LHS(left hand side), so in case the function returns an empty argument, we don’t have cairo trying to assign, rather than compare.

So we’d write our assert statement like this:

let (num) = get_number();assert NUMBER = num;

Setup Hooks

There are certain actions we want to carry out before a test case. Actions like deploying a contract and setting its address, setting up some important variables etc, that is needed by other test cases.

Similar to before hook that we use with mocha and chai, we can use setup hooks in protostar to set some variables before hand in a type of storage variable called context, and pass them from one function to another.

For example, we could use a setup hook to deploy our starknet contract from the last article, and store the contract address in context to be passed to other test cases like this:

@externalfunc __setup__{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}() {%{context.address = deploy_contract("./src/starknet.cairo",   [ids.NAME]).contract_address %}return ();}

We’d go deeper into this, when we are writing tests for our contract.

Common Cheat codes

Quoting the official protostar docs, “Most of the time, testing smart contracts with assertions only is not enough. Some test cases require manipulating the state of the blockchain, as well as checking for reverts and events. For that reason, Protostar provides a set of cheat codes.”

Its also necessary to note that these cheat codes can only be accessed through hints, and shouldn’t be explicitly written in you Cairo contract!

Whilst there are a lot of them which you can find here, in order to shorten the length of this article, we’d go over these four in today’s article:

  1. deploy_contract
  2. expect_revert
  3. expect_events
  4. start_prank

deploy_contract

This cheat code deploys a contract taking in the relative path to the contract and the constructor arguments (if any).

To use this cheat code, we pass in the relative path to our contract codes, and the constructor arguments like this:

%{ deploy_contract("./src/starknet.cairo", [322918500091226412576622]) %}

Due to the fact that the process of deploying a contract is usually slow, it’s advisable that you use this cheat code within a setup hook, so you only have to perform this action once.

The deploy_contract cheat code also gives you access to the contract address of the deployed contract which you can access and store in a context variable to be accessed from your test cases like this:

%{context.address = deploy_contract("./src/starknet.cairo", [ids.NAME]).contract_address %}

expect_reverts

This cheat code is used to check that a certain operation below it reverts with a specified error, and fails the test if it does not. In other words, you can use this test to confirm that your contract revert cases work as expected.

For example, if we go through the test for main.cairo (the default contract always created by protostar on initialization), we’d find the piece of code below, which tests that the function increase_balance will revert for a negative input.

%{ expect_revert("TRANSACTION_FAILED", "Amount must be positive") %}increase_balance(-42);

As you can notice, the expect_revert executes the function call directly below it, and checks that the error is of type “TRANSACTION_FAILED”, and matches “Amount must be positive”, and the test fails if it does not.

expect_events

This cheat code helps you check that the emitted events from your Starknet contract matches some expected events.

Unlike the expect_revert, you can use this cheat code anywhere within your function test case, as Protostar checks for emitted events after a test case is completed.

Using this test case looks like this:

%{ expect_events({"name": "stored_name", "data" : [ids.CALLER, ids.NAME]}) %}

start_prank

This cheat code is very important when writing unit tests. You can use this, to change the caller_address to any address of your choice while writing unit tests.

To use this is a little trickier than the previous ones, as with this you’d have to initialize a callable (like a state), that holds the new address, and then uninitialize it once done.

You can also initialize more than a single prank to carry out tests with different addresses. Below is an example of how you can use this cheat code:

%{ stop_prank = start_prank(0x00A596deDe49d268d6aD089B5aBdD089BE92D089B191e48) %}      // Your test logic goes here.%{ stop_prank() %}

We start a prank using the start_prank, and at the same time initialize a callable stop_prank. We can end the prank by calling stop_prank(), and any function call between when the start_prank and the stop_prank() will use the specified address as the caller_address.

Writing our first test

Woah..we’ve covered a lot already. It’s time to put all we’ve learnt into practice by writing a test for our Starknet contract from the previous article.

You could also check out the contract codes here.

We’d be dividing our tests into 5 segments, in a bid to cover all we’ve learnt thus far:

  1. Specifying necessary imports.
  2. Specifying some constants needed throughout our tests.
  3. Deploying our contract using setup hooks.
  4. Test for the store_name function.
  5. Test for the get_name function.

Specifying necessary imports

For this test, we’d be importing the HashBuiltin library function, and all the functions we want to run tests on in our Starknet contract (store_name and get_name functions).

%lang starknetfrom starkware.cairo.common.cairo_builtins import HashBuiltinfrom src.starknet import store_name, get_name

Specifying constants

For this test, we’d be needing two constants: our intended caller address which we’d use to start a prank, and the name (in felt), which we want to supply as an argument to the store_name function.

const CALLER = 0x00A596deDe49d268d6aD089B56CC76598af3E949183a8ed10aBdE924de191e48;const NAME = 322918500091226412576622;

Deploying our contract using setup hooks

Having covered what setup hooks are and why you need them for contract deployments, let’s dive into the codes:

@externalfunc __setup__{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}() {%{context.address = deploy_contract("./src/starknet.cairo", [ids.NAME]).contract_address %}return ();}

From the code above, we first specify we are using a setup hook by using the function name __setup__ . Next we deploy our contract using the deploy_contract cheat code, providing the path to our contract codes, and an argument NAME.

Notice how we use ids.NAME, rather than just using NAME…that’s how we access Cairo constants from within a hint.

Test for store_name function

@externalfunc test_store_name{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}() {  %{ stop_prank = start_prank(ids.CALLER) %}  store_name(NAME);  %{ expect_events({"name": "stored_name", "data" : [ids.CALLER, ids.NAME]}) %}  %{ stop_prank() %}  return ();}

Tests can help you understand how a function behaves and vice versa. From our function, you’d notice we get the caller_address, which we then use as a key to store our name argument.

In Protostar, by default the caller_address defaults to 0, but you can change this using the start_prank. So you can see from the code above that the first thing we do, is start a prank to change the caller address.

Next we call the store_name function, providing the constant NAME from earlier, as an argument.

Lastly, we check the emitted events in Starknet’s state to be sure that it matches our supplied arguments (CALLER and NAME), before finally stopping the prank.

Test for get_name function

@externalfunc test_get_name{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}() {   %{ stop_prank = start_prank(ids.CALLER) %}   store_name(NAME);    let (name) = get_name(CALLER);   assert NAME = name;   %{ stop_prank() %}   return ();}

This particular test is very simple. We repeat the process from earlier again, as we’d need to store a name, in order to get the name.

So we start a prank, store a name and then call the get_name function supplying the constant CALLER as an argument.

One thing i want you to take note of, is this line:

assert NAME = name;

As you can see, we obeyed the rule from earlier, putting our constant NAME, in the RHS, so Cairo doesn’t assign rather than compare.

Once we are done, our full code should look like this:

%lang starknetfrom starkware.cairo.common.cairo_builtins import HashBuiltinfrom src.starknet import store_name, get_nameconst CALLER = 0x00A596deDe49d268d6aD089B56CC76598af3E949183a8ed10aBdE924de191e48;const NAME = 322918500091226412576622;
@externalfunc __setup__{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}() { %{context.address = deploy_contract("./src/starknet.cairo", [ids.NAME]).contract_address %} return ();}@externalfunc test_store_name{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}() { %{ stop_prank = start_prank(ids.CALLER) %} store_name(NAME); %{ expect_events({"name": "stored_name", "data" : [ids.CALLER, ids.NAME]}) %} %{ stop_prank() %} return ();}@externalfunc test_get_name{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}() { %{ stop_prank = start_prank(ids.CALLER) %} store_name(NAME); let (name) = get_name(CALLER); assert NAME = name; %{ stop_prank() %} return ();}

Conclusion

We covered a whole lot today, diving deep into how you can write tests with Protostar.

Ensure you go through the remaining cheat codes here, as they can be very helpful while writing tests. You can also find an in-depth testing script from OnlyDust, that implements most of Protostar’s cheat codes here.

In our next article, we’d be diving deep into oracles with Empiric. Follow me on Medium and Twitter, so you don’t miss out when it drops!

As always, If you got value from this article, do well to share with others.

You could also connect with me on the following socials, especially on Twitter, where i share my little findings on Cairo!

Twitter: https://twitter.com/0xdarlington

LinkedIn: https://www.linkedin.com/in/nnamdarlington

Github: https://github.com/Darlington02

--

--