Docs
Testing

Cookbook

Practical examples and code snippets for common testing scenarios in Tolk smart contracts

This page is a collection of practical testing patterns. For the full API surface, use the built-in matchers and helpers reference.

For complete real-world suites, use acton-contracts as a reference: it contains larger Jetton, NFT, wallet, multisig, DNS, elector, and config contract tests built with these patterns.

Start from a shared setup

Keep deployment and common actors in one helper. Larger projects usually put this in tests/test-utils.tolk and import it from scenario files.

fun setupCounter(): (Counter, Treasury, Treasury) {
    val deployer = testing.treasury("deployer");
    val outsider = testing.treasury("outsider");

    val counter = Counter.fromStorage({
        id: 0,
        owner: deployer.address,
        counter: 0,
    });

    val deploy = counter.deploy(deployer.address, { value: ton("1") });
    expect(deploy).toHaveSuccessfulDeploy({ to: counter.address });

    return (counter, deployer, outsider);
}

Use the generated stub when starting a new test file:

acton wrapper Counter --test

Assert the initial state

After deployment, assert the storage and get methods that define the contract's invariants. This catches bad initial state before message-flow tests get noisy.

get fun `test deploy exposes initial owner`() {
    val (counter, deployer, _) = setupCounter();

    expect(counter.owner()).toEqual(deployer.address);
    expect(counter.currentCounter()).toEqual(0);
}

Test an internal happy path

Prefer generated wrapper methods for normal messages. Name important traces so gas reports, saved traces, and the Test UI stay readable.

get fun `test owner can increase counter`() {
    val (counter, deployer, _) = setupCounter();

    val res = counter.sendIncreaseCounter(deployer.address, 123);
    res.giveName("increase-counter");

    expect(res).toHaveSuccessfulTx<IncreaseCounter>({
        from: deployer.address,
        to: counter.address,
    });
    expect(res).toHaveAllSuccessfulTxs();
    expect(counter.currentCounter()).toEqual(123);
}

Search for a transaction and inspect its body

Use findTransaction<T>() when the test needs values from the transaction instead of only asserting that it exists.

val response = res.findTransaction<ResponseWalletAddress>({
    from: minter.address,
    to: owner.address,
});

expect(response).toBeNotNull();

val body = response!.loadBody<ResponseWalletAddress>();
expect(body.jettonWalletAddress).toEqual(wallet.address);

Use toHaveTx<T>() when the message type matters but you do not need the body:

expect(res).toHaveTx<TransferNotificationForRecipient>({
    from: recipientWallet.address,
    to: recipient.address,
    value: forwardAmount,
});

Test a rejected internal message

For negative paths, assert both the failure and the unchanged state. If the contract must not emit a follow-up message, assert that too.

get fun `test non-owner cannot change counter`() {
    val (counter, _, outsider) = setupCounter();
    val before = counter.currentCounter();

    val res = counter.sendIncreaseCounter(outsider.address, 10);

    expect(res).toHaveFailedTx<IncreaseCounter>({
        from: outsider.address,
        to: counter.address,
        exitCode: Errors.NotOwner,
    });
    expect(res).toNotHaveTx({
        from: counter.address,
        to: outsider.address,
    });
    expect(counter.currentCounter()).toEqual(before);
}

Send a malformed or unknown body

Wrappers also generate sendAny(...), which is useful for payload validation and unknown-opcode tests.

get fun `test unknown message is rejected`() {
    val (counter, deployer, _) = setupCounter();
    val body = beginCell().storeUint(0x999, 32).endCell();

    val res = counter.sendAny(deployer.address, body);

    expect(res).toHaveFailedTx({
        from: deployer.address,
        to: counter.address,
        exitCode: Errors.InvalidMessage,
    });
}

Use net.send(...) directly when the wrapper would hide the malformed shape:

val msg = createMessage({
    bounce: true,
    value: ton("0.1"),
    dest: wallet.address,
    body: malformedBody,
});

val res = net.send(owner.address, msg);
expect(res).toHaveTx<AskToTransfer>({
    from: owner.address,
    to: wallet.address,
    success: false,
    aborted: true,
});

Test bounced messages

When the contract has special bounce logic, build the bounced message explicitly and assert it with toHaveBouncedTx().

