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 checksRun 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
- TDD in the AI Coding EraΒ β the full argument for why TDD matters more, not less, when AI writes your code
- Unit Testing β property-based testing with ScalaCheck
- Boundary Testing β automated edge case and attack exploration
- Emulator β in-memory Cardano node for fast iteration
- Debugging β IDE debugging and logging for validators