This guide shows how to test a Plinth validator locally — no node, no live network. Because Plinth is a subset of Haskell, you can test it at several levels, cheapest first:
- the validator logic as ordinary Haskell;
- the compiled script evaluated on the CEK machine;
- the script’s execution budget, computed off-chain;
- full transactions against a simulated ledger.
The first three need nothing beyond the libraries your project already depends on
(plutus-tx); the fourth pulls in a separate ledger emulator. This guide assumes
you have a compiled validator (a CompiledCode) — see
The structure of a Plinth smart contract.
The example
A tiny self-contained validator with real pass/fail logic: the on-chain rule is
plain Haskell, wrapped in the untyped BuiltinData -> BuiltinUnit form the ledger
runs, then frozen into a value with PlutusTx.compile (the same idiom as the
project template):
{-# LANGUAGE TemplateHaskell #-}
module MyValidator where
import PlutusTx
import PlutusTx.Prelude qualified as PlutusTx
-- on-chain rule: ordinary Haskell, in the Plinth subset
mustEqual :: Integer -> Bool
mustEqual redeemer = redeemer PlutusTx.== 42
-- the untyped wrapper the ledger actually runs
untypedValidator :: BuiltinData -> PlutusTx.BuiltinUnit
untypedValidator arg =
PlutusTx.check (mustEqual (PlutusTx.unsafeFromBuiltinData arg))
validatorCode :: CompiledCode (BuiltinData -> PlutusTx.BuiltinUnit)
validatorCode = $$(PlutusTx.compile [|| untypedValidator ||])
PlutusTx.check turns the Bool into a BuiltinUnit: a True result evaluates
to unit (the script succeeds), a False calls error (the script fails).
A real validator decodes a full ScriptContext from that single BuiltinData
argument rather than a bare Integer; here we keep the argument trivial so the
tests stay self-contained. See
The Plinth contract language for the
real decoding.
Test the logic as ordinary Haskell
mustEqual is just a Haskell function, so the fastest test exercises it directly
with tasty — no compilation to
UPLC at all:
import Test.Tasty
import Test.Tasty.HUnit
import MyValidator (mustEqual)
logicTests :: TestTree
logicTests = testGroup "logic"
[ testCase "accepts 42" $ mustEqual 42 @?= True
, testCase "rejects 0" $ mustEqual 0 @?= False
]
This is the tightest inner loop, and it is where property tests (QuickCheck) belong. Be clear about what it checks, though: the Haskell meaning of your rule, not the compiled on-chain program. For that, evaluate the compiled code.
Evaluate the compiled script
To run the actual on-chain program, apply an argument to validatorCode and
evaluate it on the CEK machine. PlutusTx.unsafeApplyCode and liftCodeDef bake
a BuiltinData argument into the compiled code (the same application machinery
used to apply parameters), and
evaluateCompiledCode from PlutusTx.Eval runs it:
import PlutusTx (unsafeApplyCode, liftCodeDef, toBuiltinData)
import PlutusTx.Eval (EvalResult (..), evaluateCompiledCode)
import Data.Either (isLeft, isRight)
runWith :: Integer -> EvalResult
runWith n =
evaluateCompiledCode
(validatorCode `unsafeApplyCode` liftCodeDef (toBuiltinData n))
evalTests :: TestTree
evalTests = testGroup "compiled script"
[ testCase "accepts 42" $
assertBool "should succeed" (isRight (evalResult (runWith 42)))
, testCase "rejects 0" $
assertBool "should fail" (isLeft (evalResult (runWith 0)))
]
EvalResult carries everything the run produced:
data EvalResult = EvalResult
{ evalResult :: Either CekEvaluationException (NTerm DefaultUni DefaultFun ())
, evalResultBudget :: ExBudget
, evalResultTraces :: [Text]
}
A Right means the script succeeded; a Left means it errored (a failed
validation). displayEvalResult :: EvalResult -> Text renders the result, budget,
and traces together — handy when a test fails. Evaluating the compiled code
this way also catches anything that type-checks as Haskell but does not survive
compilation to the Plinth subset.
The plutus-tx:plutus-tx-testlib sublibrary wraps the success/failure check in
ready-made assertions (assertEvaluatesSuccessfully, assertEvaluatesWithError)
if you prefer them to pattern-matching on evalResult.
Measure the execution budget
On-chain, every CEK step costs from a budget of
execution units, and because evaluation
is deterministic the cost can be computed off-chain in advance. evaluateCompiledCode
already returns it:
import PlutusCore.Evaluation.Machine.ExBudget (ExBudget (..))
budgetOf42 :: ExBudget
budgetOf42 = evalResultBudget (runWith 42)
-- exBudgetCPU budgetOf42, exBudgetMemory budgetOf42
Asserting on the budget catches cost regressions — a refactor that
quietly makes a script more expensive. The plutus-tx:plutus-tx-testlib
sublibrary provides goldenBudget for exactly this: it records the budget in a
golden file and fails when the number drifts, using the same tasty/golden
mechanism described under
Test and benchmark.
When you need the figures exactly as the ledger computes them (rather than the
default CEK parameters evaluateCompiledCode uses), serialise the script with
serialiseCompiledCode and run it through
PlutusLedgerApi.Common.evaluateScriptCounting. That path is more faithful but
more involved: it wants the serialised script decoded with deserialiseScript,
an EvaluationContext built from cost-model parameters, and a target protocol
version. For day-to-day testing, evaluateCompiledCode is enough.
Simulate at the ledger level
Script-level tests answer “given this argument, does the validator pass and what does it cost?” They do not model transactions: multiple UTxOs, datums and redeemers attached to inputs, fees, minting, validity intervals, or several validators interacting. For that you need an emulated ledger.
The maintained tool is clb (a Cardano
Ledger Backend / “emulator”), which runs the real cardano-ledger rules
in-process: you build and submit transactions against a mock chain, advance slots,
and assert on the outcome — no node required. A test reads roughly as: start
an empty ledger, pay funds into your script address, then submit a spending
transaction with a redeemer and assert it validates (or is rejected). See the
clb documentation for the current API.
The old Plutus Application Framework is deprecated. Many older tutorials test
contracts with the off-chain Contract monad, EmulatorTrace, and
ContractModel. That framework is discontinued — do not use it for new
work. Today the split is: on-chain logic in Plinth (tested as above), off-chain
transaction building with cardano-api, and ledger simulation with clb.
clb is a separate, heavier dependency than the plutus-tx-only tests above. As
with the vendored crypto libraries, confirm it
resolves against your pinned library set, and treat this setup as for learning and
experimentation, not production.
See also
- Lock and unlock funds on a local chain — deploy the same kind of “must equal N” rule on a local devnet and watch a real node accept and reject it.
- Lock and unlock funds from GHCi
— the off-chain
cardano-apiside of the modern stack, hands-on. - The structure of a Plinth smart contract
— where the off-chain test code sits, and applying parameters with
unsafeApplyCode. - How smart contracts run: Plutus Core and the CEK machine — why off-chain evaluation and costing are faithful to the chain.
- Build plinthc from source — the tasty/golden test mechanism the compiler’s own suites use.
- Generate a blueprint — the
other main thing off-chain code does with a
CompiledCode. - Plinth user guide — the complete reference for writing Plinth.