Integration Tests

Integration Tests

We already saw how we can conveniently write tests for our smart contract using our wrapper upon Plutus simple model. But these tests were running against a mock ledger, i.e., we really were just simulating it by having some mock data-structures (say set of UTxOs) which were getting updated on submission of successful transaction. We could however write tests to test against the real node and have it slightly more convenient to program against by spinning up our own private network (privnet for short). Here is the table which outlines the differences between the two approaches:

Tests using PSM WrapperTests using Private Network
Runs against mock ledgerRuns against real node
Each unit test gets fresh set of wallets (having original balance)Each subsequent unit test continues upon the effects caused by previous ones
Fast, purer (no IO) & convenientSlow as each slot is 0.1 second

Thus these tests are suitable for integration testing.

Spinning up private network

Our private network is adapted from WoofPool's cardano-private-testnet-setup (opens in a new tab) repository.

To spin up it up:

  1. Clone this (opens in a new tab) repository. Make sure to not clone it in some deep nested path as then the path length towards the generated socket file (node.sock) may exceed 108 characters1.
  2. Enter it & checkout geniusyield branch.
  3. Enter the following in terminal: ./scripts/automate.sh (you would need to have cardano-node & cardano-cli available in your PATH).

Once it says, "Congrats! Your network is ready for use!" you can attempt to run the tests (in another terminal).

First, let's say the path to private-testnet-simple is X, then being inside your example project folder, you can execute the tests by running GENIUSYIELD_PRIVNET_DIR=$X/private-testnet cabal run betref-privnet-tests -- -j1

The -j1 is needed so that the tests run sequentially.

Remember to stop (CTRL-C, and killall cardano-node) the private testnet, or it will eventually eat all of your disk space.

The way we have it setup for our test boilerplate is that we have nine users where users second to nine start with the following balances:

  • 5 UTxOs each containing thousand ada
  • 1 million each of gold & iron tokens

First user is called "funder" as it has far more ada (couple of 100 thousands) and the number of gold & iron tokens is 2 millions.

We'll also see how to create a new user soon, if required.

⚠️

Unless you kill & restart the private network, running your privnet tests again, would have them run in the modified network state. So in general, if you wish to reexecute the command mentioned before, viz. ATLAS_PRIVNET_DIR=$(pwd)/private-testnet-simple/private-testnet cabal run privnet-tests -- -j1, you should first restart the privnet2.

Understanding our first test

📃

The tests are written in this (opens in a new tab) file and are being called here (opens in a new tab).

