Skip to Content
Scalus Club is now open! Join us to get an early access to new features πŸŽ‰
DocumentationTestingTDD & ATDD Workflow

TDD & ATDD Workflow

AI coding assistants have made writing code dramatically faster. But the bottleneck was never typing speed β€” it was knowing what to build and verifying it’s correct. When AI writes most of the code, tests shift from verification tool to specification language: the test defines intent, the implementation is disposable.

This applies doubly to smart contracts, where bugs are expensive and irreversible. On Cardano, a deployed validator cannot be patched β€” if it’s wrong, funds may be locked or stolen. Tests are your last line of defense before mainnet.

The workflow below works whether you write code manually or use AI assistants. The key insight: invest time in test quality, not implementation quality. A precise test catches bad implementations automatically β€” whether written by a human or generated by AI.

TDD: Test-Driven Development

Write a failing test first, then make it pass. For smart contracts, this means defining validator behavior before writing validator logic.

Write a failing test

Define what your validator should accept and reject:

class AuctionValidatorTest extends AnyFunSuite with ScalusTest { test("reject bid below current highest") { val state = AuctionState(currentBid = 100_000_000, deadline = 1000) val lowBid = 50_000_000 val result = Try(evaluateValidator( datum = state.toData, redeemer = BidRedeemer(lowBid).toData, context = buildContext(bidAmount = lowBid, slot = 500) )) assert(result.isFailure, "Validator should reject bids below current highest") } test("accept bid above current highest") { val state = AuctionState(currentBid = 100_000_000, deadline = 1000) val highBid = 150_000_000 val result = Try(evaluateValidator( datum = state.toData, redeemer = BidRedeemer(highBid).toData, context = buildContext(bidAmount = highBid, slot = 500) )) assert(result.isSuccess, "Validator should accept bids above current highest") } }

These tests fail because the validator doesn’t exist yet. That’s the point β€” the test is the specification.

Implement the validator

Now write (or let AI generate) the validator that makes the tests pass:

@Compile object AuctionValidator extends Validator: inline override def spend( datum: Option[AuctionDatum], redeemer: AuctionRedeemer, ctx: ScriptContext ): Unit = val d = datum.getOrFail("no datum") redeemer match case BidRedeemer(amount) => require(amount > d.currentBid, "Bid must exceed current highest") require(ctx.txInfo.validRange.from < d.deadline, "Auction has ended") // ... output checks

Run tests, iterate

If tests pass, you’re done. If they fail, fix the implementation β€” not the tests. The tests encode your intent; the implementation serves the tests.

Property-based TDD

For stronger guarantees, express properties instead of individual examples:

test("any bid at or below current highest is rejected") { forAll(Gen.choose(0L, state.currentBid)) { lowBid => val result = Try(evaluateValidator( datum = state.toData, redeemer = BidRedeemer(lowBid).toData, context = buildContext(bidAmount = lowBid, slot = 500) )) assert(result.isFailure) } }

ScalaCheck generates hundreds of random values within the range, catching edge cases that hand-picked examples miss. See Unit Testing for the full guide.

ATDD: Acceptance Test-Driven Development

TDD works at the validator level: does this function behave correctly? ATDD works at the transaction level: does this contract do what stakeholders need in a real Cardano transaction?

With Scalus, ATDD means writing full transaction scenarios against the Emulator before building the contract logic.

Define acceptance criteria as scenarios

Before writing any validator code, define what a complete interaction looks like:

test("full auction lifecycle") { val emulator = Emulator.withAddresses(Seq(Alice.address, Bob.address)) // 1. Alice creates auction with 100 ADA starting bid val createTx = TxBuilder(emulator.cardanoInfo) .payToScript(auctionAddress, AuctionDatum(100_000_000, deadline).toData, Value.ada(100)) .complete(emulator, Alice.address).await() .sign(Alice.signer).transaction emulator.submit(createTx).await() // 2. Bob bids 150 ADA β€” should succeed val bidTx = TxBuilder(emulator.cardanoInfo) .spend(auctionUtxo, BidRedeemer(150_000_000).toData, auctionScript) .payToScript(auctionAddress, updatedDatum(150_000_000, Bob.pkh).toData, Value.ada(150)) .payTo(Alice.address, Value.ada(100)) // refund previous bidder .complete(emulator, Bob.address).await() .sign(Bob.signer).transaction emulator.submit(bidTx).await() // 3. Verify final state val finalUtxo = emulator.findUtxos(auctionAddress).head val finalDatum = AuctionDatum.fromData(finalUtxo.output.requireInlineDatum) assert(finalDatum.currentBid == 150_000_000) assert(finalDatum.topBidder == Bob.pkh) }

This scenario fails initially β€” the validator and transaction builder helpers don’t exist yet. But it captures the full acceptance criteria: create, bid, refund, state transition.

Implement to satisfy scenarios

Build the validator, datum types, and transaction builders until the acceptance scenarios pass. Each passing scenario is a verified feature.

Add boundary testing

Once the happy path works, use Boundary Testing to automatically explore edge cases and attack vectors:

test("auction resists attack variations") { val commands = ContractScalaCheckCommands(emulator, AuctionStep) { (reader, state) => Future.successful( Prop(state.currentBid >= 0) :| "bid non-negative" && Prop(state.topBidder != PubKeyHash.empty) :| "has bidder" ) } commands.property().check() }

The boundary testing toolkit generates steal attempts, corrupted datums, double satisfaction attacks, and boundary value variations β€” all derived from your contract’s structure.

The Workflow

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 1. Write acceptance test (ATDD) β”‚ β”‚ Full transaction scenario against Emulator β”‚ β”‚ β†’ defines WHAT the contract should do β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 2. Write unit tests (TDD) β”‚ β”‚ Validator behavior for each redeemer action β”‚ β”‚ β†’ defines HOW the validator should behave β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 3. Implement validator β”‚ β”‚ Write code (or let AI generate it) β”‚ β”‚ β†’ iterate until all tests pass β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 4. Add boundary & attack testing β”‚ β”‚ Boundary testing explores edge cases β”‚ β”‚ β†’ catches what you didn't think of β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 5. Deploy with confidence β”‚ β”‚ Tests β†’ Emulator β†’ Devnet β†’ Testnet β†’ Main β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Further Reading

Last updated on