Motoko: How to write unit tests?

Hi,

While navigating the CanCan project, I found some unit tests, which is great!
Seems that there’s ic-repl, interesting!

Is there a good read to understand how to properly write unit tests for Motoko? Or maybe share where to learn about the ic-repl

Thank you!

1 Like

For anyone else interested in writing tests, here’s the repo for ic-repl ( GitHub - chenyan2002/ic-repl ) and has some examples. Also, under testing in the list here GitHub - dfinity/awesome-dfinity: A curated list of awesome projects and resources relating to DFINITY and the Internet Computer

1 Like

We are still experimenting with the best way to test Motoko and canisters. There are several tools around today that can help with unit testing:

7 Likes

Thank you very much! I’ll test them starting with the ic-repl :slight_smile:

Great response, thanks.

Quick question about ic-repl: I’m guessing we have to make sure to tear down any objects we create in our ic-repl test scripts? I don’t see it done explicitly in any of the examples (unless they are non-stable variables, in which case the next canister deploy should clear out any test data).

ic-repl is testing at the canister level, there are no objects. The script can handle deployment as well, so that the testing script can deploy a new canister, test method calls, and then delete the canister. As an example, see motoko playground’s upgrade test: https://github.com/dfinity/motoko-playground/blob/main/service/pool/tests/upgrade.test.sh

2 Likes

Hm, interesting.

I don’t think I see the canister deletion logic though? I see this:

fail call ic.install_code(
  record {
    arg = encode (init);
    wasm_module = file "../../../.dfx/local/canisters/backend/backend.wasm";
    mode = variant { upgrade };
    canister_id = S;
  },
);

But that seems to upgrade the canister, not delete it.

Right, I don’t need canister deletion in my test. I always start a local replica and create a new canister for the test.

You can call ic.delete_canister to delete the canister if you want.

1 Like

After checking most of these…
ic-repl - wasn’t for me. Doesn’t have the features I need and feels rigid.
deploying and running a canister which tests another canister - pretty slow

The fastest approach (using motoko compiler - moc) - instantly shows results when I click file save:
moc -r `vessel sources` test/something.mo

If you are on macos this script will monitor your src and test directories and run the test after every change

#!/bin/sh
fswatch -o src test | xargs -n1 -I{} moc -r `vessel sources` test/something.mo

Limitations I have found so far:
Wont work - Debug.print(Principal.toText(Principal.fromActor(this)))

In addition to the approaches listed by @chenyan above, there is also the approach to use the ic-hs IC simulator as a library, and use Haskell to script the testing of the compiled canister. As it works with the compiled wasm, it works with both Motoko and Rust canisters. This is the approach chosen by the Internet Identity project:

1 Like

That’s a good option! Thanks for sharing!

I end up finding Rust easier to build with, which includes tests, then Motoko and the current dev experience.

Thanks for sharing!

Your skills are highly appreciated, had a quick thought on this, and I made an assumption:

  • On the Haskell approach, I wouldn’t have to start a local replica and deploy the Canister.
  • Makes the iterations on implementing code changes and testing faster, then let’s say test via bash or agentjs as it’s require to deploy to a local replica (slow).

Is this correct?

Yes, these are some of the benefits. Also you get to use an expressive programming language to describe your tests, re-use testing libraries (tasty, quickcheck) and your tests are statically typed, has Haskell can import the Candid type of your canister into it’s own type system. This way, if you change your canister interface, the Haskell compiler will tell you which tests to update. You also have better control over the IC, e.g. you can change the canister’s time easily.

The downsides are that you need to know Haskell and that not all behavior of the IC is faithfully represented by ic-hs (e.g. no cycle accounting).

2 Likes

I’d guess that basic understanding of Haskell be enough for common assertions when writing tests? As in it being a client call versus the actual implementation.

For some value of “basic”, certainly :slight_smile: . I mean, you still have to set the dev environment up, deal with the syntax, and if you use record or variants in the Candid interface, these map to row-types data types, which are probably no longer basic use of Haskell. But give it a try, maybe you’ll like it and will happily learn what’s missing, if anything!

Ok thanks! So, it’s not trivial and that’s a pity.

Unfortunately, I won’t have the time to get into Haskell anytime soon (months, or years).

Too bad, it’s worth it :slight_smile:

With all the respect, I did take some time to look into Haskell.

For any other readers interested, if you spend a few minutes looking into this subject, you’ll find @nomeata work on Haskell everywhere, here’s a good start:
https://haskell-via-sokoban.nomeata.de/

Or, maybe start by doing some music / live coding to learn some basics (this one I remember seeing here in London when I was interested in Algorave a few years ago, a pity didn’t pick it up):

:sweat_smile: A bit off-topic but thought about sharing

2 Likes

For the test of the canister-api another way is to use this dart library if you are on a linux or if you want to run this with dart in the browser: ic_tools | Dart Package . Dart is a simple language to pick up. To start with dart, make a main folder, put in it a pubspec.yaml file (simple well documented config) and a folder: ‘lib’ and a file main.dart within the lib folder.

  • pubspec.yaml
  • lib/main.dart

next in the main folder: run the command: dart pub add ic_tools
Then follow the ic_tools readme to set the lib up.
in the main.dart file you can call your canisters, and run dart lib/main.dart heres a sample:

import 'dart:typed_data';
import 'package:ic_tools/ic_tools.dart';
import 'package:ic_tools/candid.dart';

Future<void> main() async {
    Canister ledger = Canister(Principal('ryjl3-tyaaa-aaaaa-aaaba-cai'));
    String icp_id = 'ecedd9b3595d88667b78315da6af8e0de29164ef718f96930e0459017d5d8a04';
    Record record = Record.oftheMap({ 'account': Text(icp_id) });
    Uint8List sponse_bytes = await ledger.call( calltype: 'call', method_name: 'account_balance_dfx', put_bytes: c_forwards([record]) );
    Record icpts_balance_record = c_backwards(sponse_bytes)[0] as Record;
    Nat64 e8s = icpts_balance_record['e8s'] as Nat64;
    double icp_count = e8s.value / 100000000; 
    print(icp_count);
}

Im happy to help if you have some questions on it.

1 Like