Acton
Tutorial

Writing your first integration test in Tolk

Learn how to write integration tests for Tolk smart contracts that test real message sending and contract interactions

In this article, we'll build integration tests for a simple Counter smart contract in Tolk. Integration tests differ from unit tests they test real message sending between contracts, not just isolated functions like in unit tests.

If you've written tests in TypeScript, you know that to write tests, you first need to write wrappers — TypeScript classes that implement various methods for deployment, sending messages, and executing getter methods. Without them, it's quite difficult to write tests.

When writing tests in Tolk, this problem doesn't go away completely, but the necessary wrappers are written as your tests grow in complexity, and writing them is very clear and simple.

In this guide, we will write a wrapper manually to understand how it works under the hood. In real projects, you can generate wrappers automatically using the wrapper command.

1. Our Test Subject: Counter Contract

Before we write tests, let's understand what we're testing. We'll work with a simple Counter contract that can be increased or reset:

Contract Logic

The contract handles two types of messages:

  • IncreaseCounter - increases the counter by a specified amount
  • ResetCounter - resets the counter to zero

It also provides a getter currentCounter() to read the current value.

counter.tolk
import "types"

type AllowedMessage = IncreaseCounter | ResetCounter

fun onInternalMessage(in: InMessage) {
    val msg = lazy AllowedMessage.fromSlice(in.body);

    match (msg) {
        IncreaseCounter => {
            var storage = lazy Storage.load();
            storage.counter += msg.increaseBy;
            storage.save();
        }
        ResetCounter => {
            var storage = lazy Storage.load();
            storage.counter = 0;
            storage.save();
        }
        else => {
            assert (in.body.isEmpty()) throw 0xFFFF;
        }
    }
}

fun onBouncedMessage(in: InMessageBounced) {}

get fun currentCounter(): int {
    val storage = lazy Storage.load();
    return storage.counter;
}

Data Types

The contract uses these data structures:

types.tolk
struct Storage {
    id: uint32
    counter: uint32
}

fun Storage.load(): Storage {
    return Storage.fromCell(contract.getData());
}

fun Storage.save(self) {
    contract.setData(self.toCell());
}

struct (0x7e8764ef) IncreaseCounter {
    increaseBy: uint32
}

struct (0x3a752f06) ResetCounter {}

2. Setting Up the Test File

First, as in the case with unit tests, let's create a file counter.test.tolk and declare a new test there:

get fun `test should deploy`() {

}

3. Testing Contract Deployment

First, we need to deploy our Counter contract in order to interact with it. But first let's create initial code for the contract.

import "@acton/build"

import "@contracts/types"

struct Counter {
    address: address
    init: ContractState
}

fun Counter.fromStorage(storage: Storage): Counter {
    val init = ContractState {
        code: build("Counter"),
        data: storage.toCell(),
    };
    val address = AutoDeployAddress { stateInit: init }.calculateAddress();
    return Counter { address, init };
}

We declared a structure that will describe our contract and a constructor that will create a new contract instance with the passed storage. The build function allows us to compile our contract to a cell with compiled code. If the contract has already been compiled in previous tests, it will return the code without recompilation, which greatly speeds up the tests.

We use Storage directly from types.tolk. If you add new fields in the future, the tests simply won't compile. This allows you to avoid a whole class of problems when TypeScript wrappers weren't updated accordingly.

Now let's write our first test in which we will deploy the contract.

import "@acton/emulation/testing"

get fun `test should deploy`() {
    val counter = Counter.fromStorage({
        id: 0,
        counter: 0,
    });

    val deployer = testing.treasury("deployer");
    val msg = createMessage({
        bounce: false,
        value: ton("1.0"),
        dest: {
            stateInit: counter.init,
        },
    });
    net.send(deployer.address, msg);
}

To create a message, we use the familiar createMessage function — the same one we use in contracts themselves.

