Testing Michelson contracts with PyTezos

No more excuses for not writing tests

Baking Bad
Sep 16, 2019 · 6 min read

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

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 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 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.

Follow the instructions from the documentation at https://baking-bad.github.io/pytezos/ 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 fairly easily.

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

For Ubuntu (snap package manager required):

$ sudo snap install pycharm-community --classic

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):

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

Boilerplate for your integration test class

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.

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).

$ 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.

What we want to check is that the passed parameter is actually concatenated to our 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.

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, 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.

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 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)

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)

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.

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'])

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).

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.

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 1s). 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

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=[])

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)

Check out our tests for a naive NFT implementation and Atomex swap contract.

Using interactive debugger for a complicated test case

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 to not miss anything, and ask any questions in our Telegram chat.

Cheers!

Originally published at https://baking-bad.org on September 16, 2019, where you can find full version of the article.

Tezos Commons

Updates and insights from the global Tezos community

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store