val bounced = createMessage({
    bounce: false,
    value: ton("0.1"),
    dest: counter.address,
    body: IncreaseCounter { increaseBy: 10 },
}).bounced();

val res = net.send(deployer.address, bounced);
expect(res).toHaveBouncedTx({ to: counter.address });

Assert external-out messages

Use toEmitExternalMessage<T>() for a simple existence check, and findExternalOutMessage<T>() when you need to inspect the body.

expect(res).toEmitExternalMessage<TransferEvent>();

val event = res.findExternalOutMessage<TransferEvent>({
    from: counter.address,
});

expect(event).toBeNotNull();
expect(event!.loadBody()).toEqual(TransferEvent { value: 123 });

Test accepted external-in messages

net.sendExternal(...) and generated sendExternal* wrapper methods return ExternalSendResult. Assert acceptance before unwrapping the trace.

get fun `test signed external transfer`() {
    val (wallet, keyPair) = setupWallet();
    val body = createExternalSignedBody(keyPair, 0);

    val result = wallet.sendExternalSigned(body);

    expect(result).toBeAccepted();
    result.giveName("wallet-external-transfer");

    val txs = result.unwrap();
    expect(txs).toHaveSuccessfulTx<InternalTransfer>({
        from: wallet.address,
        to: wallet.address,
    });
}

The same helpers work on the accepted trace:

val first = result.at(0);
expect(first.getUsedGas()).toBeGreater(0);

val trace = result.waitForTrace(true, 1, 1);
expect(trace).toBeNotNull();

Test rejected external-in messages

For an external-in rejection, assert the external status directly. Do not unwrap it just to check for null.

get fun `test bad signature is rejected`() {
    val (wallet, badKeyPair) = setupWalletWithWrongKey();
    val body = createExternalSignedBody(badKeyPair, 0);

    val result = wallet.sendExternalSigned(body);

    expect(result).toBeNotAccepted();
    expect(result).toHaveExternalVmExitCode(Errors.BadSignature);
    expect(result.isAccepted()).toBeFalse();
    expect(result.waitForFirstTransaction(true, 1, 1)).toBeNull();
}

Rejected external-in matcher failures include source location and emulator diagnostics. Run with --backtrace full to also see the contract-side onExternalMessage backtrace when source maps are available.

Check balances and fees

Balance tests are usually strongest when they include the transaction fee that was charged to the receiving account.

val before = testing.getAccountBalance(receiver.address);

val res = wallet.sendTransfer(owner.address, receiver.address, value);
expect(res).toHaveTx({ from: wallet.address, to: receiver.address });

val fee = res.at(1).tx.load().totalFees.grams;
val after = testing.getAccountBalance(receiver.address);

expect(after).toEqual(before + value - fee);

Add a local gas assertion

Use toConsumeLessThan(...) for a tight local guard on one transaction.

val res = counter.sendIncreaseCounter(deployer.address, 100);

expect(res).toHaveSuccessfulTx<IncreaseCounter>({
    from: deployer.address,
    to: counter.address,
});
expect(res.at(0)).toConsumeLessThan(1500);

For project-level gas drift, create and compare gas snapshots:

acton test --snapshot gas-baseline.json
acton test --baseline-snapshot gas-baseline.json --fail-on-diff

See gas profiling and snapshots for baseline reports and CI setup.

Inspect out actions

Use out-action helpers when the interesting behavior is in C5 actions rather than the delivered child transactions.

val tx = res.at(0);
val actions = tx.allOutActions();

expect(actions).toBeSendMessageAt<TransferNotification>(0);

val body = actions.getSendMessageBodyAt<TransferNotification>(0);
expect(body).toBeNotNull();

Remember that action indexing is reversed: index 0 is the last produced action. See out-action helpers for details.

Control time

Use testing.setNow(...) for expiration windows, replay protection, and time-dependent get methods.

testing.setNow(1700000000);

val expiresAt = testing.getNow() + 3600;
val res = contract.sendNewOrder(owner.address, order, expiresAt);

expect(res).toHaveSuccessfulTx<NewOrderRequest>({
    from: owner.address,
    to: contract.address,
});

Control config

Use config overrides for tests that depend on fees, prices, or global chain parameters.

import "@acton/emulation/config"

