Unit Tests
Writing smart contracts & writing tests go hand in hand. Tests are also an excellent way to conveniently check working of our smart contract instead of building transactions using cardano-cli
and submitting them to local node.
Now that we have written our smart contract and defined the required operations over it, let's see whether its working as expected.
Our test suite is a wrapper around Plutus simple model (opens in a new tab)1 which is created by MLabs.
MLabs is working on an evolution of PSM, namely CLB (opens in a new tab) which is intended to work exclusively with Atlas. Thus, we have deprecated support of PSM and would soon document overhaul of this test suite. If you would like to avoid using PSM and wait till CLB is ready, you can skip to next section, namely, Integration Tests.
Currently our PSM wrapper does not support operations related to staking, namely, stake key registration, delegation, de-registration and rewards withdrawal.
Why not just use "Plutus simple model" instead of the wrapper?
-
Reusability: Well firstly to maintain compatibility with our toolchain. For instance, our operations were making use of
GYTxQueryMonad
monad and thus to be able to reuse those same operations we would need to define an instance for it. -
Additional checks: But secondly and more importantly, plutus simple model lacks some basic checks, for instance:
- Whether a UTxO satisfies minimum ada requirement.
- Transaction fees requirement.
- Transaction signatures requirement, etc.
We already handle these cases using our transaction building machinery and thus tests written here reflect the actual environment more.
For this guide there should be no need to go over the plutus simple model documentation but this doesn't mean that one shouldn't. It's very lucid and takes few minutes to cover and can be accessed by cloning their (opens in a new tab) repository, entering the docs
folder and running mdbook serve --open
.
Unit tests for placing a bet operation
Entire code file for tests pertaining to this operation is available here (opens in a new tab). Note that we are using tasty
(opens in a new tab) to write our tests and a file calling these individual unit tests is here (opens in a new tab).
Our objective here would be to write tests for each of our operation, hence the name "unit tests". Though one may write other sort of tests as well, including property based ones.
Defining Run for placing a bet operation
Before any jibber-jabber, let's see the code so that we know it isn't as complex as it might seem:
placeBetRun :: GYTxOutRef -> BetRefParams -> OracleAnswerDatum -> GYValue -> Maybe GYTxOutRef -> GYTxMonadRun GYTxId
placeBetRun refScript brp guess bet mPreviousBetsUtxoRef = do
addr <- ownAddress
skeleton <- placeBet refScript brp guess bet addr mPreviousBetsUtxoRef
sendSkeleton skeleton
Why do we call it "run"? Well if you have gone over the documentation of plutus simple model (opens in a new tab), you'll know that they have this "Run" monad where actually most of the test code gets executed and we have wrapper around this type, which we call GYTxMonadRun
(opens in a new tab). But as an end developer, there is no need to understand about it.
Also our GYTxMonadRun
has an instance of GYTxQueryMonad
.
The idea here is that any tests we do related to performing the bet operation would need to call the placeBet
function which we have defined before. Therefore we have defined a run to call this function. Our placeBetRun
function takes all those parameters which are required by placeBet
function, except the address as that we are able to get using ownAddress
function2. ownAddress
(opens in a new tab) function is defined in GeniusYield.TxBuilder.Run
(opens in a new tab) module where actually most of the code related to our wrapper lives and it gives the address of the wallet running this run as we'll shortly see.
Lastly sendSkeleton
can be understood as submitting the transaction. It will update the mock ledger state and return the transaction id for the submitted transaction. Note that it does raise an exception in case it fails to submit the transaction.
Understanding testRun
Before we see a trace calling the run we just defined, notice that in our testGroup
, we have the first test written as:
testRun "Balance checks after placing first bet" $ firstBetTrace (OracleAnswerDatum 3) (valueFromLovelace 20_000_000) 0_182_793
Now what is this testRun
(opens in a new tab)?
This function takes a string to represent the name of the test and a continuation function (of type Wallets -> Run a
) and then internally generates wallets to give to our continuation function.
The type Wallets
is defined as:
data Wallets = Wallets
{ w1 :: !Wallet
, w2 :: !Wallet
, w3 :: !Wallet
, w4 :: !Wallet
, w5 :: !Wallet
, w6 :: !Wallet
, w7 :: !Wallet
, w8 :: !Wallet
, w9 :: !Wallet
} deriving (Show, Eq, Ord)
where Wallet
is:
data Wallet = Wallet
{ walletPaymentSigningKey :: !GYPaymentSigningKey
, walletNetworkId :: !GYNetworkId
, walletName :: !String
}
deriving (Show, Eq, Ord)
Thus our testRun
function, generates these 9 wallets where each wallet is having the following three assets:
- Million ada.
- Million
fakeGold
. - Million
fakeIron
.
where fakeGold
and fakeIron
are our two non-native assets.
Each call to testRun
(as you can see - we have multiple tests, all beginning with testRun
) runs the given test with a fresh (new) blockchain ledger state having given the above balances to those 9 wallets.
In our case, "Balance checks after placing first bet"
is the name of the test and firstBetTrace (OracleAnswerDatum 3) (valueFromLovelace 20_000_000) 0_182_793
is our continuation function.
Defining a trace to call placeBetRun
Now let's see the definition firstBetTrace
we briefly encountered above:
-- | Trace for placing the first bet.
firstBetTrace :: OracleAnswerDatum -- ^ Guess
-> GYValue -- ^ Bet
-> Wallets -> Run () -- Our continuation function
firstBetTrace dat bet ws@Wallets{..} = do
-- First step: Get the required parameters for initializing our parameterized script and add the corresponding reference script
(brp, refScript) <- computeParamsAndAddRefScript 40 100 (valueFromLovelace 200_000_000) ws
void $ runWallet w1 $ do -- following operations are ran by first wallet, `w1`
-- Second step: Perform the actual run.
withWalletBalancesCheckSimple [w1 := valueNegate bet] $ do
placeBetRun refScript brp dat bet Nothing
Here the last argument is of type Wallets
as we noted.
Note that this function starts by calling computeParamsAndAddRefScript
, therefore let's see about it:
-- | Function to compute the parameters for the contract and add the corresponding refernce script.
computeParamsAndAddRefScript
:: Integer -- ^ Bet Until slot
-> Integer -- ^ Bet Reveal slot
-> GYValue -- ^ Bet step value
-> Wallets -> Run (BetRefParams, GYTxOutRef) -- Our continuation
computeParamsAndAddRefScript betUntil' betReveal' betStep Wallets{..} = do
let betUntil = slotFromApi (fromInteger betUntil')
betReveal = slotFromApi (fromInteger betReveal')
fmap fromJust $ runWallet w1 $ do
betUntilTime <- slotToBeginTime betUntil
betRevealTime <- slotToBeginTime betReveal
let brp = BetRefParams (pubKeyHashToPlutus $ walletPubKeyHash w8) (timeToPlutus betUntilTime) (timeToPlutus betRevealTime) (valueToPlutus betStep) -- let oracle be wallet `w8`.
mORef <- addRefScript (walletAddress w9) (betRefValidator' brp)
case mORef of
Nothing -> fail "Couldn't find index of the Reference Script in outputs"
Just refScript -> return (brp, refScript)
Our first step is to construct the parameter (BetRefParams
) for our parameterized contract. Recall its type is:
data BetRefParams = BetRefParams
{
brpOraclePkh :: PubKeyHash -- ^ Oracle's payment public key hash. This is needed to assert that UTxO being looked at indeed belongs to the Oracle.
, brpBetUntil :: POSIXTime -- ^ Time until which bets can be placed.
, brpBetReveal :: POSIXTime -- ^ Time at which Oracle will reveal the correct match result.
, brpBetStep :: Value -- ^ Each newly placed bet must be more than previous bet by `brpBetStep` amount.
}
For brpBetUntil
, we choose slot 40 but since plutus works in posix time, we need to enter a monad having an instance of GYTxQueryMonad
to get posix time from slot and therefore that calculation happens inside runWallet w1
. Similarly for brpBetReveal
we chose slot 100.
runWallet
(opens in a new tab) is a utility function which enables us to give the environment. Hm.. what environment you ask? Well in general when constructing the transaction from skeleton we need some context, like who is actually submitting this transaction? As we'll need their address to give them the change output. runWallet
takes as first argument, the wallet to generate context from and then the actual run to run against this context.
Now coming back to our parameters, for brpOraclePkh
parameter, we chose that for wallet 8. And we take our step amount to be 200 ada.
Though it is not required for this operation (where we place the first bet) but since our placeBet
function is overloaded to accept the subsequent bet case too - we need to give reference to the UTxO containing reference script. For that we have a helper function called addRefScript
(opens in a new tab) which adds the given script at a given address (we chose that for wallet 9) and returns the reference to it (in Maybe
).
Now we are almost done to call our run with just one more line to understand.
withWalletBalancesCheckSimple
takes a list of tuple3 where the first element of the tuple is the wallet and second element denotes the difference in the wallet's value which we expect after the execution of the operation defined inside its do
block excluding ada required for transaction fees and to satisfy minimum ada requirements of the generated output4. Here we want the balance of wallet 1 (which is the one actually calling this operation) to decrease by the bet amount and also the fees.
How do we know the fees? Well by running the test without it and then noting the transaction fees from the log messages.
And this covers our first test 🥳.
Multiple bets trace
Now let's write a slightly more involved trace. This time we'll make our trace parameteric over the required contract parameters.
Here is the signature of our trace:
-- | Trace which allows for multiple bets.
multipleBetsTraceWrapper
:: Integer -- ^ slot for betUntil
-> Integer -- ^ slot for betReveal
-> GYValue -- ^ bet step
-> [(Wallets -> Wallet, OracleAnswerDatum, GYValue)] -- ^ List denoting the bets
-> Wallets -> Run () -- Our continuation function
multipleBetsTraceWrapper betUntil' betReveal' betStep walletBets ws = do
-- First step: Get the required parameters for initializing our parameterized script and add the corresponding reference script
(brp, refScript) <- computeParamsAndAddRefScript betUntil' betReveal' betStep ws
-- Second step: Perform the actual bet operations
multipleBetsTraceCore brp refScript walletBets ws
The first three parameters correspond to the parameters of contract.
The fourth parameter denotes the different bets.
We may for instance call this function like so:
testRun "Balance checks with multiple bets" $ multipleBetsTraceWrapper 400 1000 (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 65_000_000 <> fakeGold 1000)
]
Next we want to add our reference script and compute the actual contract parameters (converting slot to posix) - which is again handled like before.
We would then like to perform the actual bet operations. But this time we won't concern ourselves much with actual fees but rather take a threshold of 1 ada. Our approach here is to compare the balances before performaing any operation and after performing all the operations and then see that each wallet has lost the bet amount they placed considering threshold fees.
Note: We use balance
function to get the balance for the given wallet.
-- | Trace which allows for multiple bets.
multipleBetsTraceCore
:: BetRefParams
-> GYTxOutRef -- ^ Reference script
-> [(Wallets -> Wallet, OracleAnswerDatum, GYValue)] -- ^ List denoting the bets
-> Wallets -> Run () -- Our continuation function
multipleBetsTraceCore brp refScript walletBets ws@Wallets{..} = do
let
-- | Perform the actual bet operation by the corresponding wallet.
performBetOperations [] _ = return ()
performBetOperations ((getWallet, dat, bet) : remWalletBets) isFirst = do
if isFirst then do
void $ runWallet (getWallet ws) $ do
void $ placeBetRun refScript brp dat bet Nothing
performBetOperations remWalletBets False
else do
-- need to get previous bet utxo
void $ runWallet (getWallet ws) $ do
betRefAddr <- betRefAddress brp
[_scriptUtxo@GYUTxO {utxoRef}] <- utxosToList <$> utxosAtAddress betRefAddr
void $ placeBetRun refScript brp dat bet (Just utxoRef)
performBetOperations remWalletBets False
-- | To sum the bet amount for the corresponding wallet.
sumWalletBets _wallet [] acc = acc
sumWalletBets wallet ((getWallet, _dat, bet) : remWalletBets) acc = sumWalletBets wallet remWalletBets (if getWallet ws == wallet then acc <> valueNegate bet else acc)
-- | Idea here is that for each wallet, we want to know how much has been bet. If we encounter a new wallet, i.e., wallet for whose we haven't yet computed value lost, we call `sumWalletBets` on it.
getBalanceDiff [] _set acc = acc
getBalanceDiff wlBets@((getWallet, _dat, _bet) : remWalletBets) set acc =
let wallet = getWallet ws
wallet'sName = walletName wallet
in
if Set.member wallet'sName set then getBalanceDiff remWalletBets set acc
else
getBalanceDiff remWalletBets (Set.insert wallet'sName set) ((wallet := sumWalletBets wallet wlBets mempty) : acc)
balanceDiffWithoutFees = getBalanceDiff walletBets Set.empty []
balanceBeforeAllTheseOps <- fmap fromJust $ runWallet w1 $ traverse (\(wallet, _value) -> balance wallet) balanceDiffWithoutFees
performBetOperations walletBets True
balanceAfterAllTheseOps <- fmap fromJust $ runWallet w1 $ traverse (\(wallet, _value) -> balance wallet) balanceDiffWithoutFees
void $ runWallet w1 $ verify (zip3 balanceDiffWithoutFees balanceBeforeAllTheseOps balanceAfterAllTheseOps)
where
-- | 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 [] = return ()
verify (((wallet, diff), vBefore, vAfter) : xs) =
let vAfterWithoutFees = vBefore <> diff
(expectedAdaWithoutFees, expectedOtherAssets) = valueSplitAda vAfterWithoutFees
(actualAda, actualOtherAssets) = valueSplitAda vAfter
-- threshold = valueFromLovelace 1_000_000 -- 1 ada
threshold = 1_000_000 -- 1 ada
in if expectedOtherAssets == actualOtherAssets && actualAda < expectedAdaWithoutFees && expectedAdaWithoutFees - threshold <= actualAda then verify xs
-- valueGreater vAfterWithoutFees vAfter && valueGreaterOrEqual vAfter (valueMinus vAfterWithoutFees threshold) then verify xs
else fail ("For wallet " <> walletName wallet <> " expected value (without fees) " <> show vAfterWithoutFees <> " but actual is " <> show vAfter)
An eagle eye might notice two comments inside the verify
function.
Firstly, note that valueSplitAda
splits our GYValue
into lovelaces and that which remains besides it. Since fees don't affect non-ada tokens (not yet), we compare with respect to threshold using ada tokens.
We could also compare GYValue
's directly using valueGreater
(there is also valueGreaterOrEqual
) as done in comments but the current one is slightly more optimal as we need not compare on non-ada tokens again.
But sometimes we want a test to fail!
What happens if the newly placed bet is not more than atleast brpBetStep
amount? What happens if the transaction skeleton was somewhat wrong, say we didn't put mustBeSignedBy
? What if someone tries to place a bet after brpBetUntil
? What if...
Well for all such cases, we can assert that a given trace must fail using mustFail
like:
testRun "Not adding atleast bet step amount should fail" $ mustFail . multipleBetsTrace 400 1000 (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 <> fakeGold 1000)]
Here wallet w4
didn't increase the bet by 10 ada and thus must fail.
Sometimes we want to assert specific failure among other possible failures. As mustFail
above doesn't distinguish among them, one can simply use catchError
like in this (opens in a new tab) test.
Unit tests for taking the bet pot
Entire code file for tests pertaining to this operation is available here (opens in a new tab).
On similar lines as before, let's first define our run for takeBets
operation:
-- | Run to call the `takeBets` operation.
takeBetsRun :: GYTxOutRef -> BetRefParams -> GYTxOutRef -> GYTxOutRef -> GYTxMonadRun GYTxId
takeBetsRun refScript brp toConsume refInput = do
addr <- ownAddress
skeleton <- takeBets refScript brp toConsume addr refInput
sendSkeleton skeleton
Next, we'll define our trace to call this run:
-- | Trace for taking bet pot.
takeBetsTrace :: Integer -- ^ slot for betUntil
-> Integer -- ^ slot for betReveal
-> GYValue -- ^ bet step
-> [(Wallets -> Wallet, OracleAnswerDatum, GYValue)] -- ^ List denoting the bets
-> Integer -- ^ Actual answer
-> (Wallets -> Wallet) -- ^ Taker
-> Bool -- ^ To check balance
-> Wallets -> Run () -- Our continuation function
takeBetsTrace betUntil' betReveal' betStep walletBets answer getTaker toCheckBalance ws@Wallets{..} = do
(brp, refScript) <- computeParamsAndAddRefScript betUntil' betReveal' betStep ws
multipleBetsTraceCore brp refScript walletBets ws
-- Now lets take the bet
mMRef <- runWallet w1 $ addRefInput True (walletAddress w8) (datumFromPlutusData $ OracleAnswerDatum answer)
let taker = getTaker ws
case mMRef of
Just (Just refInput) -> do
void $ runWallet taker $ do
betRefAddr <- betRefAddress brp
[_scriptUtxo@GYUTxO {utxoRef, utxoValue}] <- utxosToList <$> utxosAtAddress betRefAddr Nothing
waitUntilSlot $ slotFromApi (fromInteger betReveal')
(if toCheckBalance then withWalletBalancesCheckSimple [taker := utxoValue] $ do
takeBetsRun refScript brp utxoRef refInput else takeBetsRun refScript brp utxoRef refInput)
_anyOtherMatch -> fail "Couldn't place reference input successfully"
Here we first did the common step of computing the required script parameters and adding the reference script.
Then we used addRefInput
(opens in a new tab) whose purpose here would become clear by seeing its haddock documentation below:
-- | Adds an input (whose datum we'll refer later) and returns the reference to it.
addRefInput:: Bool -- ^ Whether to inline this datum?
-> GYAddress -- ^ Where to place this output?
-> GYDatum -- ^ Our datum.
-> GYTxMonadRun (Maybe GYTxOutRef)
Next we simply wait until time for bet revealation and claim our pot!
Now that we have our trace for taking bet pot, we can try testing for other conditions - examples for some are written in the TakeBetPot.hs
(opens in a new tab) file.
Footnotes
-
We use a custom fork (opens in a new tab) of Plutus simple model. ↩
-
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)
-
Since we require the signature being present in the skeleton, we can't place bet on anyone else's behalf anyways. ↩
-
If you would like exact fine grained control over balance change, use
withWalletBalancesCheck
instead. ↩