Testing Cairo with Python

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.

pytest

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.approx

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.

pytest.raises

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.

Interactive prompt on failure

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.

Running a single test case

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.

Running only matching tests

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.

Command line arguments

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.

pytest.ini

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

Cairo specific tips and utils

Now for more tips geared towards testing Cairo contracts.

Using caller_address

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.

Compile contracts for testing

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.

Take advantage of hints

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.

Working with Cairo structs in Python

There’s two ways of how I deal with Cairo sturcts 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.

Mock block number and block timestamp

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.

Print transaction execution resources

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.

Subscribe to cairopractice
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.