B9lab Logo
Tezos Developer Portal
Developer PortalDeveloper Portal

Testing, Testing, Testing

Testing with the SmartPy IDE and wrapper unit tests


Reading Time: 54 min

In this section, we will take a closer look at testing, more specifically:

  • Testing with the SmartPy IDE, and
  • Unit tests for the wrappers.

SmartPy testing in the IDE

We test our TZMINT web application on different levels with different tools.

Let us start with the SmartPy IDE for testing. First, we need to define our scenario:

@sp.add_test(name= "ContractTest")
def ContractTest():

    # dummy addresses
    organization = sp.address("tz1hRTppkUow3wQNcj9nZ9s5snwc6sGC8QHh")
    buyer1 = sp.address("tz1xbuyer1")
    buyer2 = sp.address("tz1xbuyer2")

    # initial price
    initial_price = sp.tez(1)

    contract= PEQ(
        organization = organization,
        buy_slope = 2000,
        sell_slope = 1000,
        initial_price = initial_price,
        MFG = sp.tez(1000),
        preminted = 0,
        MPT = 1, # minimal period of time in years
        funds_ratio_for_reserve = 90,
        revenues_ratio_for_reserve = 80,
        company_valuation = 1000000,
        total_allocation = 4000,
        stake_allocation = 500,
        termination_events = ["Sale", "Bankruptcy"],
        govRights = "None",
        company_name = "TZMINT Demo"
        )

    buy_price_helper_initial = TestHelper.buy_price_helper_initial
    buy_price_helper_slope = TestHelper.buy_price_helper_slope
    sell_price_helper_slope = TestHelper.sell_price_helper_slope

    scenario = sp.test_scenario()
    scenario += contract

The test starts with the initialization of the contract. Most of the initial parameters are arbitrary. We have some helper functions in order to test our contract:

class TestHelper:
    # Helper functions for the price testing
    @staticmethod
    def buy_price_helper_initial(buyer, tez_amount, buyer_amount_of_tokens, const_price, scenario, contract, first=False):

        # if the user is not in the ledger, the user has 0 tokens
        buyer_amount_of_tokens = 0

        # check if the user is in the ledger
        if not first:
            buyer_amount_of_tokens = scenario.compute(contract.data.ledger[buyer])
        balance = scenario.compute(contract.balance)

        # call the buy entrypoint
        scenario += contract.buy().run(sender = buyer, amount = tez_amount)

        # check that the excess is sent back
        token_amount = sp.utils.mutez_to_nat(tez_amount) // sp.utils.mutez_to_nat(const_price)
        payed_tez_amount = token_amount * sp.utils.mutez_to_nat(const_price)
        scenario.verify(contract.balance ==  sp.utils.nat_to_mutez(payed_tez_amount) + balance)

        # check that the correct amount of tokens is issued
        buyer_amount_of_last_buyed_tokens = scenario.compute(sp.as_nat(contract.data.ledger[buyer]- buyer_amount_of_tokens))
        scenario.verify(buyer_amount_of_last_buyed_tokens == token_amount)

        # return the token_amount for the next call so it can be verified
        return token_amount

    @staticmethod
    def buy_price_helper_left(tez_amount, scenario, contract):
        # left side of the equation, have a look at the content for more information
        return scenario.compute(2 * sp.utils.mutez_to_nat(tez_amount) / contract.data.buy_slope + contract.data.total_tokens * contract.data.total_tokens)

    @staticmethod
    def buy_price_helper_right(buyer, buyer_amount_of_last_buyed_tokens, total_amount, scenario, contract):
        # right side of the equation, have a look at the content for more information
        return scenario.compute(buyer_amount_of_last_buyed_tokens*buyer_amount_of_last_buyed_tokens + total_amount*total_amount + 2*total_amount*buyer_amount_of_last_buyed_tokens)

    @staticmethod
    def buy_price_helper_slope(buyer, tez_amount, buyer_old_token_amount, scenario, contract, first= False):
        
        # if the user is not in the ledger, the user has 0 tokens
        buyer_amount_of_tokens = 0

        # check if the user is in the ledger
        if not first:
            buyer_amount_of_tokens = scenario.compute(contract.data.ledger[buyer])

        # check that buyer has the correct amount of tokens
        scenario.verify(buyer_amount_of_tokens == buyer_old_token_amount)
        total_amount = scenario.compute(contract.data.total_tokens)
        left_side = TestHelper.buy_price_helper_left(tez_amount, scenario, contract)

        # call buy entrypoint
        scenario += contract.buy().run(sender = buyer, amount = tez_amount)
        buyer_amount_of_last_buyed_tokens = scenario.compute(sp.as_nat(contract.data.ledger[buyer]- buyer_amount_of_tokens))
        right_side = TestHelper.buy_price_helper_right(buyer, buyer_amount_of_last_buyed_tokens, total_amount, scenario, contract)

        # check if the correct amount of tokens is issued
        # which is also a check of the buy price
        scenario += contract.square_root_test(x = left_side, y = right_side)

        # return the minted amount of tokens for the next test
        return buyer_amount_of_last_buyed_tokens
    
    def sell_price_helper_slope(tokens, scenario, contract):

        # calculate the amount of tez needed to be sent
        pay_amount = scenario.compute(sp.as_nat(contract.data.total_tokens * sp.as_nat(tokens) * contract.data.sell_slope -
                        sp.as_nat(tokens * tokens) * contract.data.sell_slope / 2
                        ) +
                        contract.data.sell_slope * sp.as_nat(tokens) *
                        contract.data.burned_tokens * contract.data.burned_tokens /
                        sp.as_nat(2 * (contract.data.total_tokens - contract.data.burned_tokens)))

        # return pay_amount for the verification
        return pay_amount