Since we need to deploy a contract, we need a deployer account with some TON coins. We create one using testing.treasury("name") — this gives us a test account that's automatically funded with coins. testing is a global namespace for emulation-only helpers, while net contains functions for sending messages and calling getter methods.

When the message is created, we call net.send() to send it.

Sending a message via msg.send() as in smart contracts will not work as you might expect. In fact, it will create an out action, but it will never be processed.

4. Adding Test Assertions

So far we haven't checked anything, let's fix that.

The net.send() function returns a list of transactions that were generated by sending the message. The expect() function that we saw in unit tests has special matchers for working with the transaction list:

    // ...
    val res = net.send(deployer.address, msg);
    expect(res).toHaveSuccessfulDeploy({ to: counter.address });
}

One of them, toHaveSuccessfulDeploy, checks that there is a successful contract deployment in the transaction list with the passed parameters. In our case, we only care about the address of the deployed contract.

Now we have a full-fledged test that deploys our Counter contract! To run it and ensure the test passes, run the following command:

acton test counter.test.tolk

The complete test code looks like this:

import "@acton/emulation/testing"

get fun `test should deploy`() {
    val counter = Counter.fromStorage({
        id: 0,
        counter: 0,
    });

    val deployer = testing.treasury("deployer");
    val msg = createMessage({
        bounce: false,
        value: ton("1.0"),
        dest: {
            stateInit: counter.init,
        },
    });
    val res = net.send(deployer.address, msg);
    expect(res).toHaveSuccessfulDeploy({ to: counter.address });
}

For such a small test, it may seem quite large in terms of lines of code, but we will fix this when we write our second test further on.

5. Creating Reusable Test Setup

When you write tests, you want to check that in a certain state, your contracts behave as expected. Many tests may require the same initial setup, such as one or more deployed contracts. Writing deployment logic in each test makes tests difficult to read and maintain, so let's consider an idiomatic approach.

For our Counter tests, we need both counter and deployer contracts. Let's create a setupTest function to handle the deployment logic:

import "@acton/emulation/testing"

fun setupTest() {
    val counter = Counter.fromStorage({
        id: 0,
        counter: 0,
    });

    val deployer = testing.treasury("deployer");
    val msg = createMessage({
        bounce: false,
        value: ton("1.0"),
        dest: {
            stateInit: counter.init,
        },
    });
    val res = net.send(deployer.address, msg);
    expect(res).toHaveSuccessfulDeploy({ to: counter.address });

    return (counter, deployer);
}

Its body contains the same code as our test, except that we also return counter and deployer for further use.

Our test now simplifies to one line:

get fun `test should deploy`() {
    setupTest()
}

For those who have already written contracts in TypeScript, this approach may be new. In TypeScript tests, global variables at the describe level are usually used and re-initialized before each test via beforeEach. However, in Tolk tests, this approach won't work, and it's easy to make mistakes by not resetting variables properly, leading to incorrect testing. The Tolk approach avoids this issue by design.

Checkpoint: We now have a clean, reusable way to deploy our Counter contract for testing!

6. Testing Contract Logic

Now writing a test for the increase logic becomes trivial. First, we get our contracts:

get fun `test should increase counter`() {
    val (counter, deployer) = setupTest();
}

Now we need to create a message via createMessage and send it:

get fun `test should increase counter`() {
    val (counter, deployer) = setupTest();

    val msg = createMessage({
        bounce: false,
        value: ton("1.0"),
        dest: counter.address,
        body: IncreaseCounter { increaseBy: 100 },
    });
    val res = net.send(deployer.address, msg);
    expect(res).toHaveSuccessfulTx({ from: deployer.address, to: counter.address });
}

We use the IncreaseCounter type, which is imported from types.tolk. We don't need to declare it again, we just use it directly. Any future changes to this type will cause a compilation error if you forget to update its usage in the test.

Now that we have successfully deployed and sent a message to our contract, we need to check that the counter has actually increased.

7. Reading Contract State

