Advanced Features
Testing Validators with Custom Evaluators
One of the challenges we encounter when testing Cardano smart contracts is testing negative cases-scenarios where your validator should reject a transaction. TxBuilder’s enables the developer to do it via custom evaluators.
The Problem with Negative Testing
When building a transaction with scripts, TxBuilder evaluates them during build() to:
- Verify the script passes validation with the provided redeemer
- Calculate execution costs to fill out the
Redeemer
This creates a dilemma for negative testing:
// A redeemer that is known to fail with our validator
val invalidRedeemer = Data(...)
val tx = TxBuilder(env)
.spend(scriptUtxo, invalidRedeemer, validator)
.payTo(attacker, scriptUtxo.output.value)
.changeTo(changeAddress)
.build() // Throws `PlutusScriptEvaluationError` by defaultThe validator correctly rejects the invalid redeemer during local evaluation, but this means build() throws an exception and you never get a transaction to test.
You can’t submit it to verify the on-chain behavior.
The Solution: Constant Budget Evaluator
// Use constant budget evaluator for negative tests
val tx = TxBuilder.withConstMaxBudgetEvaluator(env)
.spend(scriptUtxo, invalidRedeemer, validator)
.payTo(attacker, scriptUtxo.output.value)
.changeTo(changeAddress)
.build() // The previous exception is gone, as the scripts were never actually evaluated
.sign(signer)
.transaction
// Now you can submit and verify it fails on-chain
provider.submitTransaction(tx) match {
case Left(error) =>
assert(error.contains("MyValidator logic error"))
case Right(_) =>
fail("Transaction should have been rejected!")
}This technique bridges unit testing (validator in isolation) and integration testing (full transaction flow including on-chain rejection).
Testing Pattern: Positive and Negative Cases
Here’s a complete testing pattern using both evaluators:
class ValidatorIntegrationTest extends AnyFunSuite {
val defaultEvaluator = PlutusScriptEvaluator(
cardanoInfo,
EvaluatorMode.EvaluateAndComputeCost
)
test("valid redeemer is accepted on-chain") {
val validRedeemer = Action.Reveal(correctPreimage)
// Use default evaluator - should pass locally
val tx = TxBuilder.withCustomEvaluator(env, defaultEvaluator)
.spend(collateralUtxos)
.spend(scriptUtxo, validRedeemer, validator, Set(receiverPkh))
.payTo(receiver, scriptUtxo.output.value)
.changeTo(changeAddress)
.build()
.sign(signer)
.transaction
// Should succeed on-chain
provider.submitTransaction(tx) match {
case Right(txHash) =>
println(s"Transaction accepted: ${txHash.toHex}")
case Left(error) =>
fail(s"Valid transaction was rejected: $error")
}
}
test("invalid redeemer is rejected on-chain") {
val invalidRedeemer = Action.Reveal(wrongPreimage)
// Use constant budget evaluator - bypasses local validation
val tx = TxBuilder.withConstMaxBudgetEvaluator(env)
.spend(collateralUtxos)
.spend(scriptUtxo, invalidRedeemer, validator, Set(receiverPkh))
.payTo(attacker, scriptUtxo.output.value)
.changeTo(changeAddress)
.build()
.sign(signer)
.transaction
// Should fail on-chain
provider.submitTransaction(tx) match {
case Left(error) =>
assert(error.contains("preimage hash mismatch"))
case Right(txHash) =>
fail(s"Invalid transaction was accepted: ${txHash.toHex}")
}
}
}