Skip to content

Latest commit

 

History

History
296 lines (225 loc) · 16.5 KB

plutip-testing.md

File metadata and controls

296 lines (225 loc) · 16.5 KB

CTL integration with Plutip

Plutip is a tool to run private Cardano testnets. CTL provides integration with Plutip via plutip-server binary that exposes an HTTP interface to control local Cardano clusters.

Table of Contents

Architecture

CTL depends on a number of binaries in the $PATH to execute Plutip tests:

  • plutip-server to launch a local cardano-node cluster
  • ogmios
  • kupo

All of these are provided by CTL's overlays.runtime (and are provided in CTL's own devShell). You must use the runtime overlay or otherwise make the services available in your package set (e.g. by defining them within your own overlays when instantiating nixpkgs) as purescriptProject.runPlutipTest expects all of them; an example of using CTL's overlays is in the ctl-scaffold template.

The services are NOT run by docker-compose (via arion) as is the case with launchCtlRuntime: instead they are started and stopped on each CTL ContractTest execution by CTL itself.

If you have based your project on the ctl-scaffold template then you have two options to run Plutip tests:

  1. nix develop followed by npm run test (recommended for development)
  2. nix run .#checks.x86_64-linux.ctl-scaffold-plutip-test
    • where you'd usually replace x86_64-linux with the system you run tests on
    • and ctl-scaffold-plutip-test with the name of the plutip test derivation for your project;
    • note that building of your project via Nix will fail in case there are any PureScript compile-time warnings.

Testing contracts

CTL can help you test the offchain Contracts from your project (and consequently the interaction of onchain and offchain code) by spinning up a disposable private testnet via Plutip and making all your Contracts interact with it.

There are two approaches to writing such tests.

First is to use the Contract.Test.Plutip.runPlutipContract function, which takes a single Contract, launches a Plutip cluster and executes the passed contract. This function runs in Aff; it will also throw an exception should contract fail for any reason. After the contract execution the Plutip cluster is terminated. You can either call it directly from your test's main or use any library for grouping and describing tests which support effects in the test body, like Mote.

Mote is a DSL for defining and grouping tests (plus other quality of life features, e.g. skipping marked tests).

