Unit Tests

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 using cardano-cli and 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.

Why not just use "Plutus simple model" instead of the wrapper?

  1. 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.

  2. 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 (defined in Run.hs (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 function is defined in file Run.hs (opens in a new tab) 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?

This function (defined in Utils.hs (opens in a new tab)) 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
              -> Integer            -- ^ Expected fees
              -> Wallets -> Run ()  -- Our continuation function
firstBetTrace dat bet expectedFees 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.
    withWalletBalancesCheck [w1 := valueNegate (valueFromLovelace expectedFees <> 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 is a utility function (defined again in Utils.hs (opens in a new tab)) 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 defined in Utils.hs (opens in a new tab) called addRefScript 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.

withWalletBalancesCheck 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. 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
              -> Maybe Integer                                      -- ^ Expected fees
              -> Wallets -> Run ()  -- Our continuation function
takeBetsTrace betUntil' betReveal' betStep walletBets answer getTaker mExpectedFees 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
        waitUntilSlot $ slotFromApi (fromInteger betReveal')
        case mExpectedFees of
          Just expectedFees ->
            withWalletBalancesCheck [taker := utxoValue <> valueNegate (valueFromLovelace expectedFees)] $ do
              takeBetsRun refScript brp utxoRef refInput
          Nothing -> 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 defined in Utils.hs (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

  1. We use a custom fork (opens in a new tab) of Plutus simple model.

  2. To convey the message better, we have a pattern synonym defined in Utils.hs (opens in a new tab) file:

    pattern (:=) :: x -> y -> (x, y)
    pattern (:=) x y = (x, y)
  3. Since we require the signature being present in the skeleton, we can't place bet on anyone else's behalf anyways.