EOLink 0.2.3: Integration testing with pytest

If you like this content consider tipping in ETH, LINK or an ERC20 stablecoin at this address: 0xf9f6849230cBe10200B43dB103511b778898e71C

Code associated with this post is available on Github (for this post, see Tag v0.0.2)

To this point in the BrightLink project development, I have only built the skeleton of the financial infrastructure. A donor deposits funds into the contract, where it is moved into an Aave lending pool to generate interest. After some time, the funds are withdrawn from Aave and distributed among three accounts depending upon whether the value of a “trigger” variable exceeds a threshold that is defined during the contract deployment. In the previous post I tested the contract using specific unit tests, but only for static values for the trigger variable. However, it is critical that this code works for a range of trigger values both below and above the threshold value that determines where to pay out to.

This requires some slightly more advanced testing procedures. For each value of the trigger variable, the system must be in a known, consistent state such that the value of the trigger variable is the only thing that changes in each iteration of the test. The testing also has the potential to take a significiant amount of time to complete as there are additional transactions that reset the state of the contract each iteration and many iterations might be desirable. For these reasons, I decided to split the tests into two subdirectories inside /tests. One subdirectory contains the unit tests, and the other will contain the new integration test. This mirrors the advice in this Curve tutorial. In the bash terminal:

$ cd ./BrightLink

$ mkdir ./tests/unitary

$ mkdir ./tests/integrative

$ mv ./tests/* ./tests/unitary/

$ touch ./tests/integrative/conftest.py

Now the existing tests are stored in /unitary and there is a new /integrative directory available for the new integration tests.

The contents of conftest.py can remain identical to those in the unitary conftest.py, except that set_trigger_value is not longer required, since we no longer wish for this to be a constant value. Otherwise, conftest.py can remain the same as before:

import pytest

from brownie import (
    Contract,
    accounts,
    network,
)

@pytest.fixture
def checkNetwork():
    assert network.show_active() == 'kovan'
    return

@pytest.fixture
def set_threshold():
    return 5

@pytest.fixture(scope="module")
def getDeployedContract():
    return Contract('0x15416033dBe9478e436d9DFfb625A5ab7758146D')

@pytest.fixture(scope='module')
def set_deposit_amount():
    return 5000e18

@pytest.fixture(scope='module')
def load_owner():
    owner = accounts.load('main')
    return owner

@pytest.fixture(scope='module')
def load_customer():
    customer = accounts.load('account2')
    return customer

@pytest.fixture(scope='module')
def load_donor():
    donor = accounts.load('account3')
    return donor

With conftest in place, the testing script can be built. I named the testing script test_aave.py and it begins with some simple imports and a small function that checks the initial balances of each wallet are consistent with expectations.

import pytest
import time
from brownie import (

    interface
)

def test_initial_balances(load_customer):


    assert dai.balanceOf(contract) ==0
    assert adai.balanceOf(contract)==0
    assert dai.balanceOF(load_customer)==0

    return

Now, the challenge is to devise a function that includes all of the functionality of the contract in such a way that the state of the contract and the wallets are identical in each iteration up to the point that the iterating variable (“trigger”) has some effect on the contract outcome. This is required because the iteration is only occurring in Python, it is not a feature of Solidity or the Kovan testnet, meaning there is no automatic rolling back of transactions at the end of each iteration. If in one iteration there is a transfer of DAI between wallets, then the post-transfer balance of the wallets involved persists into the next iteration, meaning test conditions have changed. Therefore, specific functions that reset the test conditions at the end of each iteration are needed.

Pytest includes a decorator that enables repeated function calls with changing variable values where each iteration is treated as a separate test. This is known as “parametrization”. We wish to iterate over the trigger variable, and we can define the function as follows:

@pytest.mark.parametrize('trigger',[0,3,5,7])
def test_system(set_deposit_amount, getDeployedContract, load_owner, load_customer, load_donor, set_threshold, trigger):

In this function, the values of trigger are 0, 3, 5, 7. Each iteration will update the value of “trigger” with these values. Inside the function, the two tokens should be defined, then some DAI is transferred to the contract.

@pytest.mark.parametrize('trigger',[0,3,5,7])
def test_system(set_deposit_amount, getDeployedContract, load_owner, load_customer, load_donor, set_threshold, trigger):

    dai = interface.IERC20('0xFf795577d9AC8bD7D90Ee22b6C1703490b6512FD')
    adai = interface.IERC20('0xdCf0aF9e59C002FA3AA091a46196b37530FD48a8')
    contract = getDeployedContract

    dai.transfer(contract,set_deposit_amount,{'from':load_donor})

Now, the contract functions can be used to set the deposit value, move the funds to Aave, wait for some interest to accrue, then withdraw the funds from Aave back into the contract. The function “setDummyTrigger()” is then used to define the trigger value in the contract. Here, we pas the function the variable “trigger” which pytest has set to one of the values from the list provided in the decorator.

    contract.lockDepositBalance({'from':load_owner})

    contract.depositFundsToAave({'from':load_owner})
    
    time.sleep(30)

    contract.WithdrawFundsFromAave({'from': load_owner})

    contract.setDummyTrigger(trigger,{'from':load_owner})

Next, some check to make sure the contract balances are consistent with expectation before releasing any funds. In preparation for the final checks that the wallet balances have updated correctly, the current balances are assigned to initialXBalance variables.

    assert adai.balanceOf(contract) == 0
    assert dai.balanceOf(contract) > set_deposit_amount

    initialOwnerBalance = dai.balanceOf(load_owner)
    initialCustomerBalance = dai.balanceOf(load_customer)
    initialDonorBalance = dai.balanceOf(load_donor)

With those checks passed, the contract’s retrieveDAI() function is called, which distributes the funds according to the trigger value. Depending on whether the trigger value is greater than the threshold set on contract deployment, the DAI should be transferred to either the customer or donor wallet. The list of values provided in the decorator span both conditions, so in some iterations the balance should transfer to the customer, in other iterations it should transfer to the donor. This is tested using a series of asserts under a if/else condition:

    contract.retrieveDAI({'from':load_owner})


    if trigger > set_threshold:

        assert adai.balanceOf(contract) == 0
        assert dai.balanceOf(contract) == 0
        assert dai.balanceOf(load_owner) > initialOwnerBalance
        assert dai.balanceOf(load_customer) == initialCustomerBalance+set_deposit_amount
        assert dai.balanceOf(load_donor) == initialDonorBalance
    
    else:
        assert adai.balanceOf(contract) == 0
        assert dai.balanceOf(contract) == 0
        assert dai.balanceOf(load_owner) > initialOwnerBalance
        assert dai.balanceOf(load_customer) == initialCustomerBalance
        assert dai.balanceOf(load_donor) == initialDonorBalance+set_deposit_amount

Finally, these transactions will change the balance of each wallet, so there are some final conditional transactions that reset the balances back to the initial state, ensuring consistent test conditions in each iteration:

    # reset wallet balances before next iteration

    if dai.balanceOf(contract) !=0:
        dai.transfer(load_owner,dai.balanceOf(contract),{'from':contract})
    
    if dai.balanceOf(load_customer) !=0:
        dai.transfer(load_donor,dai.balanceOf(load_customer),{'from':load_customer})


    return

Now, this test can be run using

$ brownie test tests/integrative/ --network kovan

Notice that pytest picks up 5 items even though we only have two test functions. This is because the decorated function interates 4 times, with each iteration considered to be a discrete test. Assuming all is well, the result should be:

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s