Major Uplift for Cadence Testing Framework

Hello fellow builders,

From the beginning of the year I have been working on improving DevEx tools for the Cadence smart-contract programming language, with the most notable ones being coverage reporting and testing.
In this post I want to share a major uplift that we are preparing for the Cadence testing framework.

Let us start with a sample integration test:

import Test
import ApprovalVoting from "../contracts/ApprovalVoting.cdc"

pub let admin = Test.getAccount(0x0000000000000007)

pub fun setup() {
    let err = Test.deployContract(
        name: "ApprovalVoting",
        path: "../contracts/ApprovalVoting.cdc",
        arguments: []
    )

    Test.expect(err, Test.beNil())
}

pub fun testInitializeEmptyProposals() {
    let proposals: [String] = []
    let code = Test.readFile("../transactions/initialize_proposals.cdc")
    let tx = Test.Transaction(
        code: code,
        authorizers: [admin.address],
        signers: [admin],
        arguments: [proposals]
    )

    let result = Test.executeTransaction(tx)

    Test.assertError(result, errorMessage: "Cannot initialize with no proposals")
}

pub fun testInitializeProposals() {
    let proposals = [
        "Longer Shot Clock",
        "Trampolines instead of hardwood floors"
    ]
    let code = Test.readFile("../transactions/initialize_proposals.cdc")
    let tx = Test.Transaction(
        code: code,
        authorizers: [admin.address],
        signers: [admin],
        arguments: [proposals]
    )

    let result = Test.executeTransaction(tx)

    Test.expect(result, Test.beSucceeded())

    let typ = Type<ApprovalVoting.ProposalsInitialized>()
    let events = Test.eventsOfType(typ)
    Test.assertEqual(1, events.length)
}

pub fun testCastVote() {
    let code = Test.readFile("../transactions/cast_vote.cdc")
    let voter = Test.createAccount()
    let tx = Test.Transaction(
        code: code,
        authorizers: [voter.address],
        signers: [voter],
        arguments: [1]
    )

    let result = Test.executeTransaction(tx)

    Test.expect(result, Test.beSucceeded())

    let typ = Type<ApprovalVoting.VoteCasted>()
    let events = Test.eventsOfType(typ)
    Test.assertEqual(1, events.length)

    let event = events[0] as! ApprovalVoting.VoteCasted
    Test.assertEqual("Trampolines instead of hardwood floors", event.proposal)
}

pub fun testViewVotes() {
    let code = Test.readFile("../scripts/view_votes.cdc")

    let result = Test.executeScript(code, [])
    let votes = (result.returnValue as! {Int: Int}?)!

    let expected = {0: 0, 1: 1}
    Test.assertEqual(expected, votes)
}

Now let us briefly explain what is going on behind the scenes.

This Cadence script is executed against a special environment, just like the environments that execute regular scripts & transactions, but with certain differences.

The reason this is categorized as an integration test, is because the contract under testing (ApprovalVoting) is deployed on a Blockchain and we interact with it by executing scripts and transactions, hence testing all of our code end-to-end.

Under the hood, the test environment utilizes the Flow Emulator as a library, which gives us access to all of its features.

Most of the testing API is exposed through the Test contract, that is why we import it on the above test script.

We also import the contract under testing, so that we can reference the nested types defined in it, such as structs, enums, events etc. In the above sample, we are making certain assertions about a contract event being emitted, and we also make assertions on its payload. In other cases, a script might return a nested type defined in a contract, so by importing the contract in the test script, we can make use of that type and perform assertions.

Note: The import of the contract in the test script is only meant for accessing nested types, and not directly accessing its fields and functions. Because the import is performed only once, transactions that mutate the contractโ€™s state, cannot be reflected on the imported contract in the test script. Bear in mind that test cases run after all the imports and the special setup function.
That being said, we are also working on some unit testing features, to allow testing of contracts with function calls, instead of scripts & transactions, but generally these two types of testing should not be mixed up.

Just like with the Flow Emulator, before using a contract, we have to deploy it on an account first. That is what happens in the special setup function, which is called before any test case runs. The admin account, is the account where the contract under testing is deployed, and is specified on the flow.json config file, which all Flow dApps make us of.

{
  "emulators": {
    "default": {
      "port": 3569,
      "serviceAccount": "emulator-account"
    }
  },
  "contracts": {
    "ApprovalVoting": {
      "source": "contracts/ApprovalVoting.cdc",
      "aliases": {
        "testing": "0x0000000000000007"
      }
    }
  },
  "networks": {
    "emulator": "127.0.0.1:3569",
    "testing": "127.0.0.1:3569"
  },
  "accounts": {
    "emulator-account": {
      "address": "0xf8d6e0586b0a20c7",
      "key": "2619878f0e2ff438d17835c2a4561cb87b4d24d72d12ec34569acd0dd4af7c21"
    }
  },
  "deployments": {
    "emulator": {
      "emulator-account": [
        "FooContract",
        "ArrayUtils",
        "StringUtils",
        "ApprovalVoting"
      ]
    }
  }
}

As we can see, we have introduced the testing network and in the contracts section we specify an alias for our contract, which is the address where it will be deployed for the testing environment.

The rest of the functions in the above test script, are test cases. Functions that begin with a test prefix and have neither arguments or return values, are considered valid test cases.

Below we list some of the key features that are being added:

import Test
import BlockchainHelpers

pub fun testSample() {
    let account = Test.createAccount()

    // Assert that a certain built-in event was emitted.
    let typ = CompositeType("flow.AccountCreated")!
    let events = Test.eventsOfType(typ)
    Test.expect(events.length, Test.beGreaterThan(1))

    // Get the Flow token balance of an account.
    let balance = getFlowBalance(for: account)

    // Mint a specified amount of Flow token to an account.
    mintFlow(to: account, amount: 1500.0)

    // Burn a specific amount of Flow token from an account.
    burnFlow(from: account, amount: 500.0)

    // Retrieve the blockchain's current block height,
    // and rollback to a specified height.
    let height = getCurrentBlockHeight()
    Test.reset(to: height - 2)

    // Move the blockchain's time in the future and past.
    // timeDelta is the representation of 20 days, in seconds.
    let timeDelta = Fix64(20 * 24 * 60 * 60)
    Test.moveTime(by: timeDelta)

    // Create and load snapshots of the blockchain
    let admin = Test.createAccount()
    Test.createSnapshot(name: "adminCreated")

    mintFlow(to: admin, amount: 1000.0)
    Test.createSnapshot(name: "adminFunded")

    var balance = getFlowBalance(for: admin)
    Test.assertEqual(1000.0, balance)

    Test.loadSnapshot(name: "adminCreated")

    balance = getFlowBalance(for: admin)
    Test.assertEqual(0.0, balance)

    // Assert that a custom event of a contract was emitted,
    // and it contains a specific payload.
    let typ = Type<FooContract.NumberAdded>()
    let events = Test.eventsOfType(typ)
    Test.assertEqual(1, events.length)

    let event = events[0] as! FooContract.NumberAdded
    Test.assertEqual(78557, event.n)
    Test.assertEqual("Sierpinski", event.trait)
}

This is not an exhaustive list of features, but as soon as we finalize the code and make a release available, we will publish an announcement and update all the relevant documentation.

I mentioned in the beginning of this post a coverage reporting tool. Cadence comes with built-in support for coverage reporting, and the testing framework already makes use of it.

The Hybrid Custody project is already making use of the last version of the Cadence testing framework, and benefits from these features as we can see from the above GitHub badges. The projectโ€™s CI is powered by the Flow CLI, which includes a command for the Cadence testing framework (run flow help test to view usage information).

Below we list a nice visualization that comes from the generated LCOV format, produced by the Flow CLI:

We can visually see which line is not exercised by tests.

There is also a personal GitHub repository, which I use for various samples on this: Enable test cases that are commented out by m-Peter ยท Pull Request #3 ยท m-Peter/flow-code-coverage ยท GitHub

Any community feedback is more than appreciated, as we want to drive these tools towards a direction which Flow builders find useful.

Many thanks to Bastian, Supun, Jerome, Satyam, Giovanni, Austin, bluesign and Bjartek for their support and ideas in shaping this.

Stay tuned for more :pray: :raised_hands:

4 Likes

Huge shoutout to your work here thus far! Will give more review later.

Amazing work! :rocket:
Loved how much the testing framework has improved!

Thank you for the amazing work @m-Peter and posting the update here, much appreciated!