Cairo lang ships with support for writing tests in Python. I think it’s in many ways superior to the other ways of testing Cairo code. In this post, I share some testing tips and techniques I hope others find useful as well.
Since the Cairo lang modules are in Python we can use pytest to write out tests. I absolutely love pytest - it's, by far, the best testing framework I came across in any language. If you’re new to it, spend some time reading through the documentation, it will definitely pay off.
In the mean time, get ready to by pytest-pilled:
pytest ships with pytest.approx, a function to tests for numerical precision. If you’re building a defi protocol, It's handy when dealing with all the wei rounding discrepancies.
Another helpful built in is the pytest.raises context manager which helps you to assert that a transaction failed. It even supports a match
argument to evaluate the message conforms to an expected string. See an example of this in action in the repo.
You can run pytest with the --pdb
flag. It will start a Python REPL when encountering an error. Unfortunately, it only works in context of Python, not the Cairo VM, but it’s still very helpful for debugging in certain cases. If you have ipdb
installed, you can add --pdbcls=IPython.terminal.debugger:Pdb
to use a much more convenient ipython
REPL.
To run only a single test case in a file, pass its name separated by double colons on the command line:
pytest test/token/test_erc20.py::test_transfer
This will run just the test_transfer
test from the test_erc20.py
file.
An extension of the above is the -k
command line argument, which allows you to specify a string. pytest will then check this string against all the names of test functions and run only those that match. For example:
pytest test/token/test_erc20.py -k transfer
This would execute test_transfer
, test_transfer_from
and test_transfer_failures
.
There’s more advance stuff you could do with -k
, have a look at the docs.
There's a ton of command line arguments that alter the behaviour of pytest. I pretty much always use -sv
(-s
is to see print
outputs and -v
to increase verbosity of the test suite run). Another useful one is --lf
(short for --last-failed
) which instructs pytest to execute only those tests that failed in the previous test run.
You can put the commonly used cmdline args into a configuration file. This is the default pytest.ini
I use for every new project:
[pytest]
addopts = -s
--disable-warnings
--pdbcls=IPython.terminal.debugger:Pdb
Now for more tips geared towards testing Cairo contracts.
A lot of the test I see use a Signer class and send_transaction
to emulate an account contract. Unless you're actually working on a wallet, this is completely unnecessary.
A better way is to use the caller_address
argument of execute to simulate a transaction being sent from an address of your choice. Check this example in the repo. This technique will also speed up your tests, because you don't have to compile and deploy an account contract anymore.
The compile_starknet_files function has two helpful flags you should set to true in a testing environment: debug_info
and disable_hint_validation
. It's also a good idea to apply the @functools.cache
decorator on the compile function to speed up your test suite. See the compile_contract
function in the repo.
Thanks to compiling contracts with disabled hint validation, you can do pretty much anything you want inside a Cairo hint. It's a full blown Python environment. The most obvious thing is to print
values - just remember that local variables live under the ids
namespace inside a hint:
%{ print("value of let foo is", ids.foo") %}
If you’re running the test suite with -s
as mentioned above, you’ll see the output of print from hints in the console.
There’s two ways of how I deal with Cairo sturct
s in Python.
Let’s say we have this struct declared in a Cairo contract:
struct Person {
age: felt,
height: felt,
countries_visited: felt
}
I either create an equivalent namedtuple
in Python:
from collections import namedtuple
Person = namedtuple("Person", "age height countries_visited")
adventurer = Person(43, 77, 112)
Or I use the compiled contract to do the same:
contract = compile_contract("person.cairo")
adventurer = contract.Person(43, 77, 112)
Whichever way you use, they are easily comparable in Python. A single assert
on the whole struct will do. As always, there’s a full example in the repo.
In case you need to manipulate the block number or timestamp, you can use this block_info
fixture. Just pass it as an argument to your test and off you go.
A call to execute
returns an instance of StarknetCallInfo class. Use it to get the number of steps and builtins the transaction consumed:
tx = await contract.function().execute()
print(tx.call_info.execution_resources)
# ExecutionResources(n_steps=40, builtin_instance_counter={'range_check_builtin': 4, 'bitwise_builtin': 1}, n_memory_holes=2)
You can plug in the fee weights and build a simple transaction cost reporting tool with your test suite (note this does not take into account storage costs).
I’m always looking for cool ways how to use pytest and how to test Cairo contracts. If you have anything to share, I’ll be happy to hear from you.