Skip to main content
Version: Next

Testing

The LIGO command-line interpreter provides commands to test your LIGO code. It provides three main ways to test code:

  • ligo run test: Runs automated tests in LIGO code

  • ligo run interpret: Interprets a LIGO expression in the context of a LIGO file

  • ligo run dry-run: Simulates running a contract based on a given parameter and storage value

danger

LIGO testing tools are in beta and may change. No production test procedure should rely on these tools alone.

Testing with ligo run test

The command ligo run test runs automated tests on a contract.

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 in a simulated environment, as well as additional helper functions that allow you to control different parameters of the Mavryk testing library.

note

To originate a contract in the test simulation, use the Test.Next.originate function, which accepts these parameters:

  • The contract itself
  • The initial storage value
  • The starting balance of the contract in mav

The function returns an object that has these values:

  • taddr: The address of the deployed contract in the simulation
  • size: The size of the deployed contract in bytes, as an integer
  • code: The Michelson code of the contract

You can get the storage of a deployed contract by passing the address of the contract to the Test.Next.Typed_address.get_storage function.

For example, this LIGO file includes a simple counter contract:

// This is mycontract.jsligo
export namespace MyContract {
export type storage = int;
export type result = [list<operation>, storage];
@entry const increment = (delta : int, storage : storage) : result => [[], storage + delta];
@entry const decrement = (delta : int, storage : storage) : result => [[], storage - delta];
@entry const reset = (_u : unit, _storage : storage) : result => [[], 0];
}

To test the contract, create a function to originate the contract in the test simulation, call it, and verify the result. You can put the test functions in the same file or a separate file.

This example shows a test in a separate file. It follows these basic steps:

  1. It imports the contract file with the import directive.
  2. It creates a function named run_test1 for the test.
  3. In the function, it creates a value for the initial storage of the contract.
  4. It originates the contract to the test simulation with the initial storage.
  5. It verifies that the deployed contract has the storage value.
  6. It calls the increment entrypoint with the Test.Next.Contract.transfer_exn function, passing the entrypoint, the parameter, and 0 mav.
  7. It verifies the updated storage value.
// This is mycontract-test.jligo
#import "gitlab-pages/docs/testing/src/testing/mycontract.jsligo" "MyModule"
const run_test1 = () => {
let initial_storage = 10;
let orig = Test.Next.Originate.contract(contract_of(MyModule.MyContract), initial_storage, 0mav);
Test.Next.Contract.transfer_exn(Test.Next.Typed_address.get_entrypoint("increment", orig.taddr), 5, 0mav);
return Assert.assert(Test.Next.Typed_address.get_storage(orig.taddr) == initial_storage + 5);
};
const test1 = run_test1();

The run test command evaluates all top-level definitions and prints any entries that begin with the prefix test as well as the value that these definitions evaluate to. If any of the definitions fail, it prints a message with the line number where the problem occurred. You can also log messages to the console with the Test.Next.IO.log function.

To run the tests, pass the file with the tests to the run test command. If the file imports other files, pass the folders that contain these files in the --library argument, as in this example:

ligo run test --library gitlab-pages/docs/testing/src/testing/ gitlab-pages/docs/testing/src/testing/mycontract-test.jsligo

The response shows that the functions at the top level of the file ran successfully:

Everything at the top-level was executed.
- test1 exited with value ().

Creating transactions

The function Test.Next.Contract.transfer_exn creates a transaction in the test simulation, as in the example in the previous section. It takes these parameters:

  • The target entrypoint or account to call
  • The parameter to pass
  • The amount of mav to include

If the transaction succeeds, it returns the gas consumption. If it fails, it fails the test.

For greater control, such as to test error conditions and error messages, you can use the function Test.Next.Contract.transfer. The function takes the same parameters but returns an option of the type test_exec_result, which is Fail if the transaction failed and Success if it succeeded. In case of success the value is the gas consumed and in case of failure the value is an object of the type test_exec_error that describes the error.

danger