Here is the code (& explaination follows after it):

  testCaseSteps "Balance checks & taking pot by closest guesser should pass" $ \info -> withSetup setup info $ \ctx -> do
 
    -- First step: Construct the parameters and obtain validator from it.
    --
    -- Let's define a new User to represent Oracle (not necessary though)
    oracleUser <- newTempUserCtx ctx (ctxUserF ctx) (valueFromLovelace 20_000_000) False
    (currentSlot, slotConfig) <- getSlotAndConfig ctx
    let betUntilSlotDelta = 100
        betRevealSlotDelta = 200
        betUntilTime = slotToBeginTimePure slotConfig (unsafeAdvanceSlot currentSlot betUntilSlotDelta)
        betRevealTime = slotToBeginTimePure slotConfig (unsafeAdvanceSlot currentSlot betRevealSlotDelta)
        brp = BetRefParams (pubKeyHashToPlutus $ userPkh oracleUser) (timeToPlutus betUntilTime) (timeToPlutus betRevealTime) (valueToPlutus $ valueFromLovelace 10_000_000)
        validator = betRefValidator' brp
    validatorAddress <- ctxRunC ctx (ctxUserF ctx) $ betRefAddress brp
    -- Second step: Putting reference script for validator.
    refScript <- addRefScriptCtx ctx (ctxUserF ctx) (validatorToScript validator)
    -- Third step: Put some bets.
    --
    -- 1st bet.
    txBodyLock <- ctxRunI ctx (ctxUser3 ctx) $ placeBet refScript brp (OracleAnswerDatum 1) (valueFromLovelace 10_000_000) (userAddr (ctxUser3 ctx)) Nothing
    lockedORef <- findOutput validatorAddress txBodyLock
    void $ submitTx ctx (ctxUser3 ctx) txBodyLock
 
    -- Balance of `(ctxUser2 ctx)` before placing the bet
    balance <- ctxQueryBalance ctx (ctxUser2 ctx)
    --
    -- 2nd bet.
    txBodyLockUser2 <- ctxRunI ctx (ctxUser2 ctx) $ placeBet refScript brp (OracleAnswerDatum 2) (valueFromLovelace 20_000_000) (userAddr (ctxUser2 ctx)) (Just lockedORef)
    lockedORef <- findOutput validatorAddress txBodyLockUser2
    void $ submitTx ctx (ctxUser2 ctx) txBodyLockUser2
    --
    -- 3rd bet.
    txBodyLock <- ctxRunI ctx (ctxUser3 ctx) $ placeBet refScript brp (OracleAnswerDatum 3) (valueFromLovelace 35_000_000) (userAddr (ctxUser3 ctx)) (Just lockedORef)
    lockedORef <- findOutput validatorAddress txBodyLock
    void $ submitTx ctx (ctxUser3 ctx) txBodyLock
 
    -- Fourth step, get the bets pot.
    --
    -- Let's first wait for the required amount
    ctxWaitUntilSlot ctx (unsafeAdvanceSlot currentSlot betRevealSlotDelta)  -- here this `currentSlot` is what we obtained sometime ago, the actual current slot has certainly increased a lot by now.
    --
    -- Let's then add for the reference input
    refInputORef <- addRefInputCtx ctx (ctxUserF ctx) True (userAddr oracleUser) (datumFromPlutusData (OracleAnswerDatum 2))
    --
    -- Unlock operation
    txBodyUnlock <- ctxRunI ctx (ctxUser2 ctx) $ takeBets refScript brp lockedORef (userAddr (ctxUser2 ctx)) refInputORef
    void $ submitTx ctx (ctxUser2 ctx) txBodyUnlock
    --
    -- Balance of `(ctxUser2 ctx)` after unlocking
    let adaExpectedIncrease = valueFromLovelace 45_000_000
    assertUserFunds (txBodyFee txBodyUnlock + txBodyFee txBodyLockUser2) ctx (ctxUser2 ctx) $ balance <> adaExpectedIncrease

The first line testCaseSteps "test description" $ \info -> withSetup setup info $ \ctx -> do can be seen as a boilerplate for all of your tests.

ctx denotes the so called context (of type Ctx) and contains information about our users, additional tokens, etc. It is defined in Ctx.hs (opens in a new tab) file and it is essential to go over that file if you intend to write these tests.

Variable info is used to log messages and you can use it in your test's do block like info $ printf "Hello from %s" "Atlas"

We next see the use of newTempUserCtx utility function. As mentioned before, we already have nine users in our context, where they have the type User:

data User = User
    { userSKey :: !GYPaymentSigningKey
    , userAddr :: !GYAddress
    }

But at rare times, we might need to create a new user. Such a user would not be part of the context and thus would be local to the test creating it3.

We can do that with the help of newTempUserCtx function. It accepts the context parameter, the user which will fund this new user, the value to be given to this new user and a boolean denoting whether we want to create a 5-ada-only UTxO too for this new user.

Next we see the use of getSlotAndConfig function. Earlier when we wrote for PSM tests, we could work in absolute slots as we were always running each test from the beginning of ledger but this is not the case here. Thus, we would need to work with relative slots, i.e., we find the current slot and then add offset with respect to it. Function getSlotAndConfig has the folowing definition:

getSlotAndConfig :: Ctx -> IO (GYSlot, GYSlotConfig)
getSlotAndConfig ctx = do
  slot <- ctxCurrentSlot ctx
  sc   <- ctxSlotConfig ctx
  return (slot, sc)

Next we compute for our contract parameters and since we already obtained the slot config, we can use slotToBeginTimePure instead of slotToBeginTime.

We next see the use of ctxRunC. To understand it, we need to first look at signature of ctxRunF.

ctxRunF :: forall t v. Traversable t => Ctx -> User -> GYTxMonadNode (t (GYTxSkeleton v)) -> IO (t GYTxBody)

