Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 273 additions & 0 deletions contract-dev/testing/style-guide.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
---
title: "Testing styleguide"
sidebarTitle: "Styleguide"
---

import { Aside } from '/snippets/aside.jsx';

This guide explains how to write tests for TON smart contracts.

<Aside type="tip">
Full code examples from this guide are available in the [testing styleguide repository](https://github.com/ton-org/docs-examples/tree/main/Testing/Styleguide).
</Aside>

### Use descriptive test names with "should"

Name tests using the "should" pattern. This makes test intent clear and failures easy to understand.

```typescript
// ❌ Bad - vague or incomplete descriptions
it('counter', async () => { });

it('test increment', async () => { });

// ✅ Good - clear "should" pattern
it('should increment counter', async () => { });

it('should reject increment from non-owner', async () => { });
```

### Prefer `const` over `let`

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.

```typescript expandable
// ❌ Bad - using let
let 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();
});
```

```typescript expandable
// ✅ Good - using const
it('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.

```typescript expandable
// ❌ Bad - dependent tests
describe('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
});
});
```

```typescript expandable
// ✅ Good - independent tests
describe('Contract operations', () => {
it('should increment counter from zero', async () => {
const blockchain = await Blockchain.create();
const contract = blockchain.openContract(MyContract.createFromConfig({}, code));
await contract.sendDeploy(deployer.getSender(), toNano('0.05'));

await contract.sendIncrement(user.getSender(), toNano('0.1'));
expect(await contract.getCounter()).toBe(1n);
});

it('should decrement counter from one', async () => {
const blockchain = await Blockchain.create();
const contract = blockchain.openContract(MyContract.createFromConfig({}, code));
await contract.sendDeploy(deployer.getSender(), toNano('0.05'));

// Set up the required state
await contract.sendIncrement(user.getSender(), toNano('0.1'));

await contract.sendDecrement(user.getSender(), toNano('0.1'));
expect(await contract.getCounter()).toBe(0n);
});
});
```

### Single expect per test

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.

```typescript expandable
// ❌ Bad - multiple expectations
it('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);
});
```

```typescript expandable
// ✅ Good - single expectation per test
it('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);
});
```

### Extract shared logic into test functions

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](https://github.com/ton-org/docs-examples/blob/main/contract-dev/Upgrading/tests/HotUpdate.spec.ts).

### Use `setup()` function

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.

```typescript expandable
const setup = async () => {
const blockchain = await Blockchain.create({ config: "slim" });

const owner = await blockchain.treasury("deployer");
const user = await blockchain.treasury("user");

const contract = blockchain.openContract(await Parent.fromInit());

const deployResult = await contract.send(owner.getSender(), { value: toNano(0.5) }, null);

return { blockchain, owner, user, contract, deployResult };
};

it("should deploy correctly", async () => {
const { owner, contract, deployResult } = await setup();

expect(deployResult.transactions).toHaveTransaction({
from: owner.address,
to: contract.address,
deploy: true,
success: true,
});
});
```

### Component-based test organization

Organize tests by functional components. Large protocols have individual contract tests and integration tests. Separating concerns makes it clear what broke.

```typescript
describe("parent", () => {
// Test parent contract in isolation
});

describe("child", () => {
// Test child contract in isolation
});

describe("protocol", () => {
// Test end-to-end protocol flows
});
```

## Gas and fee considerations

Fee validation is critical in TON because transactions can halt mid-execution when funds run out. Consider this scenario with [Jetton](/standard/tokens/jettons/how-it-works) 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](/contract-dev/gas).

### Discovering minimal fees

Calculate required fees using formulas, but empirically discovering the minimal amount provides practical validation. Use binary search to efficiently find the threshold:

```typescript expandable
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");
});
```

## Advanced testing patterns

### Testing all message fields

When testing messages, verify all important fields to ensure complete correctness.

```typescript expandable
it("should send message to parent", async () => {
const { user, owner, contract } = await setup();
const message: SomeMessage = { $$type: "SomeMessage" };
const sendResult = await contract.send(user.getSender(), { value: toNano(0.1) }, message);
const expectedMessage: NotifyParent = {
$$type: "NotifyParent",
originalSender: user.address,
};
const expectedBody = beginCell().store(storeNotifyParent(expectedMessage)).endCell();
expect(sendResult.transactions).toHaveTransaction({
from: contract.address,
to: owner.address,
body: expectedBody,
mode: SendMode.CARRY_ALL_REMAINING_INCOMING_VALUE,
});
});
```
1 change: 1 addition & 0 deletions docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@
"group": "Testing",
"pages": [
"contract-dev/testing/overview",
"contract-dev/testing/style-guide",
"contract-dev/testing/reference"
]
},
Expand Down