-
Notifications
You must be signed in to change notification settings - Fork 12
Integration Tests
$ 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"
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]
]
Scenarios are structured in three parts: setup, actions and assertions.
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"]
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
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
]
A few rules to help preserving the current design and approach:
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.
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.
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.
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.