Use const for all variable declarations. Immutable bindings prevent accidental reassignments and make test behavior predictable. Tests using let with shared hooks can silently use wrong contract instances when setup changes.
// ❌ Bad - using letlet blockchain: Blockchain;let contract: SandboxContract<Contract>;beforeEach(async () => { blockchain = await Blockchain.create(); contract = blockchain.openContract(await Contract.fromInit());});it('should do smth', async () => { await contract.sendSmth();});
// ✅ Good - using constit('should do smth', async () => { const blockchain = await Blockchain.create({ config: "slim" }); const contract = blockchain.openContract(await Contract.fromInit()); await contract.sendSmth();});
Do not depend on state generated by previous tests
Make each test completely independent. Test execution order can change. Dependencies between tests create fragile suites where one failure cascades into dozens of false failures.
// ❌ Bad - dependent testsdescribe('Contract operations', () => { let blockchain: Blockchain; let contract: SandboxContract<MyContract>; beforeAll(async () => { blockchain = await Blockchain.create(); contract = blockchain.openContract(MyContract.createFromConfig({}, code)); await contract.sendDeploy(deployer.getSender(), toNano('0.05')); }); it('should increment counter', async () => { await contract.sendIncrement(user.getSender(), toNano('0.1')); expect(await contract.getCounter()).toBe(1n); }); it('should decrement counter', async () => { // This test depends on the previous test's state await contract.sendDecrement(user.getSender(), toNano('0.1')); expect(await contract.getCounter()).toBe(0n); // Assumes counter was 1 });});
Verify one specific behavior per test. When a test has multiple assertions and fails on the second, the third never runs. This masks additional bugs and forces sequential debugging instead of catching all issues at once.
// ❌ Bad - multiple expectationsit('should handle user operations', async () => { // some code await contract.sendIncrement(user.getSender(), toNano('0.1')); expect(await contract.getCounter()).toBe(1n); await contract.sendDecrement(user.getSender(), toNano('0.1')); expect(await contract.getCounter()).toBe(0n); expect(await contract.getOwner()).toEqualAddress(user.address);});
// ✅ Good - single expectation per testit('should increment counter', async () => { // some code await contract.sendIncrement(user.getSender(), toNano('0.1')); expect(await contract.getCounter()).toBe(1n);});it('should decrement counter', async () => { // some code await contract.sendIncrement(user.getSender(), toNano('0.1')); await contract.sendDecrement(user.getSender(), toNano('0.1')); expect(await contract.getCounter()).toBe(0n);});it('should set correct owner', async () => { // some code expect(await contract.getOwner()).toEqualAddress(user.address);});
When multiple contracts share similar behavior, extract that logic into a reusable test function. This reduces duplication and ensures consistent testing across related contracts.See example in the HotUpdate test suite.
Extract common test setup into a dedicated function. This reduces duplication and improves maintainability. When initialization logic changes, updating it in one place prevents errors and inconsistency.
Organize tests by functional components. Large protocols have individual contract tests and integration tests. Separating concerns makes it clear what broke.
describe("parent", () => { // Test parent contract in isolation});describe("child", () => { // Test child contract in isolation});describe("protocol", () => { // Test end-to-end protocol flows});
Fee validation is critical in TON because transactions can halt mid-execution when funds run out. Consider this scenario with Jetton transfers: Alice sends 100 jettons to Bob. The transaction successfully debits Alice’s jetton wallet, but Bob never receives the tokens. Insufficient fees prevented the message from reaching Bob’s wallet. The tokens are effectively lost.Always validate that gas constants and fee calculations are sufficient for complete transaction execution. See more details in the gas documentation.
Calculate required fees using formulas, but empirically discovering the minimal amount provides practical validation. Use binary search to efficiently find the threshold:
test.skip("find minimal amount of TON for protocol", async () => { const checkAmount = async (amount: bigint) => { const { user, child, parent } = await setup(); const message: SomeMessage = { $$type: "SomeMessage" }; const sendResult = await child.send(user.getSender(), { value: amount }, message); expect(sendResult.transactions).toHaveTransaction({ from: parent.address, to: user.address, body: beginCell().endCell(), mode: SendMode.CARRY_ALL_REMAINING_INCOMING_VALUE + SendMode.IGNORE_ERRORS, }); }; let L = 0n; let R = toNano(10); while (L + 1n < R) { let M = (L + R) / 2n; try { await checkAmount(M); R = M; } catch (error) { L = M; } } console.log(R, "is the minimal amount of nanotons for protocol");});