Version: 0.19.0

Testing LIGO

Testing LIGO code#

The LIGO command-line interpreter provides sub-commands to directly test your LIGO code. The three main sub-commands we currently support are:

  • test

  • interpret

  • 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 test#

The sub-command test can be used to test a contract using LIGO.

⚠️ Please keep in mind that this sub-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 sub-command alone.

Testing with test: internally#

When running the test sub-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 Tezos testing library.

⚠️ The testing framework has been recently updated, changing a few of the basic functions in the module Test. Please check the next section in case you need to test files using the previous primitives.

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 the type ('parameter, 'storage) typed_address, the compiled program in Michelson of type michelson_program, 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:

// This is testnew.jsligo
type storage = int;
type parameter =
["Increment", int]
| ["Decrement", int]
| ["Reset"];
type return_ = [list<operation>, storage];
// Two entrypoints
let add = ([store, delta]: [storage, int]): storage => store + delta;
let sub = ([store, delta]: [storage, int]): storage => store - delta;
/* Main access point that dispatches to the entrypoints according to
the smart contract parameter. */
let main = ([action, store]: [parameter, storage]) : return_ => {
return [
list([]) as list<operation>, // No operations
match(action, {
Increment:(n: int) => add ([store, n]),
Decrement:(n: int) => sub ([store, n]),
Reset: () => 0})
]
};

We can deploy it and query the storage right after, to check that the storage is in fact the one which we started with:

// This continues testnew.jsligo
let _test = () : bool => {
let initial_storage = 42 as int;
let [taddr, _, _] = Test.originate(main, initial_storage, 0 as tez);
return (Test.get_storage(taddr) == initial_storage);
};
let test = _test();

The test subcommand 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.

ligo test gitlab-pages/docs/advanced/src/testnew.jsligo
// Outputs:
// Everything at the top-level was executed.
// - test exited with value true.

The function Test.transfer_to_contract allows to bake a transaction. It takes a target account of type 'parameter contract, the parameter of type 'parameter and an amount of type tez. This function performs the transaction, and returns a test_exec_result, which tells whether the transaction was successful or not, and in case it was not, it contains a test_exec_error describing the error. There is an alternative version, called Test.transfer_to_contract_exn which performs the transaction and ignores the result, failing in case that there was an error.

We can extend the previous example by executing a transaction that increments the storage after deployment:

// This continues testnew.jsligo
let _test2 = () : bool => {
let initial_storage = 42 as int;
let [taddr, _, _] = Test.originate(main, initial_storage, 0 as tez);
let contr = Test.to_contract(taddr);
let r = Test.transfer_to_contract_exn(contr, (Increment (1)), 1 as mutez);
return (Test.get_storage(taddr) == initial_storage + 1);
}
let test2 = _test2();

The environment assumes a source for the operations which can be set using the function Test.set_source : address -> unit.

Testing with test: externally#

To test the contract externally, we need to create a testing file that is different from the file containing the contract we want to test. This testing file has access to the additional Test module, it will be interpreted, and implicitly update a global state (the tezos context). To do that, the LIGO interpreter uses the same library that Tezos internally uses for testing. Here we will simulate that the contract is actually deployed to an address, and check that the resulting storage is 42 after executing a call to Increment:

Note: the types present in the context of the testing file differ from the ones when writing a contract.

Code insertion are used to write code to be compiled in the context of a contract. Holding all the default types you are used to and the ones you defined in your file (if specified).

let testme_test = "./gitlab-pages/docs/advanced/src/testme.jsligo"
let _test = (): bool => {
let init_storage = Test.compile_expression(Some(testme_test), jsligo`10 as int` as ligo_program);
let [addr, _, _] = Test.originate_from_file(testme_test, "main", init_storage, 0 as tez);
let param = Test.compile_expression(Some(testme_test), jsligo`Increment(32)` as ligo_program);
let transfer_result = Test.transfer(addr, param, 0 as tez);
let result = Test.get_storage_of_address(addr);
let check_ = Test.compile_expression((None() as option<string>), jsligo`42 as int` as ligo_program);
Test.log("okay");
return Test.michelson_equal(result, check_)
}
let test = _test();

Notice that now we wrote the test property inside LIGO, but the contract being tested (written also in LIGO) is in an external file. We used the following functions:

  • Test.compile_expression to compile an expression.

  • Test.originate_from_file to deploy a contract from a file.

  • Test.transfer to simulate an external call taking an address (instead of a contract).

  • Test.get_storage_of_address to check the storage from a contract (instead of a typed_address).

  • Test.log to log variables.

  • Test.michelson_equal to check if the Michelson results are equal, as now the storage is returned in its Michelson representation from Test.get_storage_of_address.

More info about the Test module available when using the sub-command test.

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.