var config = testing.getConfig();
var prices = config.getMsgForwardPrices(BASECHAIN);

prices.bitPrice *= 10;
prices.cellPrice *= 10;
config.setMsgForwardPrices(prices, BASECHAIN);

expect(testing.setConfig(config)).toEqual(true);

Work with deterministic addresses

Use treasuries for accounts that send messages, and named random addresses for passive accounts. Names show up in transaction trees and matcher failures.

val owner = testing.treasury("owner");
val receiver = testing.treasury("receiver");
val passiveAddress = randomAddress("not-deployed-recipient");

testing.topUp(passiveAddress, ton("0.5"));

Read account state

Use testing.isDeployed(...) before get methods that should tolerate lazy deployment, and testing.getAccountState(...) when you need balance or storage metadata.

fun JettonWallet.getJettonBalance(self): coins {
    if (!testing.isDeployed(self.address)) {
        return 0;
    }

    val data = net.runGetMethod<JettonWalletDataReply>(
        self.address,
        "get_wallet_data",
    );
    return data.jettonBalance;
}
val state = testing.getAccountState(contract.address);
expect(state).toBeNotNull();

val balance = state!.storage.balance.grams;
expect(balance).toBeGreater(0);

Save and restore world state

World-state snapshots are useful when setup is expensive and several tests need the same deployed base state.

val saved = testing.saveSnapshot("build/test-state/after-deploy.json");
expect(saved).toBeTrue();

// Later in another test:
val loaded = testing.loadSnapshot("build/test-state/after-deploy.json");
expect(loaded).toBeTrue();

See world state snapshots for file layout and caveats.

Split a long chain into steps

Use a cursor when a large transaction cascade should be inspected in phases.

val cursor = testing.createTraceIterationCursor(sender.address, msg);

val firstHop = cursor.executeN(1);
expect(firstHop).toHaveSuccessfulTx({ to: root.address });

val rest = cursor.executeAllRemaining();
expect(rest).toHaveAllSuccessfulTxs();

See step-by-step execution for executeTill(...), batching, and trace export behavior.

Write staged end-to-end tests

For large flows, split the scenario into named stage helpers. This keeps each assertion close to the state transition it validates and lets you reuse early stages as smaller benchmark tests.

fun deployStage(): FlowContext {
    val ctx = deployFixture();
    expect(ctx.minter.getTotalSupply()).toEqual(0);
    return ctx;
}

fun mintStage(ctx: FlowContext) {
    val res = ctx.minter.sendMint(ctx.admin.address, ctx.user.address, amount);
    expect(res).toHaveSuccessfulDeploy({ to: ctx.userWallet.address });
    expect(ctx.userWallet.getJettonBalance()).toEqual(amount);
}

get fun `test full flow`() {
    val ctx = deployStage();
    mintStage(ctx);
    transferStage(ctx);
    burnStage(ctx);
}

Use attributes for test intent

Use dotted @test.* attributes for expected failures, gas limits, skipped work, and fuzzing.

@test.fail_with(Errors.NotOwner)
get fun `test direct helper throws not owner`() {
    throw Errors.NotOwner;
}

@test.gas_limit(5000)
get fun `test transfer stays within gas budget`() {
    runTransferScenario();
}
import "@acton/testing/fuzz"

@test.fuzz({ runs: 64, seed: 42 })
get fun `test amount is bounded`(rawAmount: int) {
    val amount = fuzz.bound(rawAmount, 1, 1000);
    expect(amount).toBeGreater(0);
}

See test attributes and fuzz testing for the complete syntax.

Register libraries in setup

If a transaction tree reports a missing library, register the code before the message that needs it.

fun setupWithLibrary(): ContractWithLib {
    testing.registerLibrary(libraryCode);
    return deployContractThatDependsOnLibrary();
}

Debug failing tests

Start with the failing test only, then add body decoding and full backtraces:

acton test --filter "non-owner cannot change counter"
acton test --filter "non-owner cannot change counter" --show-bodies
acton test --filter "non-owner cannot change counter" --backtrace full

Print a trace when developing a test:

val res = counter.sendIncreaseCounter(deployer.address, 123);
println(res);

The printed tree is the same format used by matcher failures. See reading transaction chains for the format and Test UI for interactive trace inspection.

Last updated on

On this page