Testing Michelson Tezos contracts with PyTezos library

Tezos developing infrastructure for pure Michelson is as important as for high-level languages. It's easy with PyTezos library

Michael
Michael   Follow

In our previous post, we slightly touched on how to conduct integration tests for Michelson (opens new window) contracts. It’s time to dive deeper and try some more advanced features offered by PyTezos (opens new window).

We believe that developing infrastructure for pure Michelson is as important as for high-level languages because:

  • This is the only way to unleash the whole power of the Tezos VM (Michelson) right now (and build “production-ready” applications);
  • The storage size required for a smart contract turns out to be much smaller (which is important given the current hard limit);
  • The amount of gas needed for contract calls is also decreasing dramatically (see our case (opens new window) with 3–4x reduction).

What we did actually is very simple, it was there since the beginning. We just created a convenient wrapper using our Python SDK.

# How it works

The Tezos node provides an API for executing a contract’s code with given storage, input, and several more parameters. It is nothing but an integration testing tool (in theory it is also possible to unit test lambdas). Basically, you can use it as is, but PyTezos can make your life significantly easier.

We are using our Python interface (opens new window) for generating test calls and handling raw responses: this opens the access to all the opportunities Python (and Python IDEs) offers for testing, including various frameworks and visual tools e.g. interactive debugger.

# Setting up the environment

Follow the instructions from the documentation at (opens new window)https://baking-bad.github.io/pytezos/ (opens new window) or https://pytezos.baking-bad.org/ (opens new window) to install PyTezos and all the dependencies.

We will also need a Tezos node with RPC API accessible, the simplest way is to use one of the public nodes, but you can also set up a sandboxed node (opens new window) fairly easily.

We can run tests from the command line, but for the best experience, we recommend using the PyCharm IDE (opens new window) which by the way has excellent Michelson support (opens new window) thanks to Joachim Ansorg.

For Ubuntu (snap package manager required):

$ sudo snap install pycharm-community --classic

# Generic template

As soon as everything is installed we can move on to creating our first test case. Let’s take a sample contract that appends an input string to the value in the storage (concatenation):

parameter string;
storage string;
code { DUP;
       DIP { CAR ; NIL string ; SWAP ; CONS } ;
       CDR ; CONS ;
       CONCAT ;
       NIL operation; PAIR }

Write it to the my_contract.tz and create a new file test_my_contract.py with the following content:

from os.path import dirname, join
from unittest import TestCase
from decimal import Decimal

from pytezos import ContractInterface, pytezos, format_timestamp, MichelsonRuntimeError

class MyContractTest(TestCase):

    @classmethod
    def setUpClass(cls):
        cls.my = ContractInterface.create_from(join(dirname(__file__), 'my_contract.tz'))
        cls.maxDiff = None

NOTE

test_ prefix is important because that’s how pytest (python testing tool) recognizes files which have to be executed

What we did is imported several functions for working with paths, Python unit test framework, and all the necessary PyTezos modules and helpers.
Next, we declared a custom test case, an individual unit of testing which checks for a specific response to a particular set of inputs.

Initialization is done via the setUpClass method which is called once and before the other methods of the class. We created a contract interface from the file, and also told the testing engine to show us the entire diff output if we compare two objects.

That’s it, we are ready to interact with the contract, but in order to do so, we first need to learn how to construct an input and what is the format of the output.

# Type schema

PyTezos relies on a high-level interface built on top of Michelson annotations. But even if your code is not annotated you still can use all the features. In order to display the type schemas for storage and parameter of your contract, you can use the PyTezos CLI (or use an interactive notebook/console as was previously shown (opens new window)).

$ pytezos contract schema --path=my_contract.tz

The path is unnecessary if there is only one .tz file in the current folder:

$ pytezos parameter schema

In our case, both storage and parameter are just strings so we can move on to the testing part.

# Simple test

What we want to check is that the passed parameter is actually concatenated to our storage.

 def test_concat(self):
    res = self.my.call('bar').result(storage='foo')
    self.assertEqual('foobar', res.storage)

