Introduction

Testing is a critical step in the development of WAX(EOSIO/Antelope) smart contracts. It ensures that the contract’s functions, logic, and interactions with the blockchain operate as intended. Since smart contracts are immutable once deployed, rigorous testing is essential to prevent costly errors and safeguard user trust. By simulating various scenarios and edge cases, tests help developers verify the correctness, security, and reliability of their contracts, ultimately leading to more robust decentralized applications.

0. Setup

In this article we’ll take a look at testing libraries ‘chai’ and ‘mocha’. You have to install them.

npm install chai
npm install mocha

After that you should create a folder called ‘tests’ and every file inside it should be called *.test.js. For example ‘sample.test.js’.

To execute your tests you should call.

mocha tests
  1. Simplest example

For testing we will use ‘chai’ and ‘mocha’ libraries. For interacting with WAX blockchain we will use ‘eosjs’. Here’s the simplest example for testing.

Firstly we import all the necessary libs and initialize them with our data.

import { Api, JsonRpc } from 'eosjs'
import { JsSignatureProvider } from 'eosjs/dist/eosjs-jssig.js'
import fetch from 'node-fetch' // required for eosjs
import { TextEncoder, TextDecoder } from 'util'


import { expect } from 'chai'


// Configuration
const WAX_TESTNET_ENDPOINT = 'https://waxtest-hyperion.alcor.exchange'


const user_name = ‘your_account’
const user_pk = ‘your_key’


const rpc = new JsonRpc(WAX_TESTNET_ENDPOINT, { fetch }); // Update URL if necessary
const signatureProvider = new JsSignatureProvider([user_pk]); // Replace with your private key
const api = new Api({ rpc, signatureProvider, textDecoder: new TextDecoder(), textEncoder: new TextEncoder() });

After that all the tests will be organized in a following way

describe('Group of tests', function() {
   this.timeout(15000)


   it('should be ...', async () => {})
   it('should do ...', async () => {})
   it('should not throw ...', async () => {})
})

Now let’s create our first test-case

describe('Token Contract', function () {
   this.timeout(15000);


   it('should send tokens', async () => {
       const result = await api.transact({
           actions: [{
               account: 'eosio.token',
               name: 'transfer',
               authorization: [{
                   actor: user_name,
                   permission: 'active',
               }],
               data: {
                   from: user_name,
                   to: 'toknwaxstest',
                   quantity: '1.00000000 WAX',
                   memo: 'test send'
               },
           }]
       }, {
           blocksBehind: 3,
           expireSeconds: 30,
       });


       expect(result).to.have.property('transaction_id');
       expect(result).to.have.property('processed');
   })
})

This code tries to send 1 WAX from user_name to some other account. If the result is actually valid then expect doesn’t throw and the test is passed. 

2. Describing most popular usages of chai.expect

2.1. Equality

  it('should show equality checks', async () => {
       expect(10).to.equal(10)
       expect("WAX").to.equal("WAX")


       expect(1).to.not.equal(0)


       expect([1, 2, 3]).to.deep.equal([1, 2, 3])
       expect({ key: "value" }).to.deep.equal({ key: "value" })
   })

2.2. Range checks

 it('should show range checks', async () => {
       expect(10).to.be.within(5, 15)
       expect(10).to.be.above(5)
       expect(10).to.be.below(15)
       expect(10).to.be.above(5).and.to.be.below(15)
   })

2.3. Containment

 it('should show containment', async () => {
       expect("1.00000000 WAX").to.include("WAX")
       expect([1, 2, 3]).to.include(2)
       expect([1, 2, 3]).to.not.include(0)


       const sample = { name: user_name, key: user_pk };
       expect(sample).to.include({ name: user_name })
   })

2.4. Properties check

 it('should show properties check', async () => {
       const sample = { name: user_name, key: user_pk };


       expect(sample).to.have.property("name", user_name)
       expect(sample).to.have.property("key")
   })

2.5. Length check

  it('should show length check', async () => {
       expect("WAX").to.have.lengthOf(3)
       expect([1, 2, 3, 4]).to.have.lengthOf(4)
   })

2.6. Boolean checks

 it('should show boolean checks', async () => {
       expect(true).to.be.true
       expect(false).to.be.false
   })

2.7. Exceptions handling

  it('should show expections handling', async () => {
       expect(
           () => { throw new Error("Error"); }
       ).to.throw();


       expect(
           () => { throw new Error("Error"); }
       ).to.throw("Error");




       async function test_throw() {
           throw new Error("WAX Error")
       }


       try {
           await test_throw();
       } catch (error) {
           expect(error.message).to.equal("WAX Error")
       }
   })

3. Usage of testing library for WAX Farming game

For example we will test staking and quests

3.1. Get staking data

