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 --testAssert 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-diffSee 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 fullPrint 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