// This is remove-balance.jsligo
type balances = map <address, tez>
let balances_under = (b : balances, threshold:tez) : balances => {
let f = (acc : balances, kv :[address , tez] ) : balances => {
let [k,v] = kv ;
if (v < threshold) { return Map.remove (k,acc) } else {return acc}
};
return Map.fold (f,b,b);
}

Let us imagine that we want to test this function against a range of thresholds with the LIGO test framework.

First, let's define a variable for the file under test and reset the state with 5 bootstrap accounts (we are going to use the bootstrap addresses later)

let under_test : option <string> = Some(("./gitlab-pages/docs/advanced/src/remove-balance.jsligo")) ;
let x = Test.reset_state ( 5 as nat, list([]) as list <nat> );

Now build the balances map that will serve as the input of our test.
Because types/values living in the context of balances_under are not directly accessible from our unit-test code, you will need to compile bootstrap account addresses to michelson using Test.compile_value and inject the resulting michelson value in the map using Test.compile_expression_subst.
Note that within the code injection (e.g. {| <code> |}), you have access to all the types accesible from the tested file.

let bs_addr = (i : int) : michelson_program => {
return (Test.compile_value (Test.nth_bootstrap_account (i)))
} ;
let balances : michelson_program =
Test.compile_expression_subst (
under_test,
jsligo` Map.literal (list ([[ $a1 as address , 10 as mutez ] , [ $a2 as address , 100 as mutez ] , [ $a3 as address , 1000 as mutez ]])) ` as ligo_program,
list ([ ["a1", bs_addr (1)] , ["a2", bs_addr (2)] , ["a3", bs_addr (3)] ])
);

In general, you can use Test.compile_value for simple types such as int; string; nat; bytes; address and pair which will directly compile its argument to michelson without the need of writing a LIGO code injection. Otherwise, use Test.compile_expression_subst or Test.compile_expression to compile an expression written in the same manner as in the tested file.

Test.compile_expression_subst will bind a new variable holding a michelson injection for each hole (e.g. $a1) present in the substitution before compiling the expression.

Our simple test loop will call 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 threshold - of type nat in the test file but of type tez in the tested file - also needs to be dynamically compiled from the test loop and injected in the function call using Test.compile_expression_subst.

We also print the actual and expected sizes for good measure.

let to_tez = (i:nat) : michelson_program => {
return (
Test.compile_expression_subst (
(None () as option<string>),
jsligo` $i * (1 as mutez) ` as ligo_program,
list ([ ["i", Test.compile_value (i)] ])
)) } ;
let _test = () : unit =>
List.iter
( ([threshold , expected_size] : [nat , nat]) : unit => {
let expected_size = Test.compile_value (expected_size) ;
let size =
Test.compile_expression_subst (
under_test,
jsligo` Map.size (balances_under ($b, $threshold)) ` as ligo_program,
list ([ ["b", balances] , ["threshold", to_tez (threshold)]]) );
let unit = Test.log (["expected", expected_size]) ;
let unit = Test.log (["actual",size]) ;
return (assert (Test.michelson_equal (size,expected_size)))
},
list ([ [15 as nat,2 as nat] , [130 as nat,1 as nat] , [1200 as nat,0 as nat]]) );
let test = _test();

You can now execute the test:

Testing with interpret#

The sub-command interpret allows to interpret an expression in a context initialised by a source file. The interpretation is done using Michelson's interpreter.

Let's see how it works on an example. Suppose we write the following contract which we want to test.

// This is testme.jsligo
type storage = int;
type parameter =
["Increment", int]
| ["Decrement", int]
| ["Reset"];
type return_ = [list<operation>, storage];
// Two entrypoints
let add = ([store, delta]: [storage, int]): storage => store + delta;
let sub = ([store, delta]: [storage, int]): storage => store - delta;
/* Main access point that dispatches to the entrypoints according to
the smart contract parameter. */
let main = ([action, store]: [parameter, storage]) : return_ => {
return [
list([]) as list<operation>, // No operations
match(action, {
Increment:(n: int) => add ([store, n]),
Decrement:(n: int) => sub ([store, n]),
Reset: () => 0})
]
};

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

ligo interpret --init-file testme.jsligo "main (Increment (32), 10)"
// Outputs:
// ( LIST_EMPTY() , 42 )

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:

--amount=AMOUNT (absent=0)
AMOUNT is the amount the Michelson interpreter will use for the
transaction.
--balance=BALANCE (absent=0)
BALANCE is the balance the Michelson interpreter will use for the
contract balance.
--now=NOW
NOW is the NOW value the Michelson interpreter will use
(e.g. '2000-01-01T10:10:10Z')
--sender=SENDER
SENDER is the sender the Michelson interpreter transaction will use.
--source=SOURCE
SOURCE is the source the Michelson interpreter transaction will use.