Firstly let’s see how to read tables content and how to use expect to validate it.

  it('should fetch amount of staked WAX', async () => {
       const result = await rpc.get_table_rows({
           code: contract_name,
           scope: contract_name,
           table: 'balance',
           lower_bound: user_name,
           upper_bound: user_name,
           limit: 1
       })


       expect(result).to.have.property('rows')


       const result_rows = result.rows
       expect(result_rows).to.have.lengthOf(1)


       const user_staked_row = result_rows[0]
       expect(user_staked_row).to.have.property("owner", user_name)
       expect(user_staked_row).to.have.property("quantity")


       const user_staked_quantity = user_staked_row["quantity"]
       expect(user_staked_quantity).to.include("WAX")


       const user_staked_amount = parse_quantity(user_staked_quantity, 'WAX')
       expect(user_staked_amount).to.be.a("number")
   })

Here we consequently ensure that data is returned as expected

  1. ‘result’ contains ‘rows’
  2. ‘rows’ contain 1 record
  3. this record contains correct ‘owner’ and some ‘quantity’
  4. ‘quantity’ is of ‘WAX’
  5. ‘parsed_quantity’ is a number

Here’s an implementation of a parsed quantity

function parse_quantity(quantity, symbol) {
   const balance = quantity.replace(` ${symbol}`, '')
   return parseFloat(balance)
}

Later we’ll use a short function for getting a staked balance

async function get_staked_balance(account) {
   const result = await rpc.get_table_rows({
       code: contract_name,
       scope: contract_name,
       table: 'balance',
       lower_bound: account,
       upper_bound: account,
       limit: 1
   })


   const result_rows = result.rows
   const user_staked_row = result_rows[0]
   const user_staked_quantity = user_staked_row["quantity"]
   const user_staked_amount = parse_quantity(user_staked_quantity, 'WAX')


   return user_staked_amount
}

Let’s break down this code and explain its building blocks.

    const result = await rpc.get_table_rows({
           code: contract_name,
           scope: contract_name,
           table: 'balance',
           lower_bound: user_name,
           upper_bound: user_name,
           limit: 1
       })

This function gets all the rows from the table ‘balance’ which belongs to ‘contract_name’. The scope is ‘contract_name’. It returns a dictionary with key ‘rows’ which contain all the rows matching the given key.

       expect(result).to.have.property('rows')

       const result_rows = result.rows
       expect(result_rows).to.have.lengthOf(1)

       const user_staked_row = result_rows[0]

This code checks that ‘result’ indeed has a key called ‘rows’. After that we check that the length of the array ‘rows’ is exactly 1 (as expected for a unique primary key). 

     expect(user_staked_row).to.have.property("owner", user_name)
       expect(user_staked_row).to.have.property("quantity")


       const user_staked_quantity = user_staked_row["quantity"]
       expect(user_staked_quantity).to.include("WAX")


       const user_staked_amount = parse_quantity(user_staked_quantity, 'WAX')
       expect(user_staked_amount).to.be.a("number")
   })

The last part of code checks that all the necessary fields can be found in the table row.We check that ‘owner’ is truly our account. Also we check that the row has field ‘quantity’ of WAX. And it also checks that if we remove ‘WAX’ from ‘quantity’ string then all we’re left with is a number.

3.2. Staking action

Here we check that 

  1. Staking performs without any throws
  2. Balance after staking action is increased exactly by a sent amount
it('should stake WAX', async () => {


       const before_staking = await get_staked_balance(user_name)


       const stake_result = await api.transact({
           actions: [{
               account: 'eosio.token',
               name: 'transfer',
               authorization: [{ actor: user_name, permission: 'active' }],
               data: {
                   from: user_name,
                   to: contract_name,
                   quantity: '1.00000000 WAX',
                   memo: 'stake',
               },
           }],
       }, { blocksBehind: 3, expireSeconds: 30 });


       const after_staking = await get_staked_balance(user_name)


       expect(after_staking - before_staking).to.equal(1)
   })

Let’s break down this code.

       const stake_result = await api.transact({
           actions: [{
               account: 'eosio.token',
               name: 'transfer',
               authorization: [{ actor: user_name, permission: 'active' }],
               data: {
                   from: user_name,
                   to: contract_name,
                   quantity: '1.00000000 WAX',
                   memo: 'stake',
               },
           }],
       }, { blocksBehind: 3, expireSeconds: 30 });

This fragment calls a transaction of sending 1 WAX to the contract. ‘data’ stores all the action inputs. 

       expect(after_staking - before_staking).to.equal(1)

Here we check that updated balance is indeed increased by 1 WAX.