We see that it has a type variable t which should have an instance of Traversable. The other two functions, namely ctxRunC & ctxRunI call this ctxRunF function with suitable instantiation of type variable t.

Here is the table which explains about these three (ctxRunF, ctxRunC & ctxRunI) related functions:

FunctionWhen to use?What does it do?
ctxRunIWhen you want to build for single GYTxSkeletonIt wraps our skeleton under Identity4, that is what suffix I stands for
ctxRunFWhen you have say multiple skeletons, like [GYTxSkeleton], or Maybe GYTxSkeleton-
ctxRunCWhen you don't want to build skeletons. This is in particular useful for operations like utxosAtAddressThe type constructor Const is defined as newtype Const a b = Const { getConst :: a } and therefore type parameter b is phantom and thus this function helps us ignore for GYTxSkeleton

We next add for reference script using helper utility function addRefScriptCtx.

We then start placing our bets, once we have the transaction body, we use findOutput function which gives us the reference to the UTxO (the first one it finds5) that is being locked at the script address.

After placing our bets, we use ctxWaitUntilSlot to wait till the unlock slot.

Note that we queried the balance of unlocker so that we can compare with it later.

We next add for our reference input using addRefInputCtx helper utility function.

Next we perform the unlock operation (calling our takeBets operation).

Lastly, we verify that the unlocker was able to take all the bets by comparing the balance using assertUserFunds method. Here is it's definition:

-- | Asserts if the user funds change as expected. This function subtracts fees from the given expected value.
assertUserFunds :: Integer -> Ctx -> User -> GYValue -> IO ()
assertUserFunds fees ctx u expectedValue = do
    currentValue <- ctxQueryBalance ctx u
    let expectedValue' = expectedValue `valueMinus` valueFromLovelace fees
    assertBool (unwords ["The value didn't change as expected",
                         "\nExpected: ", show expectedValue',
                         "\nCurrent: ", show currentValue])
               (currentValue == expectedValue')

Writing a failing test

Now let's see another test where we slightly modify the last step (all the rest is same) and this time we instead try to take funds by not the closest guesser.

  -- Fourth step, get the bets pot.
  --
  -- Let's first wait for the required amount
  ctxWaitUntilSlot ctx (unsafeAdvanceSlot currentSlot betRevealSlotDelta)  -- here this `currentSlot` is what we obtained sometime ago, the actual current slot has certainly increased a lot by now.
  --
  -- Let's then add for the reference input
  refInputORef <- addRefInputCtx ctx (ctxUserF ctx) True (userAddr oracleUser) (datumFromPlutusData (OracleAnswerDatum 2))
  --
  -- Unlock operation
  -- But this time by wrong guesser
  assertThrown isTxBodyErrorAutoBalance $ ctxRunI ctx (ctxUser3 ctx) $ takeBets refScript brp lockedORef (userAddr (ctxUser3 ctx)) refInputORef

Notice that we try catching the error using assertThrown function. Here isTxBodyErrorAutoBalance is defined as (both this & assertThrown have their definitions in Asserts.hs (opens in a new tab) file):

isTxBodyErrorAutoBalance :: BuildTxException -> Bool
isTxBodyErrorAutoBalance (BuildTxBodyErrorAutoBalance _) = True
isTxBodyErrorAutoBalance _                               = False

Thus our assertThrown function checks for two things:

  1. Whether our action indeed raises an exception.
  2. If an exception is raised, does it saitsfy our predicate? For instance, here our predicate was isTxBodyErrorAutoBalance.
💡

You can also catch for IO error like:

  errored <- catchIOError (submitTx ctx (ctxUserF ctx) txBody >> pure False) (\_ -> pure True)
  unless errored $ assertFailure "Expecting an IOError exception"

With this we conclude upon writing integration tests.

Footnotes

  1. https://unix.stackexchange.com/q/367008 (opens in a new tab)

  2. For convenience, you can write a bash script which combines setup, running tests & closing the privnet all into one simple script.

  3. Even though this user is local to the test which created it, it would still persist in our private network.

  4. Technically, it's not wrapper that is happening place here but rather we coerce with Identity newtype.

  5. Therefore this function is intended to be used when we create only a single output for an external address.