Testing LIGO
Testing LIGO code
The LIGO command-line interpreter provides commands to directly test your LIGO code. The three main commands we currently support are:
ligo run test
ligo run interpret
ligo run dry-run
We will show how to use the first two, while an example on how to use the third one was already explained here.
Testing with ligo run test
The command ligo run test
can be used to test a contract using LIGO.
⚠️ Please keep in mind that this command is still BETA, and that there are features that are work in progress and are subject to change. No real test procedure should rely on this command alone.
When running the ligo run test
command, LIGO code has access to an
additional Test
module. This module provides ways of originating
contracts and executing transactions, as well as additional helper
functions that allow to control different parameters of the Mavryk
testing library.
Note: The LIGO interpreter uses the same library that Mavryk internally uses for testing.
The function Test.originate
allows to deploy a contract in the
testing environment. It takes a contract, which is represented as a
function of type 'parameter -> 'storage -> operation list * 'storage
,
an initial storage of type 'storage
, and an initial balance for the
contract being deployed. This function deploys the contract, and
returns a record value holding: ('parameter, 'storage) typed_address
, the compiled
program in Michelson of type ('parameter, 'storage) michelson_contract
,
and the size of the program of type int
.
The storage of a deployed contract can be queried using the
Test.get_storage
function, that given a typed address ('parameter,
'storage) typed_address
, returns the 'storage
value.
As a concrete example, suppose we have the following contract:
We can deploy it and query the storage right after, to check that the storage is in fact the one which we started with:
The ligo run test
sub-command will evaluate all top-level definitions and print any
entries that begin with the prefix test
as well as the value that these
definitions evaluate to. If any of the definitions are found to have
failed, a message will be issued with the line number where the problem
occurred.
The function Test.transfer
allows to bake a transaction.
It takes a target account of type ('parameter, 'storage) typed_address
, the parameter
of type 'parameter
and an amount of type mav
. This function
performs the transaction, and returns a test_exec_result
which
can be matched on to know whether the transaction was successful or not.
In case of success you will get access to the gas consumed by the execution
of the contract and in case of failure you will get access to a test_exec_error
describing the error.
There is an alternative version, called Test.transfer_exn
which performs the transaction and will only return the gas consumption,
failing in case that there was an error.
We can extend the previous example by executing a transaction that increments the storage after deployment, we also print the gas consumption:
The syntax do { ... }
is equivalent to ( () => { ... } )()
, i.e.
it is a block expression which can contain statements and local declarations.
The environment assumes a source for the operations which can be set
using the function Test.set_source : address -> unit
.
Transfers and originations with tickets
Originating contract storing tickets or transfering to contract accepting tickets requires some extra steps. We will first explain the problems with tickets and then show you how to handle it.
The problem with tickets
There is two kind of operations in the protocol : external and internal.
internal operations
are those created by smart contracts and external operations
are those created from outside the chain
(e.g. using Test.originate
or mavryk-client
for instance) more information here
In the protocol, both external and internal transfer
/origination
operations contains a piece of michelson code representing the parameter
/initial storage
.
Now imagine you have a value of type parameter_ty
/storage_ty
containing a ticket, that you want to transfer or originate,
in the operation data, tickets will be represented in Michelson as pairs:
ticketer address , ticket value , ticket amount
If we try to apply such an operation, the type wouldn't match: ticket of bytes VS some pair. The protocol would not let you do that since you could be creating a ticket out of nowhere unless the operation happens to be forged from within a contract (i.e. "internally")!
In the testing framework - for now - it means using "proxy-contracts" forging the operations using provided a ticket value and a ticket amount.
Proxy ticket contracts
The LIGO standard library provides a Proxy_ticket
module which helps in working with tickets in the Testing framework. Here is the interface of Proxy_ticket
:
init_transfer
accepts:
- a function
mk_param
which given a ticket must return a value of your parameter type
and returns the typed address of a "transfer proxy-contract" which can then be used to do multiple transfers of tickets with the same ticketer address
transfer
accepts :
- the typed address of a "transfer proxy-contract"
- the ticket information (value and amount) together with the destination address
and returns a value of type test_exec_result
originate
accepts:
- the ticket information (value and amount)
- a function
mk_storage
which given a ticket must return a value of your storage type - your contract (having a ticket in its storage type)
Note: Functions
mk_param
andmk_storage
will be executed in the proxy contract itself
Find more detailed information on the API of Proxy_ticket
here
Usages
Transfer
Here is an example using Proxy_ticket.init_transfer
and Proxy_ticket.transfer
:
- import the module above as
Proxy_ticket
- define a contract
C
holding a ticket of string in its parameter type. The contract will just store the value of the received ticket and the address of the sender - originate contract
C
- initialize a "transfer proxy-contract" providing a function to build a parameter out of a ticket
- transfer a ticket with a value
"hello"
and an amount of10
to contractC
- print the storage of contract
C
- transfer a ticket with a value
"world"
and an amount of5
to contractC
- print the storage of contract
C
result:
Note: note that the sender (stored in the contract) matches the address of the proxy contract
Origination
Here is an example using Proxy_ticket.originate
and the type unforged_ticket
:
- import the module above as
Proxy_ticket
- define a contract
main
potentially holding a ticket of bytes in its storage. The contract will just reads the ticket in its storage if present. Note that we define two version of the contract storage type: one for the contract and one for the storage type that we would like to manipulate in our testing logic - we define the
mk_storage
function which simply wraps a ticket into an option type - we define the ticket information for a ticket of value
0x0202
and an amount of15
- we call
originate
and retrieve the address of the newly originated contract - we use the address to fetch the current contract storage using
Test.get_storage_of_address
and decompile it as ahuman_storage
- we read the content of the ticket and perform a series of assertions
result:
Unit testing a function
Consider a map binding addresses to amounts and a function removing all entries in that map having an amount less to a given threshold.
Let us imagine that we want to test this function against a range of thresholds with the LIGO test framework.
First, let's include the file under test and reset the state with 5 bootstrap accounts (we are going to use the bootstrap addresses later)
Now build the balances
map that will serve as the input of our test.
Our simple test loop will call remove_balances_under
with the compiled map
defined above, get the size of the resulting map and compare it to an
expected value with Test.michelson_equal
.
The call to balance_under
and the computation of the size of the resulting map is achieved through the primitive Test.run
.
This primitive runs a function on an input, translating both (function and input)
to Michelson before running on the Michelson interpreter.
More concretely Test.run f v
performs the following:
- Compiles the function argument
f
to Michelsonf_mich
- Compiles the value argument
v
(which was already evaluated) to Michelsonv_mich
- Runs the Michelson interpreter on the code
f_mich
with the initial stack[ v_mich ]
The function that is being compiled is called tester
.
We also print the actual and expected sizes for good measure.
You can now execute the test:
Testing with ligo run interpret
The command ligo run interpret
allows to interpret an expression in a
context initialised by a source file. The interpretation is done using
Michelson's interpreter.
We can see how it works on an example. Suppose we want to test the following contract.
This contract keeps an integer as storage, and has three entry-points:
one for incrementing the storage, one for decrementing the storage,
and one for resetting the storage to 0
.
As a simple property, we check whether starting with a storage of
10
, if we execute the entry-point for incrementing 32
, then we get
a resulting storage of 42
. For checking it, we can interpret the
main
function:
With the argument --init-file
we pass the contract we want to test,
and the sub-command requires also the expression to evaluate in that
context, in this case, a call to our contract (main
) with parameter
Increment (32)
and storage 10
. As a result, we can check that the
resulting storage is 42
(the second component of the pair), and
there are no further operations to execute (the first component).
We can tune certain parameters of the execution by passing them as arguments:
Event testing
Here is how you emit events and fetch them from your tests:
Testing a contract declared as a module or namespace
When declaring the entry points of a contract using @entry
, LIGO generates two hidden values in the module:
- an implicit
main
function, which can be obtained using the keywordcontract_of(C)
whereC
is the namespace or module containing the entry points, and - the input type for that
main
function, which can be obtained using the keywordparameter_of C
.
In the example below, contract_of(C)
is returns the implicitly-declared main
function that calls the increment
or decrement
entry points depending on the argument given, and parameter_of C
is the variant ["Increment", int] | ["Decrement", int]
.
The special constructions contract_of(C)
and parameter_of C
are not first-class functions, they are special syntax which take a module or namespace name as a parameter. This means for example that some_function(contract_of)
or contract_of(some_variable)
are invalid uses of the syntax (a literal module or parameter name must always be passed as part of the syntax).