To call a getter method on the desired contract, just call the net.runGetMethod function, passing the contract address and the method name there. Let's add a helper method to our Counter struct:

fun Counter.getCounter(self): int {
    return net.runGetMethod(self.address, "currentCounter");
}

Perfect! Now we can use this method in our test to get the current counter value.

Checkpoint: We can now read the contract's state! This is essential for verifying that our messages actually change the contract's behavior.

    // ...
    expect(res).toHaveSuccessfulTx({ from: deployer.address, to: counter.address });

    expect(counter.getCounter()).toEqual(0);
}

Let's run the test via:

acton test counter.test.tolk --filter "should increase counter"

And we will see that our test failed:

 TEST  /Users/dev/counter

 > counter.test.tolk (1 tests)
 should increase counter 21ms
    └─ Error: expect(actual).toEqual(expected)
        (
            100,
            0
        )
      └─ at counter.test.tolk:58:5

 1 failed in 1 file

Some tests failed.

The test failed because our expectation was wrong! Let's think through the logic:

  • The counter starts at 0 (as set in our setupTest())
  • We send an IncreaseCounter message with increaseBy: 100
  • The contract adds 100 to the counter: storage.counter += msg.increaseBy
  • Result: 0 + 100 = 100

8. Fixing Test Expectations

The test expected 0, but the correct result is 100. Let's fix the expectation by changing toEqual(0) to toEqual(100):

    // ...
    expect(counter.getCounter()).toEqual(100);
}

Checkpoint: Our test now passes! We've successfully created a test that validates the core functionality of our Counter contract.

Now we have a full-fledged test that deploys a contract, sends a message, and checks that this message changed the state of our contract as we expected. It looks like this in full:

get fun `test should increase counter`() {
    val (counter, deployer) = setupTest();

    val msg = createMessage({
        bounce: false,
        value: ton("1.0"),
        dest: counter.address,
        body: IncreaseCounter { increaseBy: 100 },
    });
    val res = net.send(deployer.address, msg);
    expect(res).toHaveSuccessfulTx({ from: deployer.address, to: counter.address });

    expect(counter.getCounter()).toEqual(100);
}

The steps described above make up 90% of the tests you create — you create a message, send it, and check that the state has changed as you expected. Unlike TypeScript tests, Tolk tests work with native message types, storage, and getter methods, eliminating a whole class of synchronization problems between Tolk and TypeScript code.

To make our testing even more convenient, let's add some helper methods to our Counter struct that will simplify sending messages.

9. Creating Helper Methods

As the amount of code you want to reuse grows, it's easy to create new methods that wrap the message sending logic:

fun Counter.sendIncrease(self, from: address, increaseBy: int) {
    val msg = createMessage({
        bounce: false,
        value: ton("0.1"),
        dest: self.address,
        body: IncreaseCounter { increaseBy },
    });
    return net.send(from, msg);
}

fun Counter.sendReset(self, from: address) {
    val msg = createMessage({
        bounce: false,
        value: ton("0.1"),
        dest: self.address,
        body: ResetCounter {},
    });
    return net.send(from, msg);
}

get fun `test should reset counter`() {
    val (counter, deployer) = setupTest();

    val res = counter.sendIncrease(deployer.address, 100);
    expect(res).toHaveSuccessfulTx({ from: deployer.address, to: counter.address });
    expect(counter.getCounter()).toEqual(100);

    val resetRes = counter.sendReset(deployer.address);
    expect(resetRes).toHaveSuccessfulTx({ from: deployer.address, to: counter.address });
    expect(counter.getCounter()).toEqual(0);
}

Summary

Now you know how to write basic integration tests in Tolk!

What we've covered:

  1. Setting up test files and basic structure
  2. Deploying contracts in tests
  3. Adding test assertions and verifications
  4. Creating reusable test setup functions
  5. Testing contract logic with message sending
  6. Reading contract state with getters
  7. Debugging failing tests and fixing expectations
  8. Building helper methods for cleaner test code

Last updated on

On this page