Testing your contract
Testing software is important and smart contracts are no different. However, properly testing smart contracts is absolutely crucial as it is impossible to update them after they have been deployed to the chain. The mantra of the lazy developer, "we will fix it in post", definitely does not fly with smart contracts.
Clarinet features a testing framework that allows developers to automate tests. These tests are written in code and describe the intended functionality of the smart contract. In this case, that code is written in TypeScript. Covering TypeScript itself is out of scope for the book but most web developers will be familiar with it. If you have done some JavaScript then it should also be easy enough to pick up.
Unit tests
Clarinet will generate a test file for each contract you instantiate using the
clarinet contract new
command. In the folder tests
you will find one for our
counter contract called counter.test.ts
. Depending on the version of Clarinet
you are using, it should have added some imports and template code to the test
file. You may remove the template code but make sure you leave the import
statements intact.
We're also going to be using the accounts
constant, so make sure you leave the const accounts = simnet.getAccounts();
in place as well.
Tests are defined using the test()
or it()
function. They have a name, which is
used as a description, and a callback function that is executed on test. Before
each test commences, Clarinet will instantiate a fresh local chain and then run
the function. The function should run one or more contract calls against the
contract to test it. The testing suite makes writing these calls easy but still
uses contract-call?
Behind the scenes.
In order to write successful tests, we should first think about the intended behaviour of the contract. That does not only include success states but also failure states. Remember, behaviour is observed from the outside. We care about which inputs produce what outputs. How it is implemented is a detail. Let us describe all of them in bullet point format.
get-count
returnsu0
for principals that have never called intocount-up
.get-count
returns the number of times a specific principal called intocount-up
.count-up
increments the counter for thetx-sender
as observed byget-count
and returns(ok true)
.
Testing get-count
Tests should be kept as simple as possible. You want to test exactly one aspect of a function call. The reason being that if a bug is later introduced in the contract, only the tests that specifically target that section of code will fail. Complex tests that leverage a lot of functionality can fail because of many different reasons and may make identifying the actual bug a lot more difficult.
We will therefore start by writing a test for the default behaviour of
get-count
; namely, it returning u0
for principals that have never called
count-up
before.
test("get-count returns u0 for principals that never called count-up before", () => {
// Get the deployer account.
const deployer = accounts.get("deployer")!;
// Call the get-count read-only function.
// The first parameter is the contract name, the second the function name, and the
// third the function arguments as an array. The final parameter is the tx-sender.
const incrementResponse = simnet.callReadOnlyFn(
"counter",
"get-count",
[Cl.standardPrincipal(deployer)],
deployer
);
// Assert that the returned result is a uint with a value of 0 (u0).
expect(incrementResponse.result).toBeUint(0);
});
Make sure you have everything installed by running npm install
.
We are now ready to execute the test using npm test
. If everything went
well, our test should pass with a "passed" status.
✓ tests/counter.test.ts (1)
✓ get-count returns u0 for principals that never called count-up before
Testing count-up
The next step is to test count-up
. We will write a minimal test that first
calls count-up
once, and then check the count using get-count
. As mentioned,
Clarinet will refresh the chain state for every test so that the starting
conditions are always the same. We therefore know that the first count-up
call
will always result in the counter for that tx-sender
to be u1
.
test("count-up counts up for the tx-sender", () => {
// Get the deployer account.
const deployer = accounts.get("deployer")!;
// Call count-up for deployer.
const response = simnet.callPublicFn("counter", "count-up", [], deployer);
// Assert that the returned result is a boolean true.
expect(response.result).toBeOk(Cl.bool(true));
// Get the counter value.
const getCountResponse = simnet.callReadOnlyFn(
"counter",
"get-count",
[Cl.standardPrincipal(deployer)],
deployer
);
// Assert that the returned result is a u1.
expect(getCountResponse.result).toBeUint(1);
});
The contract call transactions are constructed using the simnet.callPublicFn()
.
The Clarinet TypeScript library provides a lot of
helper functions to mine blocks, construct transactions, and create function
arguments. Inspect the source or see the Clarinet JS SDK documentation for a full
overview.
Testing the multiplayer aspect
Although the two tests we have written cover all code paths in our smart
contract, it makes sense to add another test that explicitly tests the
multiplayer aspect of our contract.
Note we are using the test
function here, so make sure that is imported at the top of the file with the other imports.
We also need to import the Cl
package which allows us to work with Clarity values: import { Cl } from "@stacks/transactions";
test("counters are specific to the tx-sender", () => {
// Get some accounts.
const deployer = accounts.get("deployer")!;
const wallet1 = accounts.get("wallet_1")!;
const wallet2 = accounts.get("wallet_2")!;
// Wallet 1 calls count-up one time.
simnet.callPublicFn("counter", "count-up", [], wallet1);
// Wallet 2 calls count-up two times.
simnet.callPublicFn("counter", "count-up", [], wallet2);
simnet.callPublicFn("counter", "count-up", [], wallet2);
// Get and assert the counter value for deployer.
const deployerCount = simnet.callReadOnlyFn(
"counter",
"get-count",
[Cl.standardPrincipal(deployer)],
deployer
);
expect(deployerCount.result).toBeUint(0);
// Get and assert the counter value for wallet 1.
const wallet1Count = simnet.callReadOnlyFn(
"counter",
"get-count",
[Cl.standardPrincipal(wallet1)],
wallet1
);
expect(wallet1Count.result).toBeUint(1);
// Get and assert the counter value for wallet 2.
const wallet2Count = simnet.callReadOnlyFn(
"counter",
"get-count",
[Cl.standardPrincipal(wallet2)],
wallet2
);
expect(wallet2Count.result).toBeUint(2);
});
Let us again run our tests using npm test
. Clarinet will execute all
tests and show us the result. We should get three ok's. If, not then the output
will show you where it went wrong. Edit your code and try again.
✓ tests/counter.test.ts (3)
✓ get-count returns u0 for principals that never called count-up before
✓ count-up counts up for the tx-sender
✓ counters are specific to the tx-sender
That concludes our first project. You now know what the entire Clarity smart contract development flow looks like! The full source code of the counter project can be found here: https://github.com/clarity-lang/book/tree/main/projects/counter.