PocketIC: Testing Canisters in Python 🐍

Today we’re announcing a new canister testing platform called PocketIC.

It consists of the PocketIC binary, which can run many concurrent IC instances, and a Python library which allows you to interact with those.

With PocketIC, you can test your canisters with just a few lines of Python code, either by interacting with an IC instance:

from pocket_ic import PocketIC

pic = PocketIC()
canister_id = pic.create_canister()

# use PocketIC to interact with your canisters
pic.add_cycles(canister_id, 1_000_000)
response = pic.update_call(canister_id, method="greeting", ...)
assert(response == 'Hello, PocketIC!')

… or even directly with a canister object:

my_canister = pic.create_and_install_canister_with_candid(...)

# call your canister functions with native Python syntax
respone = my_canister.greeting()
assert(response == 'Hello, PocketIC!')

Getting Started :rocket:

You can start testing your canisters in Python with PocketIC today.
To get started, take a look at the README of PocketIC and check out the examples.

How it Works :gear:

The PocketIC server is built upon the existing StateMachine infrastructure which is already being used by developers from DFINITY, OpenChat, HotOrNot, and others.

We created an HTTP layer on top of the StateMachine, so that its interface is language-agnostic. Now, anyone can create an integration library for PocketIC in any language. We took the first step by providing a Python testing library, which showcases both how to use PocketIC in tests and how to develop against the PocketIC server API.

Furthermore, using the community built IC Python agent ic-py, this library provides a convenient API to parse Candid files into native Python objects with the corresponding canister interfaces.

What Else? :face_with_monocle:

Going forward, we are planning to offer even more capabilities for canister developers, reaching beyond what the StateMachine tests offer today. In the future, you’ll be able to:

  • Use a Rust testing library with PocketIC
  • Save and load checkpoints for fast IC state reuse
  • Use VSCode’s Testing tab to run your canister tests

Feedback & Contributions :busts_in_silhouette:

We would be happy to get your feedback or feature requests on PocketIC.
Feel free to reach out to us through the GitHub repo, or here in the Comments section!

23 Likes

We are facing an issue with pocket_ic. In our scenario a user_index canister manages all canister for users. And when the upgrades run for user_index canister it also upgrades all the individual user canisters in post_upgrade. Previously we used to use state_machine to test this scenario and one call to tick command with advance_time used to upgrade user_index and all individual canisters. With the recent move to pocket_ic tick call is not upgrading all the canisters. Is there a way to determine how many tick calls would needed for the completed upgrade process.

Hi @gravity_vi, thanks for your question. Are you using multiple subnets in PocketIC, or are all your canisters on the same subnet? If your canisters live on different subnets, it might be the case that more ticks are required, see this thread.

It’s on the same subnet

Another potential explanation is the subnet type of the subnet to which the canisters are deployed - application subnets have 10x lower round instruction limits compared to system subnets and thus you might need up to 10x more ticks if your canisters do some work in their pre- and post-upgrade hooks.

Adding to @mraszyk’s comment, this comes from the fact that starting from version 2.0, PocketIC uses an application subnet by default instead of a system subnet to resemble the mainnet more closely.

Thanks @fxgst @mraszyk for the quick response. I will try it out

1 Like

Hello! I am using your framework along with Pytest for testing canisters written in Motoko. I have encountered a difficulty with defining a custom type Types.Record. I understand this question is more related to the ic-py library, but perhaps you might be able to help me.

I am confused about how exactly to describe my “frequency” field, which accepts a variable data type Frequency.

    public type Position = {
        beneficiary : Principal;
        amountToSell : Nat;
        tokenToBuy : Principal;
        tokenToSell : Principal;
        frequency : Frequency;
    };

    public type Frequency = {
        #Daily;
        #Weekly;
        #Monthly;
    };

This is how i define Candid type and vals for it.


    position_type = Types.Record(
        {
            "beneficiary": Types.Principal,
            "amountToSell": Types.Nat,
            "tokenToBuy": Types.Principal,
            "tokenToSell": Types.Principal,
            "frequency": Types.Record(
                {
                    "Frequency": Types.Variant(
                        fields={???}
                    )
                }
            ),
        }
    )
    position_vals = {
        "beneficiary": default_principal.to_str(),
        "amountToSell": 1,
        "tokenToBuy": ckBtc_principal_id.to_str(),
        "tokenToSell": icp_principal_id.to_str(),
        "frequency": ???,
    }

And the next code that i used for call:

    params = {"type": position_type, "value": position_vals}
    result = pic.query_call(canister_id, "openPosition", ic.encode(params=params))

Thank you in advance for any feedback!

Hi Stefs,

If I understand correctly, you are asking how to encode a variant type for candid in json? According to this list, you need a dict with a single key-value pair. It looks like {“Daily”: 1} might do, although I have not tested this.

Does that answer your question?

1 Like

Hi @Stefs-2142,
I think you want to define the variants the following way:

position_type = Types.Record(
        {
            "beneficiary": Types.Principal,
            "amountToSell": Types.Nat,
            "tokenToBuy": Types.Principal,
            "tokenToSell": Types.Principal,
            "frequency": Types.Variant(
                      {
                          "Daily": Types.Null,
                          "Weekly": Types.Null,
                          "Monthly": Types.Null,
                      }
            ),
        }
    )
    position_vals = {
        "beneficiary": default_principal.to_str(),
        "amountToSell": 1,
        "tokenToBuy": ckBtc_principal_id.to_str(),
        "tokenToSell": icp_principal_id.to_str(),
        "frequency": {"Daily": None},
    }

And then call it like this:

    payload = [{"type": position_type, "value": position_vals}]
    result = pic.query_call(canister_id, "openPosition", ic.encode(payload))

Please let us know if that works!

2 Likes

Yep, my question pertains to the encoding of the Variant object. Thanks for your reply!

Fantastic, thanks! Yes, that works)

I’ll pose another quick question on the topic. How should the type be correctly specified in retTypes for it to function properly?

Take my openPosition method, for instance. In my earlier message, I incorrectly referred to it as a query—my mistake, but it didn’t impact the logic. It returns the following type in Motoko:

public type PositionId = Nat;

To specify this type in retTypes, I used:

result = pic.update_call(canister_id, "openPosition", ic.encode(params=payload)) 
assert ic.decode(result, retTypes=[Types.Nat]) == 0

However, the outcome was a result that wasn’t unpacked:

E AssertionError: assert [{'type': 'nat', 'value': 0}] == 0
E + where [{'type': 'nat', 'value': 0}] = <function decode at 0x7fa38401fe20>(b'DIDL\x00\x01}\x00', retTypes=[<ic.candid.NatClass object at 0x7fa384019480>])
E + where <function decode at 0x7fa38401fe20> = ic.decode

Am I correct in thinking that accurately defining retTypes should yield a decoded result of = 0, rather than {'type': 'nat', 'value': 0}?

Just opened a small issue for your framework)

1 Like

Hi @Stefs-2142, decoding candid can be a bit cumbersome. Candid always returns tuples, so first you need to take the first element from the tuple that is returned (there is only one element) with [0]. Then, you get the dict with {'type': ..., 'value' ...}, so to get the value, just add ['value'].
So in total, to get out the result value in your case, you type

ic.decode(result, retTypes=[Types.Nat])[0]['value']

then you get back

0

instead of

[{'type': 'nat', 'value': 0}]

if you just did

ic.decode(result, retTypes=[Types.Nat])

I hope this helps!

1 Like

Hey!
Thank you.
Indeed, this is the exact conclusion I arrived at regarding unpacking. It appears that retType has no impact on the outcome.