Testing
Writing smart contracts and operations over them go hand in hand with testing them.
Tests are also an excellent way to check your smart contracts
instead of building transactions using cardano-cli
and submitting them to a local node.
Levels of testing
Now that we have written our smart contracts and defined the required operations, let's see whether they work as expected. When it comes to testing dApps there are plenty of techniques and approaches. Let's focus on levels at which we can perform testing:
-
Testing of UPLC functions. You may want to verify that individual functions your validators consist of, indeed hold some properties. This is useful if your on-chain logic is convoluted and involves complex computations. This level is tightly coupled to the language you use to build your smart contracts so you should consult the respective documentation.
-
Testing of individual contracts (script level). You might want to verify that the on-chain contracts you developed behave as expected in isolation just by calling them with hand-constructed arguments (like script context) since they are just functions after all and checking the results. Here again, you can use language-specific tools. But in case your contracts are already compiled down to UPLC code, you will need a special testing framework to do that. Atlas currently doesn't provide such a thing, but there exist some projects of help, namely liqwid-context-builder (opens in a new tab) from Liqwid's Libs mono repo. It allows one to easily construct various transaction contexts and verify a result that a particular script evaluates.
-
Testing of operations (transactions). At this level, one can execute whole operations (transactions) an application provides and verifies that they a) can run through and b) confirm that some conditions we are interested in are held. A nice thing to know about Atlas is that it allows you to reuse the code for operations you created in the previous step, "Operations over Contract". This is the level of testing we discuss in this section. You can also make a distinction between testing individual transactions and testing a flow of transactions, but in practice, it proves to be hard to prepare a hand-made environment for most intermediary transactions to be run in without running transactions that precede, so mostly it boils down to test the whole transaction flow.
Overview of unified testing in Atlas
Testing of whole operations (transactions) requires a Cardano ledger to evaluate them and keep the state. There are two interchangeable options available in Atlas. We will call them ledger backends throughout the rest of the section:
- CLB (opens in a new tab) emulator (a modern replacement for deprecated PSM (opens in a new tab) library) is the preferable way to test operations. It's built around the pure Cardano ledger without the use of any network or consensus bits and offers incredibly high speed with a tiny memory footprint, but with some functional limitations. You can easily spin up a fresh emulator ledger for every test case, which makes running tests in isolation a trivial task.
- Cardano private test network option (privnet for short) provides a more realistic environment.
It is a cluster of three
cardano-node
instances that potentially could support all Cardano features, including staking and governance. The main downside of a privnet is that testing time is significantly bigger and since spawning a testnet is a time-consuming operation, it's practically impossible to have a fresh network for every test case but rather we prefer single testnet for the whole test suite run.
Bear in mind that the behavior of the CLB emulator and a Cardano private network differ.
Not all features are supported in the emulator, and some notions, e.g. blocks and time is not represented enough in the emulator to carry out all tests.
Fortunately, you can switch easily between two backends with unified testing.
Now that we know what the two backends available are, let's spend some time understanding what unified testing in Atlas offers. If we dissect a test case within a test suite we can identify the following things involved:
- An application under testing, including:
- Smart contracts
- Operations, which usually prepare transactions (or their skeletons)
- Definition of a test case, including:
- A prelude sequence of actions that prepare the state for a particular operation to be ready to be executed
- A condition that wraps an operation we are testing in the test case and expresses checks we want to verify
- Test suite runtime:
- A ledger backend
- Some code to run a test case against a backend
The idea of unified testing pursues the goal of making items under (1) and (2) reusable across different (currently, the two mentioned above) ledger backends.
Let's run through an example test suite to get the idea and figure out its details.
Testing placing a bet
You can find the entire code for this example here (opens in a new tab).
Mind you we are using tasty
(opens in a new tab) to write tests.
Our objective here would be to write test cases for one of two main operations
from the bet-ref
example - namely for placing bet operation.
The test-suite given can be executed with cabal run betref-tests
command. Do note that, in the initial setup, you would notice an initial test failure, this is due to a quirk in the workings of cardano-testnet
(opens in a new tab) when spinning up private testnet and should be ignored. Note that before running this command, make sure that you have required version of cardano-cli
& cardano-node
installed and available inside your path, use cabal install --package-env=$(pwd) --overwrite-policy=always cardano-cli cardano-node
from the root of the atlas-examples
repository to achieve that.
Testing environment
Unified testing hides implementation details specific to ledger backends under
a layer of abstraction. Regardless of the backend we ultimately choose there is
TestInfo
datatype that provides access to user wallets among other things.
A wallet is represented by User
datatype that holds signing keys, address,
and collateral to use:
data TestInfo = TestInfo
{ testGoldAsset :: !GYAssetClass
, testIronAsset :: !GYAssetClass
, testWallets :: !Wallets }
data Wallets = Wallets
{ w1 :: !User
... more eighth wallets ...
}
data User = User
{ userPaymentSKey :: !GYPaymentSigningKey
, userStakeSKey :: !(Maybe GYStakeSigningKey)
, userAddresses :: !(NonEmpty GYAddress)
, userChangeAddress :: !GYAddress
, userCollateral :: Maybe UserCollateral
}
Every wallet in Wallets
will be funded with an initial set of assets:
- Million ADA.
- Million
fakeGold
. - Million
fakeIron
.
fakeGold
and fakeIron
are testing Cardano native assets that might be useful
(though you can ignore them safely).
In previous sections, we got acquainted with several monads available in Atlas
namely GYTxQueryMonad
and GYTxMonad
that allowed us to query the blockchain
and to construct transactions. Now it's time to introduce another monad that
facilitates testing - GYTxGameMonad
. Its most important action is called
asUser
and allows to run computations in GYTxMonad
using a particular
wallet (signature is slightly simplified):
asUser :: User -> GYTxMonad a -> m a
We conventionally call actions in GYTxGameMonad
"runners" since they run some
operations by submitting them under a particular user. We will see examples soon.
Then we have two functions that allow us to make a test case for a particular backend out of a runner (the signature again is slightly modified here):
mkTestFor :: TestName -> (TestInfo -> GYTxGameMonad a) -> TestTree
mkPrivnetTestFor :: Setup -> TestName -> (TestInfo -> GYTxGameMonad a) -> TestTree
Both functions take a name for a test case and a continuation function of type
TestInfo -> GYTxGameMonad a
. Then they internally generate the environment to
do the job. The difference is that mkPrivnetTestFor
also takes a value of type
Setup
that contains information about an instance of a private network.
This highlights an important distinction between them:
mkTestFor
spawns a new instance of the emulator on every call - that way all test cases will be given with a fresh (new) blockchain ledger state having the above balances to those 9 wallets.mkPrivnetTestFor
is supposed to be run inside a helper functionwithPrivnet
which spins up a private testnet according to the configuration provided and calls a series of test cases (i.e. the whole test suite) against it.
Let's use these bits to build various test cases for operations
within bet-ref
example.
Defining runner for bet placing operation
Let's start with the runner to test placeBet
operation. We won't see anything
new here. It just uses asUser action
we just learned to run the operation.
We need values of all arguments that our operation takes. We cannot know them, so
we just take all of them as arguments to the runner itself, except the address as
it can be obtained using ownAddresses
(opens in a new tab) function. This function gives back all
the addresses of the wallet (User
) that we provide to asUser
.
Once we get the result of the operation, we can build, sign, and submit a transaction.
Here, again, the wallet we specified to asUser
action is used to sign it
(though you can add additional signatures manually).
Let's take a look at the code (you can find the full version in the sources,
here it's slightly redacted for simplicity).
-- | Run to call the `placeBet` operation.
runPlaceBet
:: GYTxGameMonad m -- We write runners in `GYTxGameMonad` monad
=> GYTxOutRef -- ^ Script output reference
-> BetRefParams -- ^ Parameters
-> OracleAnswerDatum -- ^ Bet guess
-> GYValue -- ^ Bet value
-> Maybe GYTxOutRef -- ^ Ref output with existing bets
-> User -- ^ User that plays bet
-> m GYTxId
runPlaceBet refScript brp guess bet mPrevBets user =
asUser user $ do
-- Get the address
addr <- maybeM (throwAppError $ someBackendError "No own addresses")
pure $ listToMaybe <$> ownAddresses
-- Call the operation
skeleton <- placeBet refScript brp guess bet addr mPrevBets
-- Submit the transaction
buildTxBody skeleton >>= signAndSubmitConfirmed
Additional runners
Let's take a look at the arguments our runner takes:
runPlaceBet
:: GYTxOutRef -- ^ Script output reference
-> BetRefParams -- ^ Parameters
-> OracleAnswerDatum -- ^ Bet guess
-> GYValue -- ^ Bet value
-> Maybe GYTxOutRef -- ^ Ref output with existing bets
-> User -- ^ User that plays bet
-> m GYTxId
Bet guess, value, existing bets, and the user that plays a bet pertain to the
test case, so we should somehow pick or generate values for them
(we will just use some sensible values in this example).
But the first argument of type GYTxOutRef
can't seem to be easy to know.
It's the transaction output reference (transaction hash and output index number)
that should contain a reference script on the ledger. To create it we need to build
and submit another transaction.
So we need another runner that applies the script to arguments, builds a transaction
that will deploy it, sign and submits it. Let's pretend we don't have such an operation
to build a transaction to deploy within our application but what we have is
a function that makes the script. Notice the use GYTxQueryMonad
here since all that function
needs is only to figure out the current slot in the ledger to make the calculations:
-- | Queries the current slot, calculates the parameters, and builds
-- a script that is ready to be deployed.
mkScript
:: GYTxQueryMonad m
=> Integer -- ^ How many slots betting should be open
-> Integer -- ^ How many slots should pass before oracle reveals answer
-> GYPubKeyHash -- ^ Oracle PKH
-> GYValue -- ^ Bet step value
-> m (BetRefParams, GYScript PlutusV2)
mkScript betUntil betReveal oraclePkh betStep = do
... [the body is omitted] ...
It takes several parameters that define the process of betting,
does some calculations, and gives us back all the parameters of type BetRefParams
and also GYScript PlutusV2
which is the script we can deploy. So in this case
we have to build the transaction directly within the runner. Fortunately,
we can use addRefScript
function that does exactly what we need:
-- | Runner to build and submit a transaction that deploys the reference script.
runDeployScript
:: GYTxGameMonad m
=> Integer -- ^ Bet Until slot
-> Integer -- ^ Bet Reveal slot
-> GYValue -- ^ Bet step value
-> Wallets
-> m (BetRefParams, GYTxOutRef)
runDeployScript betUntil betReveal betStep ws = do
(params, script) <- mkScript betUntil betReveal (userPkh $ oracle ws) betStep
asUser (admin ws) $ do
let sAddr = userAddr (holder ws)
refScript <- addRefScript sAddr script
pure (params, refScript)
This runner doesn't call any application operations, but now we can run it
before our main runner runPlaceBet
since it returns both BetRefParams
and GYTxOutRef
we need to call the main runner and ultimately placeBet
operation.
Place first bet test
Now we are finally ready to write our first test.
It should first use runDeployScript
to calculate and deploy the script
and then run the main runner runPlaceBet
checking that a transaction goes through and the balance of the user
that submits it gets smaller accordingly
(of course, we could imagine other checks as well).
-- | Test for placing the first bet.
firstBetTest
:: GYTxGameMonad m
=> Integer
-> Integer
-> GYValue
-> OracleAnswerDatum
-> GYValue
-> TestInfo
-> m ()
firstBetTest betUntil betReveal betStep dat bet (testWallets -> ws@Wallets{w1}) = do
(brp, refScript) <- runDeployScript betUntil betReveal betStep ws
withWalletBalancesCheckSimple [w1 := valueNegate bet] $ do
void $ runPlaceBet refScript brp dat bet Nothing w1
The code almost verbatim repeats what we just said using the function withWalletBalancesCheckSimple
(opens in a new tab)1.
It allows checking the change of wallet's balance with no caring about transaction and storage fees
(the latter is also known as minimal ADA - the number of coins that should accompany Cardano native tokens).
This convenience is possible because Atlas manages its own record of
all fees that were spent over the course of tests, so they can be accounted
automatically. This way we just provide the expected delta in balance by negating
bet's value. More precisely this function takes a list of tuples2 where the first element
of the tuple is the wallet and the second element denotes the difference in
the wallet's value which we expect after the execution of the operation
defined inside its do
block.
Here we want the balance of wallet 1 (which is the one calling this operation)
to decrease with the bet amount and also the fees.
We specify all parameters when defining a test case:
placeBetTests :: TestTree
placeBetTests = testGroup "Place Bet (Emulator)"
[ mkTestFor "Balance checks after placing first bet" firstBetTest'
]
firstBetTest' :: GYTxGameMonad m => TestInfo -> m ()
firstBetTest' = firstBetTest
40
100
(valueFromLovelace 200_000_000)
(OracleAnswerDatum 3)
(valueFromLovelace 20_000_000)
Multiple bets test
Now let's write a slightly more involved test. This time we want to make sure that many bets placed in a raw using different wallets can be submitted and the balances of the wallets change accordingly.
Let's start with defining some additional type aliases to save up space:
-- This is an alias for fields of `Wallet` datatype
type Wallet = Wallets -> User
-- This type represent a bet made by a wallet
type Bet = (Wallet, OracleAnswerDatum, GYValue)
Now we want to write a function mkMultipleBetsTest
, we can pass a list
of concrete bets to build a test case:
multipleBetsTest :: GYTxGameMonad m => TestInfo -> m ()
multipleBetsTest TestInfo{..} = mkMultipleBetsTest
400 1_000 (valueFromLovelace 10_000_000) -- game params
-- list of bets
[ (w1, OracleAnswerDatum 1, valueFromLovelace 10_000_000)
, (w2, OracleAnswerDatum 2, valueFromLovelace 20_000_000)
, (w3, OracleAnswerDatum 3, valueFromLovelace 30_000_000)
, (w2, OracleAnswerDatum 4, valueFromLovelace 50_000_000)
, (w4, OracleAnswerDatum 5, valueFromLovelace 65_000_000
<> valueSingleton testGoldAsset 1_000)
]
testWallets
As usual, let's commence with a runner. We already have a runner for placing a single bet so we can reuse it:
-- | Runner for multiple bets.
runMultipleBets
:: GYTxGameMonad m
=> BetRefParams
-> GYTxOutRef -- ^ Reference script
-> [Bet]
-> Wallets
-> m ()
runMultipleBets brp refScript bets ws = go bets True
where
go [] _ = return ()
go ((getWallet, dat, bet) : remBets) isFirst = do
if isFirst then do
gyLogInfo' "" "placing the first bet"
void $ runPlaceBet refScript brp dat bet Nothing (getWallet ws)
go remBets False
else do
gyLogInfo' "" "placing a next bet"
-- need to get previous bet utxo
betRefAddr <- betRefAddress brp
GYUTxO{utxoRef} <- head . utxosToList <$> utxosAtAddress betRefAddr Nothing
gyLogDebug' "" $ printf "previous bet utxo: %s" utxoRef
void $ runPlaceBet refScript brp dat bet (Just utxoRef) (getWallet ws)
go remBets False
The runner recursively traverses the list of bets, calling runPlaceBet
on every
element, indicating whether it is the first element or not using the last
parameter of go
. Once we have the runner at hand we can write the test. We are
going to skip some details to handle balances, you can find the full version in
the source code.
-- | Makes a test case for placing multiple bets.
mkMultipleBetsTest
:: GYTxGameMonad m
=> Integer -- ^ Number of slots for betting
-> Integer -- ^ Number of slots for revealing
-> GYValue -- ^ Bet step
-> [Bet] -- ^ List denoting the bets
-> Wallets -- ^ Wallets available
-> m ()
mkMultipleBetsTest betUntil betReveal betStep bets ws = do
-- Deploy script
(brp, refScript) <- runDeployScript betUntil betReveal betStep ws
-- Get the balance
balanceBefore <- getBalance
gyLogDebug' "" $ printf "balanceBeforeAllTheseOps: %s" (mconcat balanceBefore)
-- Run operations
runMultipleBets brp refScript bets ws
-- Get the balance again
balanceAfter <- getBalance
gyLogDebug' "" $ printf "balanceAfterAllTheseOps: %s" (mconcat balanceAfter)
-- Check the difference
verify $ zip3
walletsAndBets
balanceBefore
balanceAfter
where
... some balance-related functions are omitted here ...
-- | Function to verify that the wallet indeed lost by /roughly/ the bet amount.
-- We say /roughly/ as fees is assumed to be within (0, 1 ada].
verify :: GYTxGameMonad m => [((User, GYValue), GYValue, GYValue)] -> m ()
verify [] = return ()
verify (((wallet, diff), vBefore, vAfter) : xs) =
let vAfterWithoutFees = vBefore <> diff
(expectedAdaWithoutFees, expectedOtherAssets) = valueSplitAda vAfterWithoutFees
(actualAda, actualOtherAssets) = valueSplitAda vAfter
threshold = 1_000_000 -- 1 ada
in
if expectedOtherAssets == actualOtherAssets
&& actualAda < expectedAdaWithoutFees
&& expectedAdaWithoutFees - threshold <= actualAda
then verify xs
else
throwAppError . someBackendError . T.pack $
printf "For wallet %s expected value (without fees) %s but actual is %s"
(show $ userAddr wallet)
(show vAfterWithoutFees)
(show vAfter)
Writing negative tests
But sometimes we want a test to fail! What happens if the newly placed bet is
not more than at least brpBetStep
amount? What happens if the transaction
skeleton is somewhat wrong, say we didn't put mustBeSignedBy
? What if someone
tries to place a bet after brpBetUntil
? What if...
Let's add another test:
failingMultipleBetsTest :: GYTxGameMonad m => TestInfo -> m ()
failingMultipleBetsTest TestInfo{..} = mkMultipleBetsTest
400 1_000 (valueFromLovelace 10_000_000)
[ (w1, OracleAnswerDatum 1, valueFromLovelace 10_000_000)
, (w2, OracleAnswerDatum 2, valueFromLovelace 20_000_000)
, (w3, OracleAnswerDatum 3, valueFromLovelace 30_000_000)
, (w2, OracleAnswerDatum 4, valueFromLovelace 50_000_000)
, (w4, OracleAnswerDatum 5, valueFromLovelace 55_000_000
<> valueSingleton testGoldAsset 1_000)
]
testWallets
If we run it we will get an error, since the last bet doesn't respect the minimal
bet step which is 10_000_000
lovelaces. Well for all such cases, we can assert
that a given trace must fail. It's done slightly differently for the emulator and
a private test network. For the emulator we just use mustFail
:
placeBetTestsClb :: TestTree
placeBetTestsClb = testGroup "Place bet"
[ mkTestFor "Multiple bets - to small step" $ mustFail . failingMultipleBetsTest
]
For a private testnet it's more wordy:
placeBetTests :: Setup -> TestTree
placeBetTests setup = testGroup "Place bet"
[ mkPrivnetTestFor' "Multiple bets - too small step" GYDebug setup $
handleError
(\case
GYBuildTxException GYBuildTxBodyErrorAutoBalance {} -> pure ()
e -> throwError e
)
. failingMultipleBetsTest
]
This section concludes our journey to testing dApps with Atlas.
Footnotes
-
If you were to have fine-grained control over balance change, use
withWalletBalancesCheck
(opens in a new tab) instead. ↩ -
To convey the message better, we have a defined
(:=)
(opens in a new tab) pattern synonym:↩pattern (:=) :: x -> y -> (x, y) pattern (:=) x y = (x, y)