Skip to content
This repository has been archived by the owner on Mar 1, 2019. It is now read-only.

Integration Tests

Matthias Benkort edited this page Dec 11, 2018 · 1 revision
$ stack test cardano-wallet:integration

// Or, alternatively, to run only a subset of the tests with `--match`

$ stack test cardano-wallet:integration --test-arguments "--match Transactions"

Overview

Integration tests are written as Scenarios that run in a given context. This context embeds a few stateful elements that helps writing less verbose scenario. It also ensure that each test runs in a (rather) isolated context such that they don't conflict with each other.

import Test.Integration.Framework.DSL

spec :: Scenarios Context
spec  = do
  scenario "successful payment appears in the history" $ do
      -- * PART 1 -- Setup Test Scenario
      fixture <- setup $ defaultSetup
          & initialCoins .~ [1000000]

      -- * PART 2 -- Test Action(s)
      response <- request $ Client.postTransaction $- Payment
          (defaultSource fixture)
          (defaultDistribution 14 fixture)
          defaultGroupingPolicy
          noSpendingPassword

      -- * PART 3 -- Assertions 
      verify response
          [ expectTxInHistoryOf (fixture ^. wallet)
          , expectTxStatusEventually [Creating, Applying, InNewestBlocks, Persisted]
          ]

Structure

Scenarios are structured in three parts: setup, actions and assertions.

Setup

At this step, one initializes a default wallet to work with and a bunch of other helpful data to be used later in the scenario. The setup function herebelow can be used to generate a Fixture from an initial Setup:

setup :: Setup -> Scenario Context IO Fixture

-- Where `Setup` satisfies the few constraints from:

initialCoins   :: HasType [Coin] s         => Lens' s [Word64]
walletName     :: HasType Text s           => Lens' s Text
assuranceLevel :: HasType AssuranceLevel s => Lens' s AssuranceLevel
mnemonicWords  :: HasType [Text] s         => Lens' s [Text]

A default Setup object is provided as defaultSetup with sensible defaults.

More combinators and setup elements may be added later but this allows to easily prepare a given fixture for a scenario. As a result of calling setup, we obtain a given Fixture object which satisfies constraints for the following combinators:

wallet              :: HasType Wallet s             => Lens' s Wallet
backupPhrase        :: HasType BackupPhrase s       => Lens' s BackupPhrase
defaultDistribution :: HasType (NonEmpty Address) s => Word64 -> s -> NonEmpty PaymentDistribution

For examples:

fixture01 <- setup defaultSetup

fixture02 <- setup $ defaultSetup
  & initialCoins .~ [14, 42]

fixture03 <- setup $ defaultSetup
  & walletName .~ "My Awesome Wallet"
  & mnemonicWords .~ 
      ["swallow", "rotate", "gadget", "cheap", "estate", "quit"
      , "cousin", "gym", "census", "mass", "amount", "need"]

Actions

In integration tests, actions are sequences of API calls, only, possibly interleaved with assertions.

/!\ If you see yourself writing more than that in a scenario, step back, and ask yourself questions!

There a few functions that helps writing those actions:

  • request
  • request_
  • successfulRequest

The signature of request can be intimidating:

class Request originalResponse where
    type Response originalResponse :: *

    request
        :: forall m ctx. (MonadIO m, MonadReader ctx m, HasHttpClient ctx)
        => (WalletClient IO -> IO (Either ClientError originalResponse))
        -> m (Either ClientError (Response originalResponse))

What it means under the hood is that when used inside a Scenarios context (which has an http client available), it fires an http request for the given action. This is written in order to play well with our Wallet Client, available in Cardano.Wallet.Client.

Using the ($-) combinator (which is truly just an operator for flip), one can specifies arguments for client's method and by the magic of currying, reduce every client functions down to:

action :: WalletClient IO -> Resp IO a

// or

action :: WalletClient IO -> IO ()

The Request class helps us handling in a similar fashion request without and without content, also unwrapping the data from wrData removing quite a lot of boilerplate. In practices, this looks like:

response01 <- request $ Client.postTransaction $- Payment
    (defaultSource fixture)
    (defaultDistribution 14 fixture)
    defaultGroupingPolicy
    noSpendingPassword

response02 <- request $ Client.getAccount
    $- fixture ^. wallet . walletId
    $- defaultAccountId

request_ $ Client.postWallet $- NewWallet
    (fixture ^. backupPhrase)
    noSpendingPassword
    defaultAssuranceLevel
    "My Wallet Name"
    CreateWallet

successfulRequest Client.applyUpdate 

Assertions

Lastly, a scenario does expect a one or few things from the action it just made. Assertions starts with expect... and always work on a raw API response, possibly with a few parameters.

We can easily pipe a response through several assertions by using verify:

verify :: (Monad m) => a -> [a -> m ()] -> m ()

This runs assertions in sequence, failing at the first one. Some assertions are actually synchronous and requires time as they execute under the hood one or more calls to the API. For instance exectTxStatusEventually polls the transaction history regularly until the given status is encountered or until it times out.

A few examples:

verify response 
  [ expectSuccess
  , expectEqual (fixture ^. wallet)
  , expectFieldEqual walletId (fixture ^. wallet . walletId)
  , expectWalletEventuallyRestored
  ]

Extending the DSL

A few rules to help preserving the current design and approach:

Favor readable aliases over more generic equivalents

We want to keep the scenarios as clean as possible, out of any superfluous logic. Readibility is of the utmost importance. This is why, many arguments and defaults that could just be Nothing or minBound have actually their own explicit aliases like noEmptyPassword or defaultAccountId; this reads better.

Favor explicit lenses over their generic equivalents

In a similar fashion, we expose a few lenses that are use to manipulate fields of underlying structures. In most cases, we could go away with typed @... but this is needlessly verbose. Defining clear aliases for those lenses helps the reader understands what a scenario is about.

Only export API types

Also, there's no reason to export any type that isn't directly available via the API. Therefore, we export NewAddress, WalletOperation, WalletError etc. But aren't going to export HdAccountId for instance.

Keep plumbing inside assertation functions

Sometimes, evaluating whether a call had the right effect, they are several steps to accomplish. This just creates noise in integration tests and makes the actual intent hard to understand. Instead, it's better to abstract those steps away by exposing a helper function to do the plumbing like expectWalletEventuallyRestored. Some functions can be parameterized but do it with a grain of salt. It's fine to use default and define two separate functions instead of creating one big monster trying to handle all possible cases.