Now we can start testing for buy in the initial phase:

    # buy some tokens in the intial phase, verify the token amounts and the buy price
    # and verify that the excess is sent back
    buyer1_token_amount  = buy_price_helper_initial(buyer1, (sp.tez(500) + sp.mutez(1000)), 0, initial_price, scenario, contract, True)
    buyer2_token_amount  = buy_price_helper_initial(buyer2, (sp.tez(200) + sp.mutez(3000)), 0, initial_price, scenario, contract, True)
    buyer1_token_amount += buy_price_helper_initial(buyer1, sp.tez(300), 0, initial_price, scenario, contract)

Notice that we write (sp.tez(500) + sp.mutez(1000) explicitly because we expect sp.mutez(1000) to be sent back because of initial_price = sp.tez(1) = sp.mutez(1000000). The same applies to the other tests until we reach the minimum funding goal (MFG):

    # check that the price has not changed
    scenario.verify(contract.data.price == initial_price)
    # check that MFG is reached but surpassed
    scenario.verify(contract.data.MFG == contract.balance)

In addition, we check that the price does not change in the MFG phase - This will be different after the MFG is reached (post-MFG):

    # buy some tokens in the slopy phase
    # verify the amount of issued tokens and the buy price
    buyer1_token_amount += buy_price_helper_slope(buyer1, tez_amount= sp.tez(50), buyer_old_token_amount= buyer1_token_amount, scenario= scenario, contract= contract)
    buyer2_token_amount += buy_price_helper_slope(buyer2, tez_amount = sp.tez(400), buyer_old_token_amount = buyer2_token_amount, scenario = scenario, contract = contract)
    buyer1_token_amount += buy_price_helper_slope(buyer1, tez_amount = sp.tez(100), buyer_old_token_amount = buyer1_token_amount, scenario = scenario, contract = contract)
    
    # verify that the excess is sent back
    buyer1_token_amount += buy_price_helper_slope(buyer1, tez_amount = sp.mutez(51245389), buyer_old_token_amount = buyer1_token_amount, scenario = scenario, contract = contract)

    # verify that the price is higher than the initial_price
    scenario.verify(contract.data.price > initial_price)

The buy_price_helper_initial function should be clear if you look at the code and the comments. We want to explain buy_price_helper_slope in more detail. For that, let us start with the _left and _right helper functions:

    @staticmethod
    def buy_price_helper_left(tez_amount, scenario, contract):
        # left side of the equation, have a look at the content for more information
        return scenario.compute(2 * sp.utils.mutez_to_nat(tez_amount) / contract.data.buy_slope + contract.data.total_tokens * contract.data.total_tokens)

    @staticmethod
    def buy_price_helper_right(buyer, buyer_amount_of_last_buyed_tokens, total_amount, scenario, contract):
        # right side of the equation, have a look at the content for more information
        return scenario.compute(buyer_amount_of_last_buyed_tokens*buyer_amount_of_last_buyed_tokens + total_amount*total_amount + 2*total_amount*buyer_amount_of_last_buyed_tokens)

We want to verify the price calculation of the buy entrypoint in the sloppy phase. Remember, for the calculation we use:

Calculating the buy price post-MFG
Calculating the buy price post-MFG

Now we want to check if x has the correct value.

The regular way to check a calculation like this is to square the equation - keep in mind that the solution of the equation does not change if we keep x>0. We will square but use the square root again:

Calculating the buy price post-MFG
Calculating the buy price post-MFG

Because we use an integer square root in our contract. To make sure the solution is correct, we need to use the integer square root here too.

    @staticmethod
    def buy_price_helper_slope(buyer, tez_amount, buyer_old_token_amount, scenario, contract, first= False):
        
        # if the user is not in the ledger, the user has 0 tokens
        buyer_amount_of_tokens = 0

        # check if the user is in the ledger
        if not first:
            buyer_amount_of_tokens = scenario.compute(contract.data.ledger[buyer])

        # check that the buyer has the correct amount of tokens
        scenario.verify(buyer_amount_of_tokens == buyer_old_token_amount)
        total_amount = scenario.compute(contract.data.total_tokens)
        left_side = TestHelper.buy_price_helper_left(tez_amount, scenario, contract)

        # call buy entrypoint
        scenario += contract.buy().run(sender = buyer, amount = tez_amount)
        buyer_amount_of_last_buyed_tokens = scenario.compute(sp.as_nat(contract.data.ledger[buyer]- buyer_amount_of_tokens))
        right_side = TestHelper.buy_price_helper_right(buyer, buyer_amount_of_last_buyed_tokens, total_amount, scenario, contract)

        # check if the correct amount of tokens is issued
        # which is also a check of the buy price
        scenario += contract.square_root_test(x = left_side, y = right_side)

        # return the minted amount of tokens for the next test
        return buyer_amount_of_last_buyed_tokens

Notice that in addition to the calculation of the both sides, we use:

    scenario += contract.square_root_test(x = left_side, y = right_side)

Where:

    #define a private entrypoint for testing
    @sp.entry_point(private = True)
    def square_root_test(self, params):
        sp.verify(self.square_root(params.x) == self.square_root(params.y))

Because our square_root is not an entrypoint and we want to test a transaction. Keep in mind that a private entrypoint will not be compiled and added to the contract, so those are useful for testing.

Also have a look at how we fetch the number of the tokens issued:

        scenario += contract.buy().run(sender = buyer, amount = tez_amount)
        buyer_amount_of_last_buyed_tokens = scenario.compute(sp.as_nat(contract.data.ledger[buyer]- buyer_amount_of_tokens))

Next, we want to sell some tokens and check the call:

    # check that the correct amount of tez is sent if one token is sold
    # sell 100 tokens with buyer1
    token_amount = 100

    # store the balance before selling
    balance = scenario.compute(contract.balance)

    # calculate the expected tez amount to be received
    pay_amount = sell_price_helper_slope(token_amount, scenario, contract)

    # call the sell entrypoint
    scenario += contract.sell(amount=token_amount).run(sender = buyer1)

    # verify that the correct amount is payed to the user
    scenario.verify(contract.balance == balance- sp.utils.nat_to_mutez(pay_amount))

    # update buyer1_token_amount
    buyer1_token_amount= sp.as_nat(buyer1_token_amount- token_amount)

    # verify the amount of tokens for buyer1
    scenario.verify(buyer1_token_amount == contract.data.ledger[buyer1])

This way we can also check the sell price for one token:

    # check that the correct amount of tez is sent if one token is sold
    # this will also verify the price for a token
    token_amount = 1
    balance = scenario.compute(contract.balance)
    pay_amount = sell_price_helper_slope(token_amount, scenario, contract)
    scenario += contract.sell(amount=1).run(sender = buyer1)
    scenario.verify(contract.balance == balance- sp.utils.nat_to_mutez(pay_amount))

You can also check the price of a token with the output of:

    # Check price for selling 1 token
    scenario += contract.sell(amount=1).run(sender = buyer1)

In the right panel of the IDE:

Test output
Test output

As you can see, the seller (i.e. previous buyer) receives 2.460328 tez for one token.

The last thing to test is the closing and the permissions for calling it:

    # check closing before MPT
    scenario += contract.close().run(sender = organization, valid=False, amount = sp.tez(2400), now= sp.timestamp_from_utc_now().add_days(360))
    # check closing with wrong account
    scenario += contract.close().run(sender = buyer1, valid=False, now= sp.timestamp_from_utc_now().add_days(365))
    # check closing with not enough tez
    scenario += contract.close().run(sender = organization, valid=False, amount = sp.tez(300), now= sp.timestamp_from_utc_now().add_days(365))
    # check closing with correct amount of tez
    scenario += contract.close().run(sender = organization, amount = sp.tez(2400), now= sp.timestamp_from_utc_now().add_days(365))

Unit tests for the wrappers

We use Chai for unit tests. There are different unit tests for each wrapper.

The walletWrapper is very small, so there is not much to test besides the callCounts of the inner functions:

        it("should call only buy in the contract if buy is called", async function() {
            expect(contract).to.have.property("buy");
            const result = await contract.buy(getRandomInt(10));

            expect(buy.callCount).to.equal(1);
            expect(sell.callCount).to.equal(0);
        });

This applies also to the unit tests for the contractWrapper.

The chainWrapper is more complex. We want to make sure it offers the functions the DataHandler needs, so we use to.have.property to make sure:

    ...
            expect(storage).to.have.property("funds_ratio_for_reserve");
            expect(storage).to.have.property("revenues_ratio_for_reserve");
            expect(storage).to.have.property("buy_slope");
            expect(storage).to.have.property("sell_slope");
    ...

In addition, we can pass the real network configuration and test the return types:

    ...
        it("should return the d", async function() {
            const blockchain = chainWrapper(config),
                administrator = await blockchain.d();

            expect(administrator).to.be.a("number");
        });

        it("should return the unlockingDate", async function() {
            const blockchain = chainWrapper(config),
                administrator = await blockchain.unlockingDate();

            expect(administrator).to.be.a("string");
        });
    ...

Integration tests for the walletWrapper and the contractWrapper require a different approach because of the browser extensions needed to use a wallet.

Discuss on Slack
Rate this Page
Would you like to add a message?
Submit
Thank you for your Feedback!