NOTE

If there is a single entry point, use call method

The ContractInterface class is the same one used for real interaction. The result method simulates code execution and returns operation result. But when we specify storage parameter we say ‘do not run this code with the current block context, but use ours instead’.

The result contains three useful properties:

  • res.storage storage state after the execution;
  • res.big_map_diff changed big_map entries (as a dictionary, not list);
  • res.operations internal operations spawned during the execution.

Now you can run tests in your IDE window or type pytest . -v in the console — our test should pass!

It works!

# Test it like a Pro

Looks simple? Let’s check some more advanced examples.

# Sandboxed node

You can notice that our tests run a bit slow — that’s because we execute code on a remote node. If you have a local single-node-chain, e.g. Granary (opens new window), you can explicitly tell PyTezos to use it:

ContractInterface.create_from(
    join(dirname(__file__), 'my_contract.tz'),
    shell='sandboxnet')

This is an alias for 127.0.0.1:8732 alternatively you can specify the full URI yourself.

# Custom interface

Sometimes your code is not annotated as you may want it to be, or not annotated at all (e.g. LIGO output). In those cases, you can specify your own interface or choose one from the PyTezos collection (for now there is only an NFT standard (opens new window) for non-fungible tokens). Basically custom interface is just redefined parameter and storage sections of a Michelson script.

ContractInterface.create_from(
    join(dirname(__file__), 'my_contract.tz'),
    factory=NonFungibleTokenImpl)

# Working with big_map

When you specify initial storage just set big_map entries where there should be:

res = self.my \
    .remove_entry('key') \
    .result(storage=[{'key': 'value'}, None])

The only thing that could be unintuitive is entry removal:

self.assertDictEqual({'key': None}, res.big_map_diff)

# Sending funds

Just add this method before the result() :

self.my \
    .call('deadbeef') \
    .with_amount(Decimal('0.01'))

Note that you can use either int for utz, or Decimal for tz.

# Handling internal transactions

In case your contract sends some funds away, you can check that they have reached the right destination:

self.assertEqual('tz1address', res.operations[0]['destination'])

# Patching SOURCE and SENDER

If you call contract A which in the result calls contract B, from the perspective of the latter [B] you are the SOURCE and A is the SENDER (see the docs (opens new window)).

self.my \
    .call('deadbeef') \
    .result(storage='',
            source='tz1address',
            sender='KT1address')

Both fields are optional, the only thing you should know is:

  • If both source and sender are unset, a default internal value is used instead;
  • If source is unset, sender is used for both parameters, and vice versa.

# Working with NOW

If you are dealing with time in your contract here are a few helpers for you:

>>> pytezos.now()
1568391425  # utc unix timestamp

This is the value of NOW that will be during the execution (accurate to 1_s_). Use it as a starting point for your values.

NOTE that NOW returns a timestamp in a string form %Y-%m-%dT%H:%M:%SZ , so you would probably need the second helper:

>>> format_timestamp(pytezos.now())
2019-09-13T16:17:05Z

# Handling FAILWITH and runtime errors

If you want to check that some code path leads to a failed assertion or a runtime error, use this pattern:

with self.assertRaises(MichelsonRuntimeError):
    self.my.call('deadbeef').result(storage=[])

# Setting the gas limit

Finally, If you want to make sure your contract consumes no more than a specified amount of gas, set the gas_limit parameter (default value is the hard limit):

self.my \
    .call('deadbeef') \
    .result(storage='', gas_limit=10000)

# Can I see some working examples?

Check out our tests for a naive NFT implementation (opens new window) and Atomex swap (opens new window) contract.

# Can we go even further?

Yes! In the next post, we will reveal more features of the PyTezos command-line interface, and how to set up fully-fledged CI/CD for contracts using GitHub, Travis CI, and the PyTezos CLI.

Follow us on Twitter (opens new window) to not miss anything, and ask any questions in our Telegram (opens new window) chat.

Cheers!