3.3 Quest creation

   it('should create a quest', async () => {
       const add_result = await api.transact({
           actions: [{
               account: contract_name,
               name: 'addquest',
               authorization: [{ actor: contract_name, permission: 'active' }],
               data: {
                   player: user_name,
                   type: 'tokens',
                   required_amount: 3e8
               },
           }],
       }, { blocksBehind: 3, expireSeconds: 30 });


       const result = await rpc.get_table_rows({
           code: contract_name,
           scope: contract_name,
           table: 'quests',
           lower_bound: user_name,
           upper_bound: user_name,
           limit: 1
       })


       expect(result).to.have.property("rows")
       expect(result.rows).to.not.be.empty


       const result_row = result.rows[0]
       expect(result_row).to.have.property("quests")
       expect(result_row.quests).to.not.be.empty


       const quest = result_row.quests.at(-1)
       expect(quest).to.have.property("type", "tokens")


       expect(quest).to.have.property("required_amount")
       const req_amount = parseFloat(quest.required_amount)
       expect(req_amount).to.equal(3e8)


       expect(quest).to.have.property("current_amount")
       const cur_amount = parseFloat(quest.current_amount)
       expect(cur_amount).to.equal(0)
   })

Here’s explanation of a quest creation

     const add_result = await api.transact({
           actions: [{
               account: contract_name,
               name: 'addquest',
               authorization: [{ actor: contract_name, permission: 'active' }],
               data: {
                   player: user_name,
                   type: 'tokens',
                   required_amount: 3e8
               },
          }],
       }, { blocksBehind: 3, expireSeconds: 30 });

We add a quest called ‘tokens’ with ‘required_amount’ = 3e8 to our player.

 const result = await rpc.get_table_rows({
           code: contract_name,
           scope: contract_name,
           table: 'quests',
           lower_bound: user_name,
           upper_bound: user_name,
           limit: 1
       })

       expect(result).to.have.property("rows")
       expect(result.rows).to.not.be.empty

       const result_row = result.rows[0]
       expect(result_row).to.have.property("quests")
       expect(result_row.quests).to.not.be.empty

       const quest = result_row.quests.at(-1)

After that we get the quest the player has and try to get the last one added.

       expect(quest).to.have.property("type", "tokens")

       expect(quest).to.have.property("required_amount")
       const req_amount = parseFloat(quest.required_amount)
       expect(req_amount).to.equal(3e8)

       expect(quest).to.have.property("current_amount")
       const cur_amount = parseFloat(quest.current_amount)
       expect(cur_amount).to.equal(0)

Here we check that quest type is indeed ‘tokens’, required_amount is truly 3e8 and no tokens are staked yet.

3.4. Staking for a quest

We use a short function to get all the user’s quests

async function get_quests(account) {
   const result = await rpc.get_table_rows({
       code: contract_name,
       scope: contract_name,
       table: 'quests',
       lower_bound: account,
       upper_bound: account,
       limit: 1
   })

   return result.rows[0].quests
}

This one stakes token and checks if the amount was updated correctly

 it('should stake tokens and check quest status', async () => {
       const quest_before = (await get_quests(user_name)).at(-1)

       const stake_result = await api.transact({
           actions: [{
               account: 'eosio.token',
               name: 'transfer',
               authorization: [{ actor: user_name, permission: 'active' }],
               data: {
                   from: user_name,
                   to: contract_name,
                   quantity: '3.00000000 WAX',
                   memo: 'stake',
               },
           }],
       }, { blocksBehind: 3, expireSeconds: 30 });

       const quest_after = (await get_quests(user_name)).at(-1)

       const amount_before = parseFloat(quest_before.current_amount)
       const amount_after = parseFloat(quest_after.current_amount)


       expect(amount_after - amount_before).to.equal(3e8)
   })

Explanation

     const quest_before = (await get_quests(user_name)).at(-1)

Here we get the last quest user has.

const stake_result = await api.transact({
           actions: [{
               account: 'eosio.token',
               name: 'transfer',
               authorization: [{ actor: user_name, permission: 'active' }],
               data: {
                   from: user_name,
                   to: contract_name,
                   quantity: '3.00000000 WAX',
                   memo: 'stake',
               },
           }],
       }, { blocksBehind: 3, expireSeconds: 30 });

We stake 3 WAX (amount required for this quest).

       const quest_after = (await get_quests(user_name)).at(-1)
       const amount_before = parseFloat(quest_before.current_amount)
       const amount_after = parseFloat(quest_after.current_amount)

       expect(amount_after - amount_before).to.equal(3e8)

And here we check that quest state was updated correctly.

3.5. Completing a quest

it('should complete the quest', async () => {


       const last_quest_index = (await get_quests(user_name)).length - 1
       const cmplt_result = await api.transact({
           actions: [{
               account: contract_name,
               name: 'cmpltquest',
               authorization: [{ actor: contract_name, permission: 'active' }],
               data: {
                   player: user_name,
                   quest_index: 0
               },
           }],
       }, { blocksBehind: 3, expireSeconds: 30 });
   })

Summary

Smart contracts play a vital role in blockchain ecosystems by facilitating secure and automated interactions. Testing is essential to ensure their reliability, as bugs or vulnerabilities can lead to significant consequences due to their immutability. Effective testing involves simulating real-world scenarios to validate functionality, security, and performance, helping developers deploy robust and trustworthy contracts.