If you create a transaction with Test.Next.Contract.transfer and the transaction fails, the test does not automatically fail. You must check the result of the transaction to see if it succeeded or failed.

For example, this contract is similar to the contract in an earlier example, but it only allows the number in storage to change by 5 or less with each transaction:

namespace MyContract {
export type storage = int;
export type result = [list<operation>, storage];
@entry const increment = (delta : int, storage : storage) : result =>
abs(delta) <= 5n ? [[], storage + delta] : failwith("Pass 5 or less");
@entry const decrement = (delta : int, storage : storage) : result =>
abs(delta) <= 5n ? [[], storage - delta] : failwith("Pass 5 or less");
@entry const reset = (_u : unit, _storage : storage) : result => [[], 0];
}

This test verifies that the error works by passing a number larger than 5 and handling the error:

const test_failure = () => {
const initial_storage = 10 as int;
const orig = Test.Next.Originate.contract(contract_of(MyContract), initial_storage, 0mav);
const result = Test.Next.Contract.transfer(Test.Next.Typed_address.get_entrypoint("increment", orig.taddr), 50 as int, 0mav);
match(result) {
when(Fail(_x)): Test.Next.IO.log("Failed as expected");
when(Success(_s)): failwith("This should not succeed")
};
}

Generating test accounts

You can use test accounts to simulate real accounts in tests. For example, assume that you want to allow only an administrator account to call the reset entrypoint in the contract from the previous example. This version adds an administrator address to the contract storage. It checks the sender of the transaction in the reset entrypoint and fails if the addresses don't match:

namespace Counter {
type storage = [int, address];
type return_type = [list<operation>, storage];
@entry
const increment = (n: int, storage: storage): return_type => {
const [number, admin_account] = storage;
return [[], [number + n, admin_account]];
}
@entry
const decrement = (n: int, storage: storage): return_type => {
const [number, admin_account] = storage;
return [[], [number - n, admin_account]];
}
@entry
const reset = (_: unit, storage: storage): return_type => {
const [_number, admin_account] = storage;
if (Mavryk.get_sender() != admin_account) {
return failwith("Only the owner can call this entrypoint");
}
return [[], [0, admin_account]];
}
};

To generate test accounts, pass a nat to the Test.Next.Account.address function, which returns an address. Then use the Test.Next.State.set_source function to set the source account for transactions.

This example creates an admin account and user account. It attempts to call the reset entrypoint as the user account and expects it to fail. Then it calls the reset entrypoint as the admin account and verifies that the entrypoint runs correctly:

const test_admin = (() => {
const admin_account = Test.Next.Account.address(0n);
const user_account = Test.Next.Account.address(1n);
// Originate the contract with the admin account in storage
const initial_storage = [10 as int, admin_account];
const orig = Test.Next.Originate.contract(contract_of(Counter), initial_storage, 0mav);
// Try to call the reset entrypoint as the user and expect it to fail
Test.Next.State.set_source(user_account);
const result = Test.Next.Contract.transfer(Test.Next.Typed_address.get_entrypoint("reset", orig.taddr), unit, 0mav);
match(result) {
when(Fail(_err)): Test.Next.IO.log("Test succeeded");
when (Success(_s)): failwith("User should not be able to call reset");
};
// Call the reset entrypoint as the admin and expect it to succeed
Test.Next.State.set_source(admin_account);
Test.Next.Contract.transfer_exn(Test.Next.Typed_address.get_entrypoint("reset", orig.taddr), unit, 0mav);
const [newNumber, _admin_account] = Test.Next.Typed_address.get_storage(orig.taddr);
Assert.assert(newNumber == 0);
}) ()

By default, the test simulation has two test accounts. To create more, pass the number of accounts and a list of their balances or an empty list to use the default balance to the Test.Next.State.Reset function, as in the following example. The default balance is 4000000 mav minus %5 that is frozen so the account can act as a validator.

