Testing your StarkNet contracts with Protostar

Protostar is a great and promising dev tool for StarkNet, that we at OnlyDust are using more and more in our projects. By the way, for a comparison between Protostar and Nile, get a look at this article.

One very cool thing with Protostar is that it allows to write tests in Cairo. And besides that, at the time of writing this, Protostar execute tests a lot faster than other testing solutions.

In this article, I will give some tips to write unit tests with Protostar efficiently, and some proposal to design complicated tests and high-level assertions so everything remains clean and maintainable.

Don’t deploy

If you are used to write tests with Nile, your first reflex is probably to deploy the contract(s) you want to test, and then make some calls and invocations and make assertions on the result of those calls.

Well, writing unit tests with Protostar is a bit different. As your tests are written in Cairo, you can directly call functions of contracts without deploying them, and this is exactly what we are going to do (unless you are writing an integration test of course).

Don’t do this:

local contract_address : felt
%{ ids.contract_address = deploy_contract("./contracts/my_contract.cairo", []).contract_address %}

let (res) = MyContract.get_balance(contract_address)
assert res = 100

Do this:

from contracts.my_contract import get_balance

let (res) = get_balance()
assert res = 100

Not only this will make execution faster, this will also make your test code simpler and easier to maintain. For example, changing the signature of a function will break you test at compile time, which is obviously better than having to run the test.

If you are testing a contract that calls other contracts, Protostar has a nice mock_call cheatcode to, well, mock the call. Of course, you can also decide to deploy the called contract with deploy_contract if you need more advanced mocking or if you want to actually test the integration between both contracts.

Yoda style

As you probably know, the assert statement in Cairo can assert that the value at a given memory location is equal to something… but it can also set the value if the given memory location is still unset.

When you’re writing tests, you want the test to fail in the second case.

To do so, a simple tip is to write yoda-style assertions, ie. don’t write this:

assert foo[0] = 10

Write this instead:

assert 10 = foo[0]

Run a single test

A simple tip can save you a lot of time. If you like to execute your tests frequently while you are developing –eg. TDD– you won’t want it to take more than a few seconds.

The most efficient way to do so is to execute only the test you are interested in. You can do this easily: protostar test tests/test_foo.cairo::test_example.

Designing tests

Sometimes, a test can be long and its code complicated.

Just like the rest of your codebase, it very important to keep the test codebase clean and maintainable. If you don’t, you’ll end up writing less tests. The existing ones will soon become deprecated, and you’ll either spend a lot of time updating them or worse, you’ll delete them.

From our experience, it is a good idea to put test-specific functions into a namespace test_internal . Of course you can name it differently, but the idea is to put those function inside a specific namespace that doesn’t mess up with the rest of the code.

Another good idea is to create high-level assertions in a namespace assert_that. This will let you write expressive assertions with custom error messages.

Example:

namespace assert_that:
    func stage_is(expected_stage : felt):
        alloc_locals
        let (local stage) = tournament.stage()
        with_attr error_message("Expected stage to be {expected_stage}, got {stage}"):
            assert stage = expected_stage
        end
        return ()
    end
end

This makes the test very easy to read:

@external
func test_open_registrations{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}():
    alloc_locals
    let (local context : TestContext) = test_internal.prepare(2, 2)

    %{ start_prank(ids.context.signers.admin) %}
    tournament.open_registrations()
    %{ stop_prank() %}

    assert_that.stage_is(tournament.STAGE_REGISTRATIONS_OPEN)
end

If you want to see a real-world test which follows those design patterns, get a look at this file in StarKonquest repository.

Conclusion

We find that writing tests in Cairo–thanks to Protostar–is very efficient, both in speed of development and execution.

I hope these few tips will save you some time!

Subscribe to Only Dust
Receive the latest updates directly to your inbox.
Verification
This entry has been permanently stored onchain and signed by its creator.