Generate TypeScript tests from Daml scripts. Use when user mentions "generate tests", "create tests for canton", or "generate canton tests".
Generate TypeScript integration tests that mirror Daml test scripts, using the SDK.
This skill requires a pre-generated SDK. Before running:
<project-path>/sdk<project-name>-api.ts with template namespacesLook for Daml project configuration:
daml.yaml in workspace - extract project name<project-path>/sdk/<project-name>-api.tsLook for Daml test scripts:
ls <project-path>/daml/Scripts/tests/
If directory does NOT exist or is empty:
<project-path>/daml/Scripts/tests/".daml script files that define test workflows."Convention: If <project-path>/daml/Scripts/Setup.daml exists, it contains ledger setup logic (party allocation, initial contracts, configuration) that should run before all tests.
Check for the setup file:
ls <project-path>/daml/Scripts/Setup.daml
If Setup.daml exists, create sdk/__tests__/testSetup.ts:
Setup.daml and understand what it sets up (factories, instruments, accounts, initial state)vaultId = "vault-001", not made-up values)testSetup.ts that CREATES the same contracts - the setup file must replicate the Setup.daml logic using the SDK, actually creating contracts on the ledgerbeforeAll to run the setup once before all testsCRITICAL: The testSetup.ts must actually CREATE contracts, not just verify they exist. It replaces Setup.daml for TypeScript tests. The tests should be runnable on a fresh ledger with only the DAR deployed.
CRITICAL: Never hardcode different values than what Setup.daml uses. Read the setup script and use the EXACT same IDs, amounts, and configuration.
The setup file should:
// testSetup.ts structure
import { beforeAll } from 'vitest';
import { TemplateIds, Query, Account_Account_Factory, ... } from '../<project>-api';
import type { Party, ContractId, Id, Numeric } from '../core/primitives';
import type { AccountKey, InstrumentKey, HoldingFactoryKey } from '../core/interfaces';
import { createLedgerClient } from '../ledger';
// Party configuration from environment (names depend on your Setup.daml)
export const user = process.env.USER_PARTY as Party;
export const operator = process.env.OPERATOR_PARTY as Party;
if (!user || !operator) {
throw new Error('USER_PARTY and OPERATOR_PARTY environment variables required');
}
// Ledger clients - one per party
export const userLedger = createLedgerClient(user);
export const operatorLedger = createLedgerClient(operator);
// Configuration constants (MUST match Setup.daml exactly - read from the file!)
export const configId: Id = { unpack: "config-001" }; // Example - use actual value from Setup.daml
export const initialAmount: Numeric = "1000.0"; // Example - use actual value from Setup.daml
// ... other constants extracted from Setup.daml
// Keys and IDs that will be populated during setup
export let userAccount: AccountKey;
export let primaryInstrument: InstrumentKey;
export let mainConfigCid: ContractId<unknown>;
// ... other exports based on what Setup.daml creates
beforeAll(async () => {
// Check if setup already ran by querying for a key contract from Setup.daml
const existingState = await operatorLedger.query(TemplateIds.YourProject_State_MainState);
if (existingState.length > 0) {
console.log('Setup already completed, reusing existing contracts');
// Populate exports from existing contracts (keys, instruments, etc.)
// IMPORTANT: Re-fund user accounts if they consumed holdings in previous runs
const userHoldings = await userLedger.query(TemplateIds.Holding_TransferableFungible);
const relevantHoldings = userHoldings.filter(h =>
h.payload.instrument.id.unpack === "TOKEN-ID" && h.payload.account.owner === user
);
if (relevantHoldings.length === 0) {
console.log('Re-funding user account...');
// Credit user with initial balance again using workflow
const creditCmd = Workflow_CreditAccount_Request.create({ ... });
// ... accept the credit request
}
return;
}
console.log('Running ledger setup...');
// 1. Create factories (Daml Finance standard pattern)
const accountFactoryCmd = Account_Account_Factory.create({ provider: operator, observers: [] });
const accountFactoryCid = await operatorLedger.create(accountFactoryCmd.templateId, accountFactoryCmd.argument);
// Create Holding Factory AND its Reference (Reference is required for account creation)
const holdingFactoryCmd = Holding_Factory.create({
provider: operator,
id: { unpack: "Holding Factory" },
observers: []
});
const holdingFactoryCid = await operatorLedger.create(holdingFactoryCmd.templateId, holdingFactoryCmd.argument);
// IMPORTANT: Create Holding Factory Reference - accounts need this to find the factory
const holdingFactoryRefCmd = Holding_Factory_Reference.create({
factoryView: { provider: operator, id: { unpack: "Holding Factory" } } as any,
cid: holdingFactoryCid as any,
observers: []
});
await operatorLedger.create(holdingFactoryRefCmd.templateId, holdingFactoryRefCmd.argument);
// 2. Create instruments (token factory pattern)
// ... create Token_Factory, then Token_Instrument for each asset
// 3. Create accounts via workflow - pass holdingFactory key (not CID)
const holdingFactory: HoldingFactoryKey = {
provider: operator,
id: { unpack: "Holding Factory" }
};
// CreateAccount.Request.accept needs: accountFactoryCid, holdingFactory, observers
// ... CreateAccount.Request + Accept for each user
// 4. Fund accounts (credit initial balances)
// ... CreditAccount.Request + Accept for initial holdings
// 5. Create application-specific state contracts
// ... Your project's config and state templates
console.log('Setup complete!');
}, 60000); // 60 second timeout for setup
If Setup.daml does NOT exist, skip this step. Tests will handle their own setup.
For each .daml file in Scripts/tests/:
Goal: Create a TypeScript test that achieves the same end result as the DAML script, using the SDK. The translation doesn't need to be 1:1 - what matters is that the test verifies the same workflow behavior.
TemplateName.create({...}) + ledger.create()TemplateName.choiceName(cid, args) + ledger.exercise()Query helper - IMPORTANT: The Query.<templateName>() functions return a QuerySpec object with templateId and filter properties. You must pass these separately to ledger.query():
// CORRECT - destructure the QuerySpec
const spec = Query.yourProject_State_MainState({ operator });
const contracts = await ledger.query(spec.templateId, spec.filter);
// Or inline destructuring:
const { templateId, filter } = Query.holding_Fungible({ account: accountKey });
const holdings = await ledger.query(templateId, filter);
// WRONG - do NOT pass QuerySpec directly (causes "templateId.split is not a function")
// const contracts = await ledger.query(Query.yourProject_State_MainState());
alice → process.env.ALICE_PARTY)If a workflow charges fees, do not assume “vault assets decrease by what the user received”. Often:
Derive expectations from the DAML logic / returned values to avoid off‑by‑fee errors.
Each test should be runnable independently where possible:
beforeAllIMPORTANT: Test file execution order is not guaranteed. Prefer making each test self-contained.
If you must enforce order (last resort), prefix filenames with numbers (e.g., 01_, 02_, 03_).
In beforeAll(), verify the ledger is properly set up before running tests:
daml start completed successfully.")The SDK's Query.<templateName>() functions return a QuerySpec object, NOT a template ID string. You MUST destructure the result before passing to ledger.query():
// ✅ CORRECT - destructure templateId and filter from QuerySpec
const spec = Query.yourProject_State_MainState({ operator });
const contracts = await ledger.query(spec.templateId, spec.filter);
// ✅ CORRECT - inline destructuring also works
const { templateId, filter } = Query.holding_Fungible({ account });
const holdings = await ledger.query(templateId, filter);
// ❌ WRONG - passing QuerySpec directly causes runtime error
// "TypeError: templateId.split is not a function"
const contracts = await ledger.query(Query.yourProject_State_MainState());
This pattern applies to ALL query calls in tests. Always destructure first.
Canton/Daml Finance uses wrapped types for type safety. You MUST use these correctly:
Id Type - Wrapped string identifierThe Id type is NOT a plain string. It has an unpack property:
// SDK type definition:
export interface Id {
unpack: string;
}
// ✅ CORRECT - use { unpack: "value" } format
const configId: Id = { unpack: "config-001" };
const instrumentId: Id = { unpack: "USD" };
const accountId: Id = { unpack: "User@Operator" };
// ❌ WRONG - plain strings cause type errors
const configId = "config-001"; // Type 'string' is not assignable to type 'Id'
This applies to ALL id fields: configId, instrument.id, account.id, holdingFactory.id, etc.
Numeric Type - String-based decimalThe Numeric type is a string, not a number. This preserves decimal precision:
// SDK type definition:
export type Numeric = string;
// ✅ CORRECT - use string literals for amounts
const amount: Numeric = "500.0";
const sharePrice: Numeric = "1.0";
const initialBalance: Numeric = "1000.0";
// ❌ WRONG - numbers cause type errors
const amount = 500.0; // Type 'number' is not assignable to type 'string'
Since Numeric is a string, you must parse before arithmetic:
// ✅ CORRECT - parse to number for calculations
const depositAmount = "500.0";
const sharePrice = "1.0";
const expectedShares = parseFloat(depositAmount) / parseFloat(sharePrice);
// For comparisons with contract data:
const actualShares = parseFloat(vaultState.payload.totalShares);
expect(actualShares).toBeCloseTo(expectedShares, 6);
// ❌ WRONG - direct arithmetic on strings
const shares = depositAmount / sharePrice; // NaN or type error
These compound types contain Id fields:
// ✅ CORRECT - nested Id objects with all required fields
const instrumentKey: InstrumentKey = {
depository: custodianParty,
issuer: custodianParty,
id: { unpack: "USD" }, // Id type, not string
version: "0",
holdingStandard: { tag: "TransferableFungible" } // Enum as tagged object
};
const accountKey: AccountKey = {
custodian: custodianParty,
owner: aliceParty,
id: { unpack: "Alice@Vault" } // Id type, not string
};
// ❌ WRONG - plain string ids
const instrumentKey = {
depository: custodianParty,
issuer: custodianParty,
id: "USD", // Type error!
version: "0"
};
When exercising choices, you often need to cast ContractId<unknown> to the specific payload type:
// The create() returns ContractId<unknown>, but accept() needs ContractId<Payload>
const requestCid = await ledger.create(requestCmd.templateId, requestCmd.argument);
// ✅ CORRECT - cast to specific ContractId type
const acceptCmd = Workflow_CreateAccount_Request.accept(
requestCid as ContractId<Workflow_CreateAccount_Request.Payload>
);
// Then exercise:
await ledger.exercise(
acceptCmd.templateId!,
requestCid,
acceptCmd.choice!,
{ /* choice arguments */ }
);
// ❌ WRONG - using ContractId<unknown> directly causes type errors
const acceptCmd = Workflow_CreateAccount_Request.accept(requestCid); // Type error
Extracting Choice Results: Daml choices can return values. Access them via exerciseResult:
// Exercise returns the choice's return value in exerciseResult
const result = await ledger.exercise(
acceptCmd.templateId!,
requestCid,
acceptCmd.choice!,
{ label: "User@Operator", description: "User account", accountFactoryCid, holdingFactory, observers: [] }
);
// Cast the result to the expected return type (depends on what the Daml choice returns)
const accountKey = result.exerciseResult as AccountKey;
Daml has enums and variants and they serialize differently in the JSON API:
"TransferableFungible"{ "tag": "Constructor", "value": <payload> }{ "tag": "Constructor" }Daml Finance note: HoldingStandard is commonly an enum in practice, so the JSON API expects a string.
// Example enum value (JSON: "TransferableFungible")
const holdingStandard = "TransferableFungible" as any;
// Example variant value (JSON: { tag, value })
const someVariant = { tag: "SomeCase", value: { /* payload */ } } as any;
Rule of thumb:
{ tag: ... } objects, pass the tagged object.Import the necessary types from the SDK:
import type { Id, Numeric, Party, ContractId, DamlMap } from '../core/primitives';
import type { InstrumentKey, AccountKey, HoldingFactoryKey } from '../core/interfaces';
Map Types in Daml JSON APIDaml Map types are serialized as arrays of key-value pairs, NOT as objects:
// Daml type: Map Text (Set Party)
// Used for: observers field in many Daml Finance templates
// ✅ CORRECT - empty Map is an empty array
const observers: DamlMap<string, Party[]> = [];
// ✅ CORRECT - Map with entries is array of [key, value] pairs
const observersWithData: DamlMap<string, Party[]> = [["label1", [party1, party2]]];
// ❌ WRONG - object format causes serialization errors
const observers = {}; // Will fail at runtime
This applies to all observers fields in Daml Finance templates (Account_Factory, Holding_Factory, Token_Instrument, etc.).
The JSON API query predicate parser can be fragile with nested enum/variant fields inside filters.
If a record field contains an enum/variant (e.g., InstrumentKey.holdingStandard), prefer query-safe filters:
Omit<InstrumentKey, 'holdingStandard'>When working with holdings in Daml Finance workflows:
// IMPORTANT: Many Daml workflows transfer the ENTIRE holding, regardless of amount parameter
// The amount field is often used only for calculations (e.g., share allocation),
// not for splitting the holding.
// If you need to transfer a partial amount:
// 1. First split the holding using Fungible.Split interface
// 2. Then use the split holding in the workflow
// Example: If Alice has 1000 USD and wants to deposit 500
// Option A: Deposit all 1000 (simpler)
const depositAmount = "1000.0"; // Full balance
// Option B: Split first (if Daml contract supports it)
// const splitCmd = ... // Split 1000 into 500 + 500
// Use the 500 holding for deposit, keep the other
For each Daml script, create a corresponding test file at sdk/__tests__/<script-name>.test.ts.
Every test file MUST start with a prerequisites comment:
/**
* Tests generated from: <script-name>.daml
*
* Prerequisites:
* 1. Start Canton ledger: `daml start`
* 2. Setup script runs automatically via init-script in daml.yaml
* 3. Set environment variables: USER_PARTY, OPERATOR_PARTY (from ledger output)
*
* Workflow: <brief description of what this tests>
*/
Use descriptive assertion messages that explain what's being checked:
// BAD - no context on failure
expect(holdings.length).toBeGreaterThan(0);
// GOOD - explains what should exist and why
expect(holdings.length, 'Alice should have USD holdings from setup script').toBeGreaterThan(0);
If testSetup.ts was created, import from it:
/**
* Tests generated from: <script-name>.daml
*
* Prerequisites:
* 1. Start Canton ledger: `daml start`
* 2. Setup script runs automatically via init-script in daml.yaml
* 3. Set environment variables: ALICE_PARTY, BOB_PARTY
*
* Workflow: <brief description>
*/
import { describe, test, expect, beforeAll } from 'vitest';
import { TemplateIds, Query } from '../<project-name>-api';
import type { ContractId } from '../core/primitives';
import { user, operator, userLedger, operatorLedger, configId } from './testSetup';
describe('<ScriptName> Workflow', () => {
// Verify setup completed before running tests
beforeAll(async () => {
// Use Query helper with destructuring - never pass QuerySpec directly to ledger.query()
const spec = Query.expectedContract({ /* filter */ });
const setupContracts = await operatorLedger.query(spec.templateId, spec.filter);
if (setupContracts.length === 0) {
throw new Error('Setup contracts not found. Ensure `daml start` completed successfully.');
}
});
test('complete workflow', async () => {
// Query example with destructuring
const { templateId, filter } = Query.someTemplate({ owner: user });
const contracts = await userLedger.query(templateId, filter);
// Use values from testSetup (configId, etc.) - never hardcode different values
});
});
If no Setup.daml exists, tests are self-contained:
/**
* Tests generated from: <script-name>.daml
*
* Prerequisites:
* 1. Start Canton ledger: `daml start`
* 2. Set environment variables: ALICE_PARTY, BOB_PARTY
*
* Workflow: <brief description>
*/
import { describe, test, expect, beforeAll } from 'vitest';
import { TemplateIds, Query } from '../<project-name>-api';
import type { Party, ContractId } from '../core/primitives';
import { createLedgerClient, type CantonLedgerClient } from '../ledger';
const user = process.env.USER_PARTY as Party;
const operator = process.env.OPERATOR_PARTY as Party;
if (!user) {
throw new Error('USER_PARTY environment variable required');
}
describe('<ScriptName> Workflow', () => {
let userLedger: CantonLedgerClient;
let operatorLedger: CantonLedgerClient;
beforeAll(() => {
userLedger = createLedgerClient(user);
if (operator) operatorLedger = createLedgerClient(operator);
});
test('complete workflow', async () => {
// Query contracts - ALWAYS destructure QuerySpec, never pass directly
const { templateId, filter } = Query.someTemplate({ owner: user });
const contracts = await userLedger.query(templateId, filter);
// Each test creates its own data - fully independent
});
});
If sdk/vitest.config.ts doesn't exist, create it:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['__tests__/**/*.test.ts'],
setupFiles: ['__tests__/testSetup.ts'], // Only if testSetup.ts was created
testTimeout: 30000,
// Canton often hits LOCKED_CONTRACTS if multiple files mutate shared contracts in parallel.
// Force files to run sequentially.
fileParallelism: false,
sequence: {
concurrent: false,
},
},
});
Note: Only include setupFiles if testSetup.ts was generated from Setup.daml.
Run TypeScript validation (do NOT run the tests):
cd <project-path>/sdk && npx tsc --noEmit
Fix any compilation errors.
Report when complete:
<project-name><project-path>/sdktestSetup.ts created ✅ / not needed (no Setup.daml)Then tell the user:
To run the tests:
- Start the Canton environment:
cd <project-path> && daml start- In another terminal, run:
cd <project-path>/sdk && npx vitest run