After spending significant time in the TON ecosystem, I noticed a recurring issue: the default @ton/sandbox tests often lean more toward integration tests rather than true unit tests. While there are benefits to testing the end-to-end flow of a message, it has some downsides.
- The tests lack isolation; for example, modifying the behavior of the jetton wallet contract may cause the jetton minter contract tests to fail unexpectedly.
- Tests that depend heavily on the blockchain configuration are fragile towards minor changes in configuration. Hence, the repository hosting the standard token contract is also facing failing tests.
- Setting a smart contract’s state typically involves sending messages to it, which increases dependency on other functionalities, reducing test isolation.
- The current testing setup makes it difficult to take a Test-Driven Development (TDD) approach, where functionality is incrementally built and tested in isolation.
This article introduces a solution: a testing suite configuration designed to isolate and independently verify single transactions, enabling a smoother TDD workflow for FunC smart contracts.
Solution
Our approach addresses these issues by ensuring tests are isolated, independent, and focused on verifying single transactions. This allows for a true TDD workflow, where each change can be validated incrementally.
This blog will walk you through setting up this test suite and will then demonstrate completing the contract functionality in a TDD fashion.
Setup
Create a new project using @ton/blueprint, I am naming the project as unit_tests_ton and the first contract name will be MessageCounter .
npm create ton@latest -- unit_tests_ton --type func-empty --contractName MessageCounter
Smart Contract
For demonstration, we take an example of the MessageCounter smart contract, a proxy that forwards messages while keeping track of the number of successful forwards.
The contract will accept the message with the following schema and in return will send a message to the given address to and its body will be the forward_payload.
forward_message#36e135d3 to:MsgAddress forward_payload:Either Cell ^Cell = InternalTransferMsg
Here’s the initial implementation of our MessageCounter smart contract. This contract serves as a message proxy while maintaining a counter of successful forwarded messages. We’ll build on this incrementally using TDD.
#include "imports/stdlib.fc"; const int op::forward_message = 0x36e135d3; const int error::unknown_op = 0xffff; const int error::malformed_forward_payload = 100; const int error::not_enough_ton = 101; const min_ton_for_storage = 100000; ;; storage scheme {- storage#_ count:uint256 = Storage; -} (int) load_data() inline { slice ds = get_data().begin_parse(); return ( ds~load_uint(256) ); } () save_data(int count) impure inline { set_data(begin_cell() .store_uint(count, 256) .end_cell()); } () forward_message(slice to_address, slice forward_payload) impure inline { } () recv_internal(int my_balance, int msg_value, cell in_msg_full, slice in_msg_body) impure { if (in_msg_body.slice_empty?()) { ;; ignore empty messages return (); } slice cs = in_msg_full.begin_parse(); int flags = cs~load_uint(4); ( int count ) = load_data(); if (flags & 1) { return (); } slice sender_address = cs~load_msg_addr(); cs~load_msg_addr(); ;; skip dst cs~load_coins(); ;; skip value cs~skip_bits(1); ;; skip extracurrency collection cs~load_coins(); ;; skip ihr_fee int fwd_fee = muldiv(cs~load_coins(), 3, 2); ;; we use message fwd_fee for estimation of forward_payload costs int op = in_msg_body~load_uint(32); slice to_address = in_msg_body~load_msg_addr(); if (op == op::forward_message) { throw_unless(error::malformed_forward_payload, slice_bits(in_msg_body) >= 1); forward_message(to_address, in_msg_body); count += 1; save_data(count); return (); } throw(error::unknown_op); }
Wrapper
The wrapper implementation diverges from the default by focusing solely on simulating contract behavior. This ensures that each test can target a specific contract state or message. We achieve this by,
- Composing the wrapper with a SmartContract object in addition to init and address.
- Directly sending the message to our contract with receiveMessage function and returning the transaction.
import { Address, beginCell, Cell, Contract, contractAddress, Message, toNano } from '@ton/core'; import { AccountStateActive } from '@ton/core/dist/types/AccountState'; import { Blockchain, createShardAccount, internal, SmartContract, SmartContractTransaction } from '@ton/sandbox'; export type MessageCounterConfig = { count: bigint }; export function messageCounterConfigToCell(config: MessageCounterConfig): Cell { return beginCell().storeUint(config.count, 256).endCell(); } function cellToMessageCounterConfig(cell: Cell): MessageCounterConfig { return { count: cell.beginParse().loadUintBig(256) } } export class MessageCounter implements Contract { constructor(readonly address: Address, private readonly smc: SmartContract, readonly init?: { code: Cell; data: Cell }) { } static createFromAddress(blockchain: Blockchain, address: Address) { return new MessageCounter(address, SmartContract.empty(blockchain, address)); } static createFromConfig(blockchain: Blockchain, config: MessageCounterConfig, code: Cell, workchain = 0, balance = toNano(1n)) { const data = messageCounterConfigToCell(config); const init = { code, data }; const smc = SmartContract.create(blockchain, { address: contractAddress(workchain, init), balance, code, data }) return new MessageCounter(contractAddress(workchain, init), smc, init); } sendDeploy(from: Address, balance: bigint = toNano("0.01")): Promise<SmartContractTransaction> { return this.receiveMessage(internal({ to: this.address, from, body: beginCell().endCell(), value: balance })); } static forwardMessage(user_address: Address, forwardMessage: string) { return beginCell() .storeUint(0x36e135d3, 32) // op .storeAddress(user_address) .storeStringTail(forwardMessage) .endCell() } sendForwardMessage(from: Address, user_address: Address, forwardMessage: string, amount: bigint = toNano(1)) { return this.receiveMessage(internal({ from, to: this.address, body: MessageCounter.forwardMessage(user_address, forwardMessage), value: amount, })); } async getCount(): Promise<bigint> { let res = await this.smc.get('get_count', []); let count = res.stackReader.readBigNumber(); return count; } receiveMessage(message: Message) { return this.smc.receiveMessage(message); } get config() { let cell = (this.smc.account.account?.storage.state as AccountStateActive).state.data if(!cell) throw "contract state data not found" return cellToMessageCounterConfig(cell!) } set config(config: MessageCounterConfig) { this.smc.account = createShardAccount({ address: this.address, code: this.init?.code!, data: messageCounterConfigToCell(config), balance: 0n }) } get balance() { return this.smc.balance; } set balance(balance: bigint) { this.smc.balance = balance; } }
Unit Tests
With the groundwork ready, we can now write isolated unit tests to verify the contract’s behavior. Each test will focus on a specific aspect of the contract’s functionality. We begin with this suite as our starting point.
import { Blockchain, internal, SandboxContract, TreasuryContract } from '@ton/sandbox'; import { beginCell, Cell, toNano } from '@ton/core'; import { MessageCounter } from '../wrappers/MessageCounter'; import '@ton/test-utils'; import { compile } from '@ton/blueprint'; import { randomAddress } from '@ton/test-utils'; const minTonForStorage: bigint = 100000n; describe('MessageCounter', () => { let code: Cell; beforeAll(async () => { code = await compile('MessageCounter'); }); let blockchain: Blockchain; let deployer: SandboxContract<TreasuryContract>; let sut: MessageCounter; beforeEach(async () => { blockchain = await Blockchain.create(); sut = MessageCounter.createFromConfig(blockchain, { count: 0n }, code); deployer = await blockchain.treasury('deployer'); }); it('should deploy', async () => { // when const deployResult = await sut.sendDeploy(deployer.address); // then expect(deployResult.endStatus).toEqual('active'); }); });
Now head to the terminal and try running the test suites with npm test
The result of the test suite at this point
Completing the contract with TDD
Setting the contract state
Since our unit tests need to work in isolation, we shouldn’t rely on the deploy test to deploy our contract, instead we will manually set the config of our contract in a beforeEach block.
describe("given contract is active", () => { beforeEach(() => { sut.config = { count: 0n }; }); });
Testing Getters
The simplest candidate to begin our test-driven development is the get_count function that will return us the current counter.
it("when get_count method is invoked then return current count", async () => { // given const expectedCount = toNano(Math.random()); sut.config = { count: expectedCount }; // when let actualCount = await sut.getCount() // then expect(actualCount.count).toEqual(expectedCount); })
If you run the test suite at the moment, you will witness the failing test with error “Unable to execute get method. Got exit_code: 11”. This is because we haven’t defined the get_count function yet in our contract. Head to our contract code and add this function at the very bottom.
(int) get_count() method_id { return load_data(); }
You can run the tests now and verify that they are green now.
The result of the test suite at this point
Testing Messages
For the scope of this article, I will only focus on testing the internal message, but this approach can be extended to test external messages as well.
Let’s start with a couple of tests that has an implementation in our contract already.
describe("when forward_message is received", () => { it("and forward payload is not provided then throw 100", async () => { // given let message = beginCell() .storeUint(0x36e135d3, 32) // op .storeAddress(randomAddress(0)) .endCell() // no forward payload // when let transaction = await sut.receiveMessage(internal({ from: deployer.address, to: sut.address, body: message, value: toNano(1), })); // then expect(transaction).toHaveTransaction({ exitCode: 100 }); }) describe("and forward payload is provided", () => { it("then update counter", async () => { // given const counter = sut.config.count; const toAddress = randomAddress(0); // when await sut.sendForwardMessage(deployer.address, toAddress, "Hello", toNano(1)); // then expect(sut.config.count).toEqual(counter + 1n); }) }) })
These tests should pass because our starting point has the implementation of these functions already.
The result of the test suite at this point
Let’s add another test in the last branch that expects the contract to forward the provided payload.
it("then send message with given body to the specified address", async () => { // given const toAddress = randomAddress(0); // when const transaction = await sut.sendForwardMessage(deployer.address, toAddress, "Hello", toNano(1)); // then expect(transaction).toHaveTransaction({ success: true }); expect(transaction.outMessagesCount).toBeGreaterThanOrEqual(1); expect(transaction.outMessages.get(0)?.body.beginParse().loadStringTail()).toEqual("Hello"); })
The test suite will fail, now we can head to the contract and replace forward_message function with the below implementation.
() forward_message(slice to_address, slice forward_payload) impure inline { var msg = begin_cell() .store_uint(BOUNCEABLE, 6) .store_slice(to_address) .store_coins(0) .store_uint(0, 1 + 4 + 4 + 64 + 32 + 1 + 1) .store_slice(forward_payload); send_raw_message(msg.end_cell(), SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE); }
The result of the test suite at this point
Testing Bounced Messages
Having tested basic functionality, let’s now focus on handling edge cases like bounced messages. Every message that our contract sends increments its counter, hence if a message is bounced we should revert the effect of that message (decrement the counter). This ensures that the contract maintains an accurate count, even when messages fail. Let’s write a test for it,
it("when bounced message is received then decrease counter", async() => { // given const count = 1n; sut.config = { count }; const toAddress = randomAddress(0); // when await sut.receiveMessage(internal({ from: toAddress, to: sut.address, body: beginCell().storeStringTail("Hello").endCell(), bounced: true, value: toNano("0.01") })); // then expect(sut.config.count).toEqual(count - 1n); })
Now let’s implement the functionality to make this green. Head to the contract and modify the conditional branch handling bounced messages.
if (flags & 1) { count -= 1; save_data(count); return (); }
Awesome! You have written a contract on FunC and wrote 100% independent and isolated unit tests on it with @ton/sandbox.
Summary
By following this approach, we’ve demonstrated how to write isolated, independent, and robust unit tests for FunC smart contracts. This methodology not only ensures code correctness but also facilitates seamless TDD practices, paving the way for more reliable and maintainable smart contracts.
You can also find the complete source code for reference here.Â
Sample projects are like practice runs—things might seem smooth, but the real work always finds new ways to surprise you. Now, here’s the feature-length version: my PR to the standard token contract repository implementing this approach!
I would appreciate your feedback, thoughts, or questions in the comments. It will encourage us to write more articles facilitating development on the TON blockchain.