Second (and more widely used) approach is to first build a tree of tests (in CTL's case a tree of ContractTest types -- basically a function from some distribution of funds to a Contract a) via Mote and then use the Contract.Test.Plutip.testPlutipContracts function to execute them. This allows to set up a Plutip cluster only once per top-level groups and tests passed to the testPlutipContracts and then use it in many independent tests. The function will interpret a MoteT (effectful test tree) into Aff, which you can then actually run.

The ctl-scaffold template provides a simple Mote-based example.

CTL will run contracts in your test bodies and will print errors for any failed tests. Only test body failures are checked and this works fine if you want to make sure your Contracts execute without errors; if you want to add more precise checks (like checking that particular token is now at some address, that some exact amount was transferred, etc.) then you need to either write these checks manually in a Contract monad and then throw errors, or (preferably) use the assertions library.

The communication with Plutip happens via the plutip-server's HTTP interface, which allows to start or stop a cluster. plutip-server allows only once active cluster at a time, but nothing stops you from setting up multiple CTL environments and multiple plutip-servers by running tests in separate fibers and thus using multiple Plutip clusters simultaneously. One caveat is that nodes in different clusters might get assigned the same port (see this Plutip doc) and then race to use it, which will result in one cluster starting fine and another repeatedly failing. The way to deal with this is to start another environment and try again.

Testing in Aff context

Contract.Test.Plutip.runPlutipContract's function type is defined as follows:

runPlutipContract
  :: forall (distr :: Type) (wallets :: Type) (a :: Type)
   . UtxoDistribution distr wallets
  => PlutipConfig
  -> distr
  -> (wallets -> Contract a)
  -> Aff a

distr is a specification of how many wallets and with how much funds should be created. It should either be a Unit (for no wallets), nested tuples containing Array BigInt or an Array (Array BigInt), where each element of the inner array specifies an UTxO amount in Lovelaces (0.000001 Ada).

The wallets argument of the callback is either a Unit, a tuple of KeyWallets (with the same nesting level as in distr, which is guaranteed by UtxoDistribution) or an Array KeyWallet.

wallets should be pattern-matched on, and its components should be passed to withKeyWallet:

An example Contract with two actors using nested tuples:

let
  distribution :: Array BigInt /\ Array BigInt
  distribution =
    [ BigInt.fromInt 1_000_000_000
    , BigInt.fromInt 2_000_000_000
    ] /\
      [ BigInt.fromInt 2_000_000_000 ]
runPlutipContract config distribution \(alice /\ bob) -> do
  withKeyWallet alice do
    pure unit -- sign, balance, submit, etc.
  withKeyWallet bob do
    pure unit -- sign, balance, submit, etc.

An example Contract with two actors using Array:

let
  distribution :: Array (Array BigInt)
  distribution =
    -- wallet one: two UTxOs
    [ [ BigInt.fromInt 1_000_000_000, BigInt.fromInt 2_000_000_000]
    -- wallet two: one UTxO
    , [ BigInt.fromInt 2_000_000_000 ]
    ]
runPlutipContract config distribution \wallets -> do
  traverse_ ( \wallet -> do
                withKeyWallet wallet do
                  pure unit -- sign, balance, submit, etc.
            )
            wallets

In most cases at least two UTxOs per wallet are needed (one of which will be used as collateral, so it should exceed 5_000_000 Lovelace).

Internally runPlutipContract runs a contract in an Aff.bracket, which creates a Plutip cluster on setup and terminates it during the shutdown or in case of an exception. Logs will be printed in case of an error.

Testing with Mote

Contract.Test.Plutip.testPlutipContracts type is defined as follows (after expansion of the CTL's TestPlanM type synonym):

type TestPlanM :: Type -> Type -> Type
type TestPlanM test a = MoteT Aff test Aff a

testPlutipContracts
  :: PlutipConfig
  -> MoteT Aff ContractTest Aff Unit
  -> MoteT Aff (Aff Unit) Aff Unit

-- Recall that `MoteT` has three type variables
newtype MoteT bracket test m a

where

  • bracket :: Type -> Type is where brackets will be run (before/setup is bracket r and after/shutdown is of type r -> bracket Unit),
    • in our case it's Aff and is where the CTL environment and Plutip cluster setup will happen,
    • also environment setup and Plutip startup and teardown will happen once per each top-level test or group inside the testPlutipContracts call,
    • so wrap your tests or groups in a single group if you want for the cluster to start only once,
  • test :: Type is a type of tests themselves,
    • in our case it's ContractTest, which in a nutshell describes a function from some wallet UTxO distribution to a Contract r
    • wallet UTxO distribution is the one that you need to pattern-match on when writing tests
  • m :: Type -> Type is a monad where effects during the construction of the test suite can be performed,
    • here we use Aff again
  • a :: Type is a result of the test suite, we use Unit here.

To create tests of type ContractTest, the user should either use Contract.Test.Plutip.withWallets or Contract.Test.Plutip.noWallet:

withWallets
  :: forall (distr :: Type) (wallets :: Type)
   . UtxoDistribution distr wallets
  => distr
  -> (wallets -> Contract Unit)
  -> ContractTest

noWallet :: Contract Unit -> ContractTest
noWallet test = withWallets unit (const test)

Usage of testPlutipContracts is similar to that of runPlutipContract, and distributions are handled in the same way. Here's an example:

suite :: MoteT Aff (Aff Unit) Aff
suite = testPlutipContracts config do
  test "Test 1" do
    let
      distribution :: Array BigInt /\ Array BigInt
      distribution = ...
    withWallets distribution \(alice /\ bob) -> do
      ...

  test "Test 2" do
    let
      distribution :: Array BigInt
      distribution = ...
    withWallets distribution \alice -> do
      ...

  test "Test 3" do
    noWallet do
      ...

To define tests suites you can use test, group them with group and also wrap tests or groups with bracket to execute custom actions before and after tests/groups that are inside the bracket. Note that in Mote you can define several tests and several groups in a single block, and bracket that wraps them will be run for each such test or group.

Internally testPlutipContracts places a bracket that sets up the CTL environment and starts up the Plutip cluster on the top level, so if you want to launch cluster only once wrap your tests or groups in a single group. In the example above the environment and cluster setup will happen 3 times.

testPlutipContracts also combines distributions of individual tests in a single big distribution (via nested tuples) and modifies tests to pluck their required distributions out of the big one. This allows to create wallets and fund them in one step, during the Plutip setup. See the comments in the Ctl.Internal.Plutip.Server module for more info (relevant ones are in execDistribution and testPlutipContracts functions).

In complicated protocols you might want to execute some Contracts in one test and then execute other Contracts which depend on some wallet-dependent state set up by the first batch of contracts, e.g. some authorization token is present at some wallet. Keeping these steps in separate sequential tests allows to pinpoint where things failed much easier, but currently CTL uses separate wallets for each test without an easy way to refer to wallets in other tests, so you have to call first batch of contracts again to replicate the state of the wallets, which in turn might fail or mess up your protocol, because the chain state is shared between tests for each top-level group. There's a patch to CTL you can adapt (and even better -- make a PR) if you need to share wallets between tests right now, see the limitations doc for more info. This functionality will probably be added to CTL later.

Note on SIGINT

Due to testPlutipContracts/runPlutipContract adding listeners to the SIGINT signal, Node.js's default behaviour of exiting on that signal no longer occurs. This was done to add cleanup handlers and let them run in parallel instead of exiting eagerly, which is possible when running multiple clusters in parallel. To restore the exit behaviour, we provide helpers to cancel an Aff fiber and set the exit code, to let Node.js shut down gracefully when no more events are to be processed.

...
import Contract.Test.Utils (exitCode, interruptOnSignal)
import Data.Posix.Signal (Signal(SIGINT))
import Effect.Aff (cancelWith, effectCanceler, launchAff)

main :: Effect Unit
main = interruptOnSignal SIGINT =<< launchAff do
  flip cancelWith (effectCanceler (exitCode 1)) do
    ... test suite in Aff ...

Testing with Nix

You can run Plutip tests via CTL's purescriptProject as well. After creating your project, you can use the runPlutipTest attribute to create a Plutip testing environment that is suitable for use with your flake's checks. An example:

{
  some-plutip-test = project.runPlutipTest {
    name = "some-plutip-test";
    testMain = "Test.MyProject.Plutip";
    # The rest of the arguments are passed through to `runPursTest`:
    env = { SOME_ENV_VAR = "${some-value}"; };
  };
}

The usual approach is to put projectname-plutip-test in the checks attribute of your project's flake.nix. This is done by default in the ctl-scaffold template.

Cluster configuration options

PlutipConfig type contains clusterConfig record with the following options:

{ slotLength :: Seconds
, epochSize :: Maybe UInt
, maxTxSize :: Maybe UInt
, raiseExUnitsToMax :: Boolean
}
  • slotLength and epochSize define time-related protocol parameters. Epoch size is specified in slots.
  • maxTxSize (in bytes) allows to stress-test protocols with more restrictive transaction size limits.
  • raiseExUnitsToMax allows to bypass execution units limit (useful when compiling the contract with tracing in development and without it in production).

Current limitations

  • Non-default values of epochSize (current default is 80) break staking rewards - see this issue for more info. slotLength can be changed without any problems.

Using addresses with staking key components

It's possible to use stake keys with Plutip. Contract.Test.Plutip.withStakeKey function can be used to modify the distribution spec:

let
  privateStakeKey :: PrivateStakeKey
  privateStakeKey = wrap $ unsafePartial $ fromJust
    $ privateKeyFromBytes =<< hexToRawBytes
      "633b1c4c4a075a538d37e062c1ed0706d3f0a94b013708e8f5ab0a0ca1df163d"
  aliceUtxos =
    [ BigInt.fromInt 2_000_000_000
    , BigInt.fromInt 2_000_000_000
    ]
  distribution = withStakeKey privateStakeKey aliceUtxos

Although stake keys serve no real purpose in plutip context, they allow to use base addresses, and thus allow to have the same code for plutip testing, in-browser tests and production.

Note that CTL re-distributes tADA from payment key-only ("enterprise") addresses to base addresses, which requires a few transactions before the test can be run. These transactions happen on the CTL side, because Plutip can currently handle only enterprise addreses (see this issue).

Limitations

  • See the epochSize configuration option problem here.
  • Currently there's no way to share wallets between separate tests (which is useful for complex protocols). You can adapt this PR (needs to be updated for the newer versions of CTL, likely won't need too many changes) if you need it now (and even better -- make a PR to CTL).
  • If you've used a plutus-simple-model library then you might know that it allows time travel in tests, which can be very useful for testing vesting schedules, etc. Testing with Plutip doesn't allow this, as it's running a real network, so you'd have to really wait, say 1 year, in order to test interaction with onchain code which checks that much time has passed.

See also

  • To actually write the test bodies, assertions library can be useful (usage example).
  • Take a look at CTL's Plutip tests for the usage examples:
    • the entry point with main that runs Plutip tests is here,
    • folder with various test suites is here.