diff --git a/docs/src/content/docs/book/compile.mdx b/docs/src/content/docs/book/compile.mdx
index 64645844dc..3603da1c75 100644
--- a/docs/src/content/docs/book/compile.mdx
+++ b/docs/src/content/docs/book/compile.mdx
@@ -417,6 +417,45 @@ export class Playground implements Contract {
:::
+### Test generation, `.stub.tests.ts` {#test-stubs}
+
+
+
+By default, Tact automatically generates test stubs for each compiled contract. These files provide a basic testing structure using TypeScript and the TON Blockchain sandbox, serving as a starting point for comprehensive contract testing.
+
+#### Generated file structure {#test-file-structure}
+
+Test stubs are saved to the `tests/` subdirectory inside the output folder specified in the [configuration](/book/config#projects-output). Each contract gets its test file named as `{project_name}_{contract_name}.stub.tests.ts`.
+
+For example: `build/tests/MyProject_Counter.stub.tests.ts`, where `build/` is the output folder.
+
+#### Test content {#test-stub-content}
+
+Generated test stubs include basic blockchain sandbox setup, contract deployment tests, and examples using the [TypeScript wrappers](#wrap-ts). They provide a foundation for testing contract functionality with proper imports and configuration.
+
+#### Configuration {#test-generation-config}
+
+Disable automatic test generation by adding the `skipTestGeneration` option to your [project configuration](/book/config):
+
+```json title="tact.config.json"
+{
+ "projects": [
+ {
+ "name": "MyProject",
+ "path": "./contracts/contract.tact",
+ "output": "./build",
+ "options": {
+ "skipTestGeneration": true
+ }
+ }
+ ]
+}
+```
+
+#### Using test stubs {#test-stub-usage}
+
+Read more in the dedicated section: [Using generated test stubs](/book/debug#tests-using-stubs).
+
[struct]: /book/structs-and-messages#structs
[message]: /book/structs-and-messages#messages
diff --git a/docs/src/content/docs/book/config.mdx b/docs/src/content/docs/book/config.mdx
index e7cf472b3d..d9362e5907 100644
--- a/docs/src/content/docs/book/config.mdx
+++ b/docs/src/content/docs/book/config.mdx
@@ -498,6 +498,41 @@ If set to `true{:json}`, enables the generation of the `lazy_deployment_complete
}
```
+#### `skipTestGeneration` {#options-skiptestgeneration}
+
+
+
+`false{:json}` by default.
+
+If set to `true{:json}`, disables automatic generation of test files. By default, Tact generates test stubs for all contracts in the `tests/` subdirectory of the output folder.
+
+```json title="tact.config.json" {8,14}
+{
+ "projects": [
+ {
+ "name": "contract",
+ "path": "./contract.tact",
+ "output": "./output",
+ "options": {
+ "skipTestGeneration": true
+ }
+ },
+ {
+ "name": "ContractUnderBlueprint",
+ "options": {
+ "skipTestGeneration": true
+ }
+ }
+ ]
+}
+```
+
+:::note
+
+ Read more about test generation: [Test generation](/book/compile#test-stubs).
+
+:::
+
### `verbose` {#projects-verbose}
@@ -595,7 +630,8 @@ In [Blueprint][bp], `mode` is always set to `"full"{:json}` and cannot be overri
"alwaysSaveContractData": true,
"internalExternalReceiversOutsideMethodsMap": true
},
- "enableLazyDeploymentCompletedGetter": true
+ "enableLazyDeploymentCompletedGetter": true,
+ "skipTestGeneration": false
}
}
]
diff --git a/docs/src/content/docs/book/debug.mdx b/docs/src/content/docs/book/debug.mdx
index 0501e42ec4..8be43b63ab 100644
--- a/docs/src/content/docs/book/debug.mdx
+++ b/docs/src/content/docs/book/debug.mdx
@@ -131,6 +131,30 @@ Whenever you create a new [Blueprint][bp] project or use the `blueprint create`
Those files are placed in the `tests/` folder and executed with [Jest][jest]. By default, all tests run unless you specify a specific group or test closure. For other options, refer to the brief documentation in the Jest CLI: `jest --help`.
+### Using generated test stubs {#tests-using-stubs}
+
+
+
+Tact automatically generates test stubs for each compiled contract during the [compilation process](/book/compile#test-stubs). These generated test files serve as excellent starting points for writing comprehensive tests.
+
+To use the generated test stubs:
+
+1. Copy them from the `tests/` subdirectory inside the output directory specified in your [`tact.config.json`](/book/config#projects-output).
+
+2. Customize them according to the specific needs of your contract. The generated stubs include:
+
+ * Basic [Sandbox][sb] setup
+ * Contract deployment tests
+ * Example use of [TypeScript wrappers](#tests-wrappers)
+
+3. Extend with additional test cases. The generated tests are intended as a starting point, not a complete test suite.
+
+:::caution
+
+ Since generated test files are overwritten on each compilation, always copy them to a separate location **before customizing**. This ensures your test modifications are preserved.
+
+:::
+
### Structure of test files {#tests-structure}
Let's say we have a contract named `Playground`, written in the `contracts/playground.tact` file. If we've created that contract through [Blueprint][bp], it also created a `tests/Playground.spec.ts` test suite file for us.
diff --git a/package.json b/package.json
index 253d2808b8..8e6eb2c4bb 100644
--- a/package.json
+++ b/package.json
@@ -21,8 +21,8 @@
],
"scripts": {
"compare": "ts-node src/logs/compare-logs.infra.ts",
- "build:fast": "yarn clean && yarn gen:grammar && yarn gen:stdlib && yarn gen:func-js && tsc --project tsconfig.fast.json && yarn copy:stdlib && yarn copy:func && yarn to-relative",
- "build": "cross-env NODE_OPTIONS=--max_old_space_size=5120 tsc && yarn copy:stdlib && yarn copy:func && yarn to-relative",
+ "build:fast": "yarn clean && yarn gen:grammar && yarn gen:stdlib && yarn gen:func-js && tsc --project tsconfig.fast.json && yarn copy:stdlib && yarn copy:func && yarn copy:templates && yarn to-relative",
+ "build": "cross-env NODE_OPTIONS=--max_old_space_size=5120 tsc && yarn copy:stdlib && yarn copy:func && yarn copy:templates && yarn to-relative",
"gen:config": "ts-to-zod -k --skipValidation src/config/config.ts src/config/config.zod.ts",
"gen:grammar": "pgen src/grammar/grammar.peggy -o src/grammar/grammar.ts",
"gen:stdlib": "ts-node src/stdlib/stdlib.build.ts",
@@ -39,6 +39,7 @@
"cleanall": "rimraf dist node_modules",
"copy:stdlib": "ts-node src/stdlib/copy.build.ts",
"copy:func": "ts-node src/func/copy.build.ts",
+ "copy:templates": "ts-node src/bindings/copy.build.ts",
"to-absolute": "ts-node src/to-absolute.build.ts",
"to-relative": "ts-node src/to-relative.build.ts",
"test": "jest",
@@ -98,6 +99,7 @@
"blockstore-core": "1.0.5",
"glob": "^8.1.0",
"ipfs-unixfs-importer": "9.0.10",
+ "mustache": "^4.2.0",
"path-normalize": "^6.0.13",
"yaml": "^2.7.1",
"zod": "^3.22.4"
@@ -118,6 +120,7 @@
"@types/diff": "^7.0.0",
"@types/glob": "^8.1.0",
"@types/jest": "^29.5.12",
+ "@types/mustache": "^4.2.6",
"@types/node": "^22.5.0",
"@typescript-eslint/eslint-plugin": "^8.21.0",
"@typescript-eslint/parser": "^8.21.0",
diff --git a/src/bindings/copy.build.ts b/src/bindings/copy.build.ts
new file mode 100644
index 0000000000..0364c1f9c2
--- /dev/null
+++ b/src/bindings/copy.build.ts
@@ -0,0 +1,30 @@
+import * as fs from "node:fs/promises";
+import * as path from "node:path";
+import * as glob from "glob";
+
+const cp = async (fromGlob: string, toPath: string) => {
+ for (const file of glob.sync(path.join(fromGlob, "**/*"), {
+ windowsPathsNoEscape: true,
+ })) {
+ const relPath = path.relative(fromGlob, file);
+ const pathTo = path.join(toPath, relPath);
+ const stat = await fs.stat(file);
+ if (stat.isDirectory()) {
+ await fs.mkdir(pathTo, { recursive: true });
+ } else {
+ await fs.mkdir(path.dirname(pathTo), { recursive: true });
+ await fs.copyFile(file, pathTo);
+ }
+ }
+};
+
+const main = async () => {
+ try {
+ await cp("./src/bindings/templates/", "./dist/bindings/templates/");
+ } catch (e) {
+ console.error(e);
+ process.exit(1);
+ }
+};
+
+void main();
diff --git a/src/bindings/templates/test.mustache b/src/bindings/templates/test.mustache
new file mode 100644
index 0000000000..91ca5665b4
--- /dev/null
+++ b/src/bindings/templates/test.mustache
@@ -0,0 +1,48 @@
+// !!THIS FILE IS GENERATED BY TACT. THIS FILE IS REGENERATED EVERY TIME, COPY IT TO YOUR PROJECT MANUALLY!!
+// https://docs.tact-lang.org/book/debug/
+
+{{#imports}}
+import {{{.}}};
+{{/imports}}
+
+export const test{{contractName}} = () => {
+ describe("{{contractName}} Contract", () => {
+ // Test receivers
+{{#receivers}}
+ test{{.}}();
+{{/receivers}}
+ // Test getters
+{{#getters}}
+ getterTest{{.}}();
+{{/getters}}
+ });
+};
+
+const globalSetup = async () => {
+ const blockchain = await Blockchain.create();
+ // @ts-ignore
+ const contract = await blockchain.openContract(await {{contractName}}.fromInit(
+ // TODO: implement default values
+ ));
+
+ // Universal method for deploy contract without sending message
+ await blockchain.setShardAccount(contract.address, createShardAccount({
+ address: contract.address,
+ code: contract.init!.code,
+ data: contract.init!.data,
+ balance: 0n,
+ workchain: 0
+ }));
+
+ const owner = await blockchain.treasury("owner");
+ const notOwner = await blockchain.treasury("notOwner");
+
+ return { blockchain, contract, owner, notOwner };
+};
+
+{{#receiverBlocks}}
+{{{.}}}
+{{/receiverBlocks}}
+{{#getterBlocks}}
+{{{.}}}
+{{/getterBlocks}}
\ No newline at end of file
diff --git a/src/bindings/writeTests.ts b/src/bindings/writeTests.ts
new file mode 100644
index 0000000000..b9a52f71d3
--- /dev/null
+++ b/src/bindings/writeTests.ts
@@ -0,0 +1,114 @@
+import { readFileSync } from "fs";
+import { resolve } from "path";
+import Mustache from "mustache";
+import type { ABIArgument, ContractABI, ABIReceiver } from "@ton/core";
+import type { WrappersConstantDescription } from "@/bindings/writeTypescript";
+import type { CompilerContext } from "@/context/context";
+import type { TypeDescription } from "@/types/types";
+
+function getReceiverFunctionName(receiver: ABIReceiver): string {
+ const receiverType = receiver.receiver; // 'internal' or 'external'
+ const messageKind = receiver.message.kind; // 'empty', 'typed', 'text', 'any'
+
+ let name = receiverType.charAt(0).toUpperCase() + receiverType.slice(1); // Internal or External
+
+ switch (messageKind) {
+ case "empty":
+ name += "Empty";
+ break;
+ case "typed":
+ name += "Message";
+ name += receiver.message.type ?? "Typed";
+ break;
+ case "text":
+ name += "Text";
+ if (receiver.message.text) {
+ const cleanText = receiver.message.text.replace(
+ /[^a-zA-Z0-9]/g,
+ "",
+ );
+ name += cleanText.charAt(0).toUpperCase() + cleanText.slice(1);
+ }
+ break;
+ case "any":
+ name += "Any";
+ break;
+ default:
+ name += "Unknown";
+ }
+
+ return name;
+}
+
+export function writeTests(
+ abi: ContractABI,
+ _ctx: CompilerContext,
+ _constants: readonly WrappersConstantDescription[],
+ _contract: undefined | TypeDescription,
+ generatedContractPath: string,
+ _init?: {
+ code: string;
+ system: string | null;
+ args: ABIArgument[];
+ prefix?:
+ | {
+ value: number;
+ bits: number;
+ }
+ | undefined;
+ },
+) {
+ const contractName = abi.name ?? "Contract";
+
+ const templateData = {
+ contractName,
+ imports: [
+ `{ ${contractName} } from '../${generatedContractPath}'`,
+ '{ Blockchain, createShardAccount } from "@ton/sandbox"',
+ ],
+ receivers: abi.receivers?.map(getReceiverFunctionName) ?? [],
+ getters: abi.getters?.map((g) => g.name) ?? [],
+ receiverBlocks: (abi.receivers ?? []).map((r) => {
+ const fn = getReceiverFunctionName(r);
+ return `const test${fn} = async () => {
+ describe("${fn}", () => {
+ const setup = async () => {
+ return await globalSetup();
+ };
+
+ // !!THIS FILE IS GENERATED BY TACT. THIS FILE IS REGENERATED EVERY TIME, COPY IT TO YOUR PROJECT MANUALLY!!
+ // TODO: You can write tests for ${fn} here
+
+ it("should perform correctly", async () => {
+ const { blockchain, contract, owner, notOwner } = await setup();
+ });
+ });
+};
+`;
+ }),
+ getterBlocks: (abi.getters ?? []).map((g) => {
+ const fn = g.name;
+ return `const getterTest${fn} = async () => {
+ describe("${fn}", () => {
+ const setup = async () => {
+ return await globalSetup();
+ };
+
+ // !!THIS FILE IS GENERATED BY TACT. THIS FILE IS REGENERATED EVERY TIME, COPY IT TO YOUR PROJECT MANUALLY!!
+ // TODO: You can write tests for ${fn} here
+
+ it("should perform correctly", async () => {
+ const { blockchain, contract, owner, notOwner } = await setup();
+ });
+ });
+};
+`;
+ }),
+ };
+
+ const templatePath = resolve(__dirname, "templates", "test.mustache");
+ const template = readFileSync(templatePath, "utf-8");
+ const rendered = Mustache.render(template, templateData);
+
+ return rendered;
+}
diff --git a/src/config/config.ts b/src/config/config.ts
index d474f24e40..9f94ffd4a9 100644
--- a/src/config/config.ts
+++ b/src/config/config.ts
@@ -77,6 +77,10 @@ export type Options = {
* Does nothing if contract parameters are declared.
*/
readonly enableLazyDeploymentCompletedGetter?: boolean;
+ /**
+ * If set to true, disables generation of test files.
+ */
+ readonly skipTestGeneration?: boolean;
};
export type Mode = "fullWithDecompilation" | "full" | "funcOnly" | "checkOnly";
diff --git a/src/config/config.zod.ts b/src/config/config.zod.ts
index 231098216a..4a76f5c3c3 100644
--- a/src/config/config.zod.ts
+++ b/src/config/config.zod.ts
@@ -68,6 +68,10 @@ export const optionsSchema: z.ZodType = z.object({
* Does nothing if contract parameters are declared.
*/
enableLazyDeploymentCompletedGetter: z.boolean().optional(),
+ /**
+ * If set to true, disables generation of test files.
+ */
+ skipTestGeneration: z.boolean().optional(),
});
export const modeSchema: z.ZodType = z.union([
diff --git a/src/config/configSchema.json b/src/config/configSchema.json
index 1dc1578e73..b76aafd271 100644
--- a/src/config/configSchema.json
+++ b/src/config/configSchema.json
@@ -93,6 +93,11 @@
"type": "boolean",
"default": false,
"description": "False by default. If set to true, enables generation of `lazy_deployment_completed()` getter. Does nothing if contract parameters are declared."
+ },
+ "skipTestGeneration": {
+ "type": "boolean",
+ "default": false,
+ "description": "False by default. If set to true, disables generation of test files."
}
}
},
diff --git a/src/pipeline/build.ts b/src/pipeline/build.ts
index 47065baf3e..92e808f122 100644
--- a/src/pipeline/build.ts
+++ b/src/pipeline/build.ts
@@ -14,6 +14,7 @@ import type { TypeDescription } from "@/types/types";
import { doPackaging } from "@/pipeline/packaging";
import { doBindings } from "@/pipeline/bindings";
import { doReports } from "@/pipeline/reports";
+import { doTests } from "@/pipeline/tests";
import { createVirtualFileSystem } from "@/vfs/createVirtualFileSystem";
import * as Stdlib from "@/stdlib/stdlib";
@@ -144,6 +145,13 @@ export async function build(args: {
return BuildFail(bCtx.errorMessages);
}
+ if (!bCtx.config.options?.skipTestGeneration) {
+ const testsRes = doTests(bCtx, packages);
+ if (!testsRes) {
+ return BuildFail(bCtx.errorMessages);
+ }
+ }
+
const reportsRes = doReports(bCtx, packages);
if (!reportsRes) {
return BuildFail(bCtx.errorMessages);
diff --git a/src/pipeline/tests.ts b/src/pipeline/tests.ts
new file mode 100644
index 0000000000..9c045c3f36
--- /dev/null
+++ b/src/pipeline/tests.ts
@@ -0,0 +1,50 @@
+import { writeTests } from "@/bindings/writeTests";
+import type { BuildContext } from "@/pipeline/build";
+import type { Packages } from "@/pipeline/packaging";
+
+export function doTests(bCtx: BuildContext, packages: Packages): boolean {
+ const { project, config, logger } = bCtx;
+
+ logger.info(" > Tests");
+
+ for (const pkg of packages) {
+ logger.info(` > ${pkg.name}`);
+
+ if (pkg.init.deployment.kind !== "system-cell") {
+ const message = ` > ${pkg.name}: unsupported deployment kind ${pkg.init.deployment.kind}`;
+ logger.error(message);
+ bCtx.errorMessages.push(new Error(message));
+ return false;
+ }
+
+ try {
+ const testsCode = writeTests(
+ JSON.parse(pkg.abi),
+ bCtx.ctx,
+ bCtx.built[pkg.name]?.constants ?? [],
+ bCtx.built[pkg.name]?.contract,
+ config.name + "_" + pkg.name,
+ {
+ code: pkg.code,
+ prefix: pkg.init.prefix,
+ system: pkg.init.deployment.system,
+ args: pkg.init.args,
+ },
+ );
+ const testsPath = project.resolve(
+ config.output,
+ "tests",
+ config.name + "_" + pkg.name + ".stub.tests.ts",
+ );
+ project.writeFile(testsPath, testsCode);
+ } catch (e) {
+ const error = e as Error;
+ error.message = `Tests generator crashed: ${error.message}`;
+ logger.error(error);
+ bCtx.errorMessages.push(error);
+ return false;
+ }
+ }
+
+ return true;
+}
diff --git a/yarn.lock b/yarn.lock
index 22de928e53..21c47b03c7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1565,6 +1565,11 @@
resolved "https://npm.dev-internal.org/@types/minimatch/-/minimatch-5.1.2.tgz"
integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==
+"@types/mustache@^4.2.6":
+ version "4.2.6"
+ resolved "https://npm.dev-internal.org/@types/mustache/-/mustache-4.2.6.tgz#9d4f903f4ad373699b253aa1369727bc5042811f"
+ integrity sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw==
+
"@types/node@*", "@types/node@>=13.7.0", "@types/node@^22.5.0":
version "22.15.29"
resolved "https://npm.dev-internal.org/@types/node/-/node-22.15.29.tgz#c75999124a8224a3f79dd8b6ccfb37d74098f678"
@@ -4638,6 +4643,11 @@ murmurhash3js-revisited@^3.0.0:
resolved "https://npm.dev-internal.org/murmurhash3js-revisited/-/murmurhash3js-revisited-3.0.0.tgz"
integrity sha512-/sF3ee6zvScXMb1XFJ8gDsSnY+X8PbOyjIuBhtgis10W2Jx4ZjIhikUCIF9c4gpJxVnQIsPAFrSwTCuAjicP6g==
+mustache@^4.2.0:
+ version "4.2.0"
+ resolved "https://npm.dev-internal.org/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64"
+ integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==
+
mute-stream@0.0.8:
version "0.0.8"
resolved "https://npm.dev-internal.org/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"