Testing your first Daml smart contract
Write Daml Script tests for the PaymentObligation contract.
In the previous page, you built a PaymentObligation contract. Now you will write tests that verify it behaves correctly, including cases where it should reject invalid operations.
What you will learn
- How Daml Script simulates a ledger for testing
- Creating contracts and exercising choices in tests
- Querying the ledger to verify state
- Adding contract validation with
ensure - Testing that invalid operations fail with
submitMustFail
What is Daml Script?
Daml Script is a testing framework built into Daml. It gives you an in-memory ledger that behaves exactly like a real one. No mocks, no stubs.
If you have used unit tests in Go, Python, or JavaScript, the concept is the same: you set up state, perform actions, and assert outcomes. The difference is that instead of mocking a database, Daml Script gives you a real ledger simulation.
Add the imports
To use Daml Script and assertions, add two imports at the top of your daml/Main.daml, right after the module declaration:
module Main where
import DA.Assert
import Daml.ScriptDA.Assertprovides assertion helpers likeassertEqDaml.Scriptprovides the testing framework:script,allocateParty,submit,query, and more
Script basics
Before writing tests, let's understand the building blocks:
test : Script ()
test = script do
alice <- allocateParty "Alice"
...| Syntax | Meaning |
|---|---|
Script () | The type of a test: a recipe that runs against a ledger and returns nothing |
script do | Introduces a block of ledger actions |
allocateParty | Creates a test party on the simulated ledger |
<- | Runs an action and binds the result to a variable, similar to await in JavaScript or := in Go |
The happy-path test
Add the following test below the template in daml/Main.daml:
test : Script ()
test = script do
alice <- allocateParty "Alice"
bob <- allocateParty "Bob"
obligationCid <- submit (actAs [alice, bob]) do
createCmd PaymentObligation
with
debtor = alice
creditor = bob
amount = 100.0
obligationsBeforePay <- query @PaymentObligation alice
assertEq 1 (length obligationsBeforePay)
submit alice do
exerciseCmd obligationCid Pay
obligationsAfterPay <- query @PaymentObligation alice
assertEq 0 (length obligationsAfterPay)Let's walk through each step:
Allocate parties
alice <- allocateParty "Alice"
bob <- allocateParty "Bob"Creates two test identities on the simulated ledger.
Create the contract
obligationCid <- submit (actAs [alice, bob]) do
createCmd PaymentObligation
with
debtor = alice
creditor = bob
amount = 100.0submit (actAs [alice, bob])submits a transaction on behalf of both parties. In testing,actAslets you provide the authority of multiple parties at once. In production, parties submit independently (you will learn about the propose/accept pattern in a later page).createCmdcreates a new contract from the template.obligationCidis the contract ID, a handle you use to reference this contract later.
Verify the contract exists
obligationsBeforePay <- query @PaymentObligation alice
assertEq 1 (length obligationsBeforePay)query @PaymentObligation alice returns all active PaymentObligation contracts visible to Alice. We assert there is exactly one.
Exercise the choice
submit alice do
exerciseCmd obligationCid PayAlice (the debtor) exercises the Pay choice, which archives the contract.
Verify the contract is archived
obligationsAfterPay <- query @PaymentObligation alice
assertEq 0 (length obligationsAfterPay)After payment, the contract is gone from the active ledger.
Add ensure for contract validation
Right now, nothing prevents creating a PaymentObligation with a zero or negative amount. Add an ensure clause to the template to fix this:
template PaymentObligation
with
debtor : Party
creditor : Party
amount : Decimal
where
ensure amount > 0.0
signatory debtor, creditor
nonconsuming choice Pay : ()
controller debtor
do
archive selfensure is a precondition checked every time someone tries to create this contract. If the condition is False, the ledger rejects the transaction. Think of it like validation in a constructor: the object simply cannot exist in an invalid state.
Test invalid creates with submitMustFail
Now write a test that proves the ledger rejects invalid amounts:
testInvalidAmount : Script ()
testInvalidAmount = script do
alice <- allocateParty "Alice"
bob <- allocateParty "Bob"
submitMustFail (actAs [alice, bob]) do
createCmd PaymentObligation
with
debtor = alice
creditor = bob
amount = 0.0
submitMustFail (actAs [alice, bob]) do
createCmd PaymentObligation
with
debtor = alice
creditor = bob
amount = -50.0submitMustFail succeeds when the submitted transaction is rejected by the ledger. If the transaction were to succeed, the test would fail. This is how you test that your validation rules actually work.
Test authorization failures
The controller debtor clause means only the debtor can exercise Pay. Write a test that proves the ledger enforces this:
testUnauthorizedPay : Script ()
testUnauthorizedPay = script do
alice <- allocateParty "Alice"
bob <- allocateParty "Bob"
obligationCid <- submit (actAs [alice, bob]) do
createCmd PaymentObligation
with
debtor = alice
creditor = bob
amount = 100.0
-- Bob (creditor) tries to exercise Pay, should fail
submitMustFail bob do
exerciseCmd obligationCid PayThe ledger enforces controller debtor, not your code. Even if someone tries to bypass your application and submit directly to the ledger, the authorization check still holds.
Set the init script in daml.yaml
Open daml.yaml and set the init script so dpm test knows which scripts to run:
init-script: Main:testNote that dpm test runs all top-level Script () declarations in your project, so testInvalidAmount and testUnauthorizedPay will run automatically alongside test.
Build and test
dpm build
dpm testExpected output:
daml/Main.daml:test: ok, 0 active contracts, 2 transactions.
daml/Main.daml:testInvalidAmount: ok, 0 active contracts, 2 transactions.
daml/Main.daml:testUnauthorizedPay: ok, 1 active contracts, 2 transactions.All three scripts pass. Your contract is validated, your happy path works, and your authorization rules are enforced.
Full test file
Here is the complete daml/Main.daml with the template and all three tests:
module Main where
import DA.Assert
import Daml.Script
template PaymentObligation
with
debtor : Party
creditor : Party
amount : Decimal
where
ensure amount > 0.0
signatory debtor, creditor
nonconsuming choice Pay : ()
controller debtor
do
archive self
test : Script ()
test = script do
alice <- allocateParty "Alice"
bob <- allocateParty "Bob"
obligationCid <- submit (actAs [alice, bob]) do
createCmd PaymentObligation
with
debtor = alice
creditor = bob
amount = 100.0
obligationsBeforePay <- query @PaymentObligation alice
assertEq 1 (length obligationsBeforePay)
submit alice do
exerciseCmd obligationCid Pay
obligationsAfterPay <- query @PaymentObligation alice
assertEq 0 (length obligationsAfterPay)
testInvalidAmount : Script ()
testInvalidAmount = script do
alice <- allocateParty "Alice"
bob <- allocateParty "Bob"
submitMustFail (actAs [alice, bob]) do
createCmd PaymentObligation
with
debtor = alice
creditor = bob
amount = 0.0
submitMustFail (actAs [alice, bob]) do
createCmd PaymentObligation
with
debtor = alice
creditor = bob
amount = -50.0
testUnauthorizedPay : Script ()
testUnauthorizedPay = script do
alice <- allocateParty "Alice"
bob <- allocateParty "Bob"
obligationCid <- submit (actAs [alice, bob]) do
createCmd PaymentObligation
with
debtor = alice
creditor = bob
amount = 100.0
submitMustFail bob do
exerciseCmd obligationCid PayKey takeaways
- Daml Script gives you a real in-memory ledger for testing. No mocks needed.
submitsubmits a transaction.submitMustFailasserts a transaction is rejected.ensureadds preconditions to contract creation, enforced by the ledger.querylets you inspect the active ledger state in your tests.- Authorization rules (
signatory,controller) are enforced by the ledger, not by your application code.
Next step
Coming soon: multi-step workflows with the propose/accept pattern.