const test_accounts = () => {
Test.Next.State.reset(3n, [] as list <mav>);
const admin_account = Test.Next.Account.address(0n);
const user_account1 = Test.Next.Account.address(1n);
const user_account2 = Test.Next.Account.address(2n);
Test.Next.IO.log(Test.Next.Address.get_balance(admin_account));
// 3800000000000mumav
Test.Next.IO.log(Test.Next.Address.get_balance(user_account1));
// 3800000000000mumav
Test.Next.IO.log(Test.Next.Address.get_balance(user_account2));
// 3800000000000mumav
}

Testing events

To test events, emit them as usual with the Mavryk.emit function and use the Test.Next.State.last_events function to capture the most recent events, as in this example:

namespace C {
@entry
const main = (p: [int, int], _: unit) => {
const op1 = Mavryk.emit("%foo", p);
const op2 = Mavryk.emit("%foo", p[0]);
return [([op1, op2] as list<operation>), unit];
};
}
const test = () => {
const orig = Test.Next.Originate.contract(contract_of(C), unit, 0mav);
Test.Next.Typed_address.transfer_exn(orig.taddr, Main ([1,2]), 0mav);
return [Test.Next.State.last_events(orig.taddr, "foo") as list<[int, int]>, Test.Next.State.last_events(orig.taddr, "foo") as list<int>];
};

Unit testing functions

You can use the run test command to run unit tests of functions.

A common way of unit testing functions is to create a map of input values and expected output values and iterate over them. For example, consider a map binding addresses to amounts and a function removing all entries in that map that have an amount less than a given threshold:

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

You can test this function against a range of thresholds with the LIGO test framework.

First, include the file under test and reset the state with 5 bootstrap accounts:

#include "./gitlab-pages/docs/testing/src/testing/remove-balance.jsligo"
const test_remove_balance = (() => {
Test.Next.State.reset(5n, [] as list <mav>);

Now build the balances map that serves as the test input:

const balances: balances =
Map.literal([[Test.Next.Account.address(1n), 10mav],
[Test.Next.Account.address(2n), 100mav],
[Test.Next.Account.address(3n), 1000mav]]);

The test loop will call the function with the compiled map defined above, get the size of the resulting map, and compare it to an expected value with Test.Next.Compare.eq.

The call to remove_balances_under and the computation of the size of the resulting map is achieved through the primitive Test.Next.Michelson.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.Next.Michelson.run f v performs the following:

  1. Compiles the function argument f to Michelson f_mich
  2. Compiles the value argument v (which was already evaluated) to Michelson v_mich
  3. 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.

return List.iter(([threshold, expected_size]: [mav, nat]): unit => {
const tester = ([balances, threshold]: [balances, mav]): nat =>
Map.size (remove_balances_under (balances, threshold));
const size = Test.Next.Michelson.run(tester, [balances, threshold]);
const expected_size_ = Test.Next.Michelson.eval(expected_size);
Test.Next.IO.log(["expected", expected_size]);
Test.Next.IO.log(["actual", size]);
return (Assert.assert (Test.Next.Compare.eq(size, expected_size_)))
},
list ([ [15mav, 2n], [130mav, 1n], [1200mav, 0n]]) );
}) ()

Here is the complete test file:

#include "./gitlab-pages/docs/testing/src/testing/remove-balance.jsligo"
const test_remove_balance = (() => {
Test.Next.State.reset(5n, [] as list <mav>);
const balances: balances =
Map.literal([[Test.Next.Account.address(1n), 10mav],
[Test.Next.Account.address(2n), 100mav],
[Test.Next.Account.address(3n), 1000mav]]);
return List.iter(([threshold, expected_size]: [mav, nat]): unit => {
const tester = ([balances, threshold]: [balances, mav]): nat =>
Map.size (remove_balances_under (balances, threshold));
const size = Test.Next.Michelson.run(tester, [balances, threshold]);
const expected_size_ = Test.Next.Michelson.eval(expected_size);
Test.Next.IO.log(["expected", expected_size]);
Test.Next.IO.log(["actual", size]);
return (Assert.assert (Test.Next.Compare.eq(size, expected_size_)))
},
list ([ [15mav, 2n], [130mav, 1n], [1200mav, 0n]]) );
}) ()

You can now execute the test by running this command:

ligo run test --library . gitlab-pages/docs/testing/src/testing/unit-remove-balance-mixed.jsligo

The response shows the expected and actual results of each test run:

# Outputs:
# ("expected" , 2)
# ("actual" , 2)
# ("expected" , 1)
# ("actual" , 1)
# ("expected" , 0)
# ("actual" , 0)
# Everything at the top-level was executed.
# - test exited with value ().

Testing with ligo run interpret

The command ligo run interpret interprets a LIGO expression in a context initialised by a source file. The interpretation is done using Michelson's interpreter.

For example, suppose you have a function that encodes input values into a specific format. This function takes two input values and uses them as the key and value for an entry in a map:

// This is interpret.jsligo
type myDataType = map<int, string>;
const encodeEntry = (a: int, b: string): myDataType => {
return Map.literal([[a, b]]);
}

To encode values with this function, pass the LIGO expression to call the function to the run interpret command and include the LIGO file in the --init-file argument:

ligo run interpret 'encodeEntry(5, "hello")' --init-file gitlab-pages/docs/testing/src/testing/interpret.jsligo

The response is the Michelson-encoded value of the output of the function:

MAP_ADD(5 , "hello" , MAP_EMPTY())

You can use the run interpret command to interpret complex LIGO code and get the output in Michelson, such as formatting parameters for calls to entrypoints.

You can pass these arguments to set parameters for the interpretation:

  • --amount: The amount of mav to send with the transaction; the default is 0
  • --balance: The amount of mav in the contract; the default is 0
  • --now: The current timestamp, such as 2000-01-01T10:10:10Z
  • --sender: The address for the sender of the transaction
  • --source: The address for the source of the transaction

Testing with ligo run dry-run

The ligo run dry-run command runs the contract in a simulated environment. You can use it to test contracts with given parameters and storage values. You pass these arguments to the command:

  • The contract file to run
  • The parameter to pass to the contract, as a LIGO expression
  • The value of the contract storage, as a LIGO expression

For example, this contract stores a number and allows callers to increment it by one:

namespace Counter {
type storage_type = int;
type return_type = [list<operation>, storage_type];
@entry
const main = (_action: unit, storage: storage_type): return_type =>
[[], storage + 1]
}

This command tests the contract with the run dry-run command:

ligo run dry-run -m Counter gitlab-pages/docs/testing/src/testing/counter_simple.jsligo 'unit' '4'

The result shows the new value of the storage:

( LIST_EMPTY() , 5 )

For a more complicated example, this contract stores a map and provides an entrypoint that updates elements in it:

namespace MyContract {
type storage_type = map<nat, string>;
type return_type = [list<operation>, storage_type];
@entry
const update = (param: [nat, string], storage: storage_type): return_type => {
const [index, value] = param;
const updated_map = Map.add(index, value, storage);
return [[], updated_map];
}
}

You can test the entrypoint and view the resulting operations and storage by running this command, which uses an empty map of the same type as the contract storage as the initial value of the storage:

ligo run dry-run -m MyContract gitlab-pages/docs/testing/src/testing/dry-run-complex.jsligo \
'Update(1n, "new value")' \
'Map.empty as map<nat, string>'

Note that the values of the parameter and the initial storage state are both LIGO expressions.

The result shows the empty list of operations and the new value of the storage, expressed by adding elements to an empty map:

( LIST_EMPTY() , MAP_ADD(+1 , "new value" , MAP_EMPTY()) )

You can pass these arguments to set parameters for the transaction:

  • --amount: The amount of mav to send with the transaction; the default is 0
  • --balance: The amount of mav in the contract; the default is 0
  • --now: The current timestamp, such as 2000-01-01T10:10:10Z
  • --sender: The address for the sender of the transaction
  • --source: The address for the source of the transaction