Testing CorDapps
My first introduction to testing was the book Test-Driven Development with Python and its Testing Goat. At the time, testing sounded like a lot of pain for very little gain. For years, I was a testing adherent in name only. I professed to love testing solely because I saw it as a signifier of a good developer.
I eventually learnt my lesson. Anyone who still doesn’t share the faith could be converted through a few months building blockchain applications. A single bug in a smart contract can — and regularly does — open a security hole wide enough for someone to reach in and steal the entire contents of your digital wallet.
As a result, blockchain applications require a good testing framework. Fortunately, Corda has two:
- The Contract Test Framework
- The Flow Test Framework
These are the same test frameworks that are used by the Corda Core team to test Corda itself. In this blog, we’ll take a look at their usage and benefits.
The Contract Test Framework
Corda contracts are classes that dictate whether a transaction (i.e. an attempt to update the ledger) is valid. The Contract Test Framework allows you to test a contract by passing it a series of transactions, and asserting whether each one is valid.
Here’s an example contract test from the Example CorDapp:
@Test
public void transactionMustHaveNoInputs() {
Integer iouValue = 1;
ledger(ledgerDSL -> {
ledgerDSL.transaction(txDSL -> {
txDSL.input(IOU_CONTRACT_ID, new IOUState(iouValue, miniCorp, megaCorp));
txDSL.output(IOU_CONTRACT_ID, () -> new IOUState(iouValue, miniCorp, megaCorp));
txDSL.command(keys, IOUContract.Commands.Issue::new);
txDSL.failsWith("No inputs should be consumed when issuing an IOU.");
return null;
});
return null;
});
}
This test ensures that the IOU Contract disallows IOU issuance transactions that contain inputs. It proceeds as follows:
- It starts by creating a dummy ledger that will wrap our transaction
- It builds a transaction with an input, an output and an issue command
- It asserts that the transaction should fail contract verification. In particular, it should fail with the error message “No inputs should be consumed when issuing an IOU.”
With this test in place, we are sure that our CorDapp will not allow anyone to propose an IOU issuance transaction involving inputs. More importantly, we can continue to work on our contract in the knowledge that this test will catch any regressions.
How about a test that checks whether a transaction without inputs passes contract verification?
@Test
public void transactionMustHaveNoInputs() {
Integer iouValue = 1;
ledger(ledgerDSL -> {
ledgerDSL.transaction(txDSL -> {
txDSL.output(IOU_CONTRACT_ID, () -> new IOUState(iouValue, miniCorp, megaCorp));
txDSL.command(keys, IOUContract.Commands.Create::new);
txDSL.verifies();
return null;
});
return null;
});
}
The structure is almost identical. Here, the test asserts that since our transaction doesn’t contain an input, it should pass contract verification.
These tests only scratch the surface of the Contract Test Framework. You can read more about it here: https://docs.corda.net/tutorial-test-dsl.html.
The Flow Test Framework
Corda flows can be thought of as functions that define how a Corda node performs a given action. For example, a node owner may install a SwapCurrencies
flow on their node. Invoking this flow will then cause their node to attempt to negotiate with other nodes to commit a transaction representing a currency swap.
Testing a flow would normally involve deploying your CorDapp to a set of nodes, starting them, and manually checking that the flow has the desired effects. This approach is problematic:
- Spinning up the nodes can take minutes rather than seconds
- You have to test the flow’s behaviour manually each time
- Each node is a different Java process, so debugging is difficult
Flow Test Framework completely automates these tests. It achieves this by running the flow tests against a mock network of mock nodes. Mock nodes behave like normal nodes, but their internals have been mocked out to enable accelerated start-up times.
Let’s take a look at another example from the Example CorDapp. We start by setting up a mock network:
public class IOUFlowTests {
private MockNetwork network;
private StartedNode<MockNode> a;
private StartedNode<MockNode> b;
@Before
public void setup() {
setCordappPackages("com.example.contract");
network = new MockNetwork();
BasketOfNodes nodes = network.createSomeNodes(2);
a = nodes.getPartyNodes().get(0);
b = nodes.getPartyNodes().get(1);
// For real nodes this happens automatically, but we have to manually register the flow for tests.
for (StartedNode<MockNode> node : nodes.getPartyNodes()) {
node.registerInitiatedFlow(ExampleFlow.Acceptor.class);
}
network.runNetwork();
}
@After
public void tearDown() {
unsetCordappPackages();
network.stopNodes();
}
Here, we’ve created a mock network with a mock notary and two regular mock nodes, a
and b
. We register each node to be able to run the ExampleFlow.Acceptor
flow.
And here’s an example test:
@Test
public void flowRecordsATransactionInBothPartiesTransactionStorages() throws Exception {
ExampleFlow.Initiator flow = new ExampleFlow.Initiator(1, b.getInfo().getLegalIdentities().get(0));
CordaFuture<SignedTransaction> future = a.getServices().startFlow(flow).getResultFuture();
network.runNetwork();
SignedTransaction signedTx = future.get();
// We check the recorded transaction in both vaults.
for (StartedNode<MockNode> node : ImmutableList.of(a, b)) {
assertEquals(signedTx, node.getServices().getValidatedTransactions().getTransaction(signedTx.getId()));
}
}
This test asserts that our flow records a transaction in the storages of both parties. It proceeds as follows:
- It creates a flow
- It runs the flow on node
a
and gets the transaction the flow returns - It checks that each node’s vault has a transaction in its storage that has the same ID as the transaction returned by the flow
We can also make assertions about the contents of the nodes’ vaults:
@Test
public void flowRecordsTheCorrectIOUInBothPartiesVaults() throws Exception {
Integer iouValue = 1;
ExampleFlow.Initiator flow = new ExampleFlow.Initiator(1, b.getInfo().getLegalIdentities().get(0));
CordaFuture<SignedTransaction> future = a.getServices().startFlow(flow).getResultFuture();
network.runNetwork();
future.get();
// We check the recorded IOU in both vaults.
for (StartedNode<MockNode> node : ImmutableList.of(a, b)) {
node.getDatabase().transaction(it -> {
List<StateAndRef<IOUState>> ious = node.getServices().getVaultService().queryBy(IOUState.class).getStates();
assertEquals(1, ious.size());
IOUState recordedState = ious.get(0).getState().getData();
assertEquals(recordedState.getValue(), iouValue);
assertEquals(recordedState.getLender(), a.getInfo().getLegalIdentities().get(0));
assertEquals(recordedState.getBorrower(), b.getInfo().getLegalIdentities().get(0));
return null;
});
}
}
As before, we start by running the flow on node a
. We then inspect each node's vault, asserting that it should contain a single IOU state with the expected properties.
As with the Contract Test Framework, these tests allow us to continue working on our flows safe from regressions (and since all the nodes run in a single process, it’s easy to attach a debugger too). You can read more about the Flow Test Framework here: https://docs.corda.net/flow-testing.html.
Conclusion
We’ve looked briefly at how the Contract Test Framework and the Flow Test Framework allow us to easily test our CorDapps. We can restate their key benefits as follows:
- They allow you to test your CorDapp end-to-end without deploying a single node
- They ensure that your CorDapp is free from regressions
- They shorten development times by reducing how long it takes to test new functionality
It’s been a few years since I read Test-Driven Development with Python. But if memory serves, its wisdom could be boiled down to this:
Test everything