Testing the blockchain application

How to use the test utility of the Lisk SDK to test your application.

Sample code

View the complete sample code of this guide on GitHub in the Lisk SDK examples repository.

Prerequisites

To use this guide, it is assumed that the following criteria have been met:

To conveniently test the functionality of modules, plugins, or assets, adjust the already generated test skeletons in the test folder of your application.

After generating a new module and asset, the corresponding skeletons for their unit tests can be found under test/unit/modules/module_name:

/new_app/test/
├── _setup.js
├── commands
│   └── account
│       └── create.spec.ts
├── integration
├── network
├── tsconfig.json
├── unit
│   └── modules
│       └── hello
│           ├── assets
│           │   └── create_hello_asset.spec.ts
│           └── hello_module.spec.ts
└── utils
    └── config.ts

Running the test suite

It is already possible to run the test at this point, though only the most basic tests will be implemented.

To run all test suites, execute the following:

/new_app/
yarn run test

If the tests of the asset fails, e.g. with the following error:

'asset' is declared but its value is never read.

This is most likely due to the fact that the validate() or apply() function of the asset isn’t implemented yet. To fix the error, either remove the unused variables or implement logic which uses them in the corresponding function.

The module test skeleton

The test skeleton of a module doesn’t contain any real tests in the beginning.

Use the existing structure to implement the tests required for the module, and add more tests as needed.

test/unit/modules/hello/hello_module.spec.ts
// import * as modules from '../../../src/app/modules/hello'

describe('HelloModule', () => {
	describe('constructor', () => {
		it.todo('should have valid id');
		it.todo('should have valid name');
	});

	describe('beforeBlockApply', () => {
		it.todo('should execute before block apply');
	});
	describe('afterBlockApply', () => {
		it.todo('should execute after block apply');
	});
	describe('beforeTransactionApply', () => {
		it.todo('should execute before transaction apply');
	});
	describe('afterTransactionApply', () => {
		it.todo('should execute after transaction apply');
	});
	describe('afterGenesisBlockApply', () => {
		it.todo('should execute after genesis apply');
	});
});

The asset test skeleton

The test skeleton for the asset already contains a few simple tests right from the beginning. They were automatically created during the generation of the asset. The remainder of the tests will need to be created by the developer, in order to test all the custom logic of the asset which was implemented after the initialization of the application.

test/unit/modules/hello/create_hello_asset.spec.ts
import { CreateHelloAsset } from '../../../../../src/app/modules/hello/assets/create_hello_asset';

describe('CreateHelloAsset', () => {
  let transactionAsset: CreateHelloAsset;

	beforeEach(() => {
		transactionAsset = new CreateHelloAsset();
	});

	describe('constructor', () => {
		it('should have valid id', () => {
			expect(transactionAsset.id).toEqual(0);
		});

		it('should have valid name', () => {
			expect(transactionAsset.name).toEqual('createNFT');
		});

		it('should have valid schema', () => {
			expect(transactionAsset.schema).toMatchSnapshot();
		});
	});

	describe('validate', () => {
		describe('schema validation', () => {
            it.todo('should throw errors for invalid schema');
            it.todo('should be ok for valid schema');
        });
	});

	describe('apply', () => {
        describe('valid cases', () => {
            it.todo('should update the state store');
        });

        describe('invalid cases', () => {
            it.todo('should throw error');
        });
	});
});

Writing unit tests

This example shows how to write unit tests for the module and asset from the previous guide How to create a command.

For more information about the different features of the test suite, check out the reference page The Lisk SDK testing utilities

Unit tests for the transaction asset

Imports

Add the following lines at the top of create_hello_asset.spec.ts to import the required resources for the tests.

import { CreateHelloAsset } from '../../../../../src/app/modules/hello/assets/create_hello_asset'; (1)
import { testing, StateStore, ReducerHandler, codec } from 'lisk-sdk'; (2)
import { HelloModule } from '../../../../../src/app/modules/hello/hello_module'; (3)
1 CreateHelloAsset: The asset which is tested here.
2 The following is imported from the lisk-sdk package:
  • testing contains the functions of the Lisk SDk test suite.

  • StateStore: See the state store.

  • ReducerHandler: See ReducerHandler.

  • codec: contains functions for encoding and decoding data.

3 HelloModule: is used in createDefaultAccount() to create a default account with the correct account properties.

Testing the validate() function

As a reminder, the validate() function of the asset CreateHelloAsset is shown below:

validate() function of create_hello_asset.ts
public validate({ asset }): void {
      if (asset.helloString == "Some illegal statement") {
          throw new Error(
              'Illegal hello message: Some illegal statement'
          );
      }
    }

To verify that the function is implemented correctly, write 2 tests to check if the following occurs:

  1. An error is thrown, if the hello message equals some illegal statement

  2. No error is thrown for a valid schema

The function createValidateAssetContext() is used for both tests to create a context for the validate() function.

In the first test, where an error is expected, a context with an invalid asset parameter with the helloString: 'Some illegal statement' is created, whereas in the second test a valid helloString property is passed.

After the context is created, both tests will call the validate() function with the context and the result is checked.

If all tests pass, this verifies that the validate() function behaves exactly as expected.

Tests for validate()
describe('validate', () => {
    describe('schema validation', () => {
        it('should throw error if hello message equals some illegal statement', () => {
            const context = testing.createValidateAssetContext({
                asset: { helloString: 'Some illegal statement' },
                transaction: { senderAddress: Buffer.alloc(0) } as any,
            });
            expect(() => transactionAsset.validate(context)).toThrow(
                'Illegal hello message: Some illegal statement',
            );
        });
        it('should be ok for valid schema', () => {
            const context = testing.createValidateAssetContext({
                asset: { helloString: 'Some valid statement' },
                transaction: { senderAddress: Buffer.alloc(0) } as any,
            });

            expect(() => transactionAsset.validate(context)).not.toThrow();
        });
    });
});

Testing the apply() function

As a reminder, the apply() function of the asset createHelloAsset is shown below:

apply() function of create_hello_asset.ts
public async apply({ asset, transaction, stateStore }): Promise<void> {
    // 1. Get account data of the sender of the hello transaction
    const senderAddress = transaction.senderAddress;
    const senderAccount = await stateStore.account.get(senderAddress);

    // 2. Update hello message in the senders account with thehelloString of the transaction asset
    senderAccount.hello.helloMessage = asset.helloString;
    stateStore.account.set(senderAccount.address, senderAccount);

    // 3. Get the hello counter from the database
    let counter;
    let counterBuffer = await stateStore.chain.get(
      CHAIN_STATE_HELLO_COUNTER
    );

    counter = counterBuffer ? codec.decode(
        helloCounterSchema,
        counterBuffer
    ) : { helloCounter: 0 };


    // 5. Increment the hello counter +1
    counter.helloCounter++;

    // 6. Encode the hello counter and save it back to the database
    await stateStore.chain.set(
      CHAIN_STATE_HELLO_COUNTER,
      codec.encode(helloCounterSchema, counter)
    );
}

To verify that the function is implemented correctly, write 2 tests to check if the following occurs:

  1. The hello message is updated in the sender account with the specified hello string.

  2. The hello counter is incremented by +1.

Similar to the unit tests for the validate() function, a context is prepared using createApplyAssetContext() for the apply() function which can be passed to the function when calling it in each test.

As the context is the same for every test, it is recommended to firstly prepare everything before the beforeEach() hook and directly call the apply() function with the context in each test.

create_n_f_t_asset.spec.ts
describe('apply', () => {
    let stateStore: StateStore;
    let reducerHandler: ReducerHandler;
    let account: any;
    let context;
    let counter;

    beforeEach(() => {
        account = testing.fixtures.createDefaultAccount<HelloAccountProps>([HelloModule]);

        counter = { helloCounter: 0 };

        stateStore = new testing.mocks.StateStoreMock({
            accounts: [account],
            chain: { "hello:helloCounter": codec.encode(helloCounterSchema, counter)}
        });

        reducerHandler = testing.mocks.reducerHandlerMock;

        context = testing.createApplyAssetContext({
            stateStore,
            reducerHandler,
            asset: { helloString: 'Some statement' },
            transaction: { senderAddress: account.address, nonce: BigInt(1) } as any,
        });

        jest.spyOn(stateStore.chain, 'get');
        jest.spyOn(stateStore.chain, 'set');
        jest.spyOn(reducerHandler, 'invoke');
    });
});

Additionally, add the following interface at the top of the file:

export interface HelloAccountProps {
    hello: {
        helloMessage: "Hello World";
    };
}

It is used in the beforeEach() hook to create a default account with valid account properties.

The tests for the valid cases test are implemented as shown below:

describe('valid cases', () => {
    it('should update sender account hello message', async () => {
        await transactionAsset.apply(context);
        const updatedSender = await stateStore.account.get<HelloAccountProps>(account.address);

        expect(updatedSender.hello.helloMessage).toEqual("Some statement");
    });
    it('should increment the hello counter by +1', async () => {
        await transactionAsset.apply(context);

        expect(stateStore.chain.set).toHaveBeenCalledWith(
            CHAIN_STATE_HELLO_COUNTER,
            codec.encode(helloCounterSchema, { helloCounter: 1 })
        );
    });
});

Unit tests for the module

Imports

Add the following lines at the top of hello_module.spec.ts to import the required resources for the tests.

test/unit/modules/hello/hello_module.spec.ts
import { helloCounterSchema, CHAIN_STATE_HELLO_COUNTER } from "./assets/create_hello_asset.spec"; (1)
import { CreateHelloAsset } from '../../../../src/app/modules/hello/assets/create_hello_asset'; (2)
import { testing, StateStore, codec } from 'lisk-sdk'; (3)
import { HelloModule } from '../../../../src/app/modules/hello/hello_module'; (4)
1 The following is imported from the unit tests for the asset:
  • helloCounterSchema: used to encode the hello counter for the database.

  • CHAIN_STATE_HELLO_COUNTER: the key under which the hello counter is saved in the database.

2 CreateHelloAsset: used to create a valid test transaction with a hello asset.
3 The following is imported from the lisk-sdk package:
  • testing: contains the functions of the Lisk SDK test suite.

  • StateStore: See the state store.

  • ReducerHandler: See reducerHandler.

  • codec: contains functions for encoding and decoding data.

4 HelloModule: The module which is tested here.

Test preparations

test/unit/modules/hello/hello_module.spec.ts
describe('HelloModule', () => {
    // Creates a new hello module
    let helloModule: HelloModule = new HelloModule(testing.fixtures.defaultConfig.genesisConfig);
    let asset = { helloString: "Hello test" };
    let stateStore: StateStore;
    let account = testing.fixtures.defaultFaucetAccount;
    let context;
    let channel = testing.mocks.channelMock;
    let validTestTransaction;

    // Overrides the init() method of the hello module to use the mocked channel
    helloModule.init({
        channel: channel,
        logger: testing.mocks.loggerMock,
        dataAccess: new testing.mocks.DataAccessMock(),
    });

    // Creates a valid hello transaction for testing
    validTestTransaction = testing.createTransaction({
        moduleID: 1000,
        assetClass: CreateHelloAsset,
        asset,
        nonce: BigInt(0),
        fee: BigInt('10000000'),
        passphrase: account.passphrase,
        networkIdentifier: Buffer.from(
            'e48feb88db5b5cf5ad71d93cdcd1d879b6d5ed187a36b0002cc34e0ef9883255',
            'hex',
        ),
    });

    // Creates an invalid hello transaction for testing
    invalidTestTransaction = testing.createTransaction({
        moduleID: 2,
        assetClass: TokenTransferAsset,
        asset: transferAsset,
        nonce: BigInt(0),
        fee: BigInt('10000000'),
        passphrase: account.passphrase,
        networkIdentifier: Buffer.from(
            'e48feb88db5b5cf5ad71d93cdcd1d879b6d5ed187a36b0002cc34e0ef9883255',
            'hex',
        ),
    });

    beforeEach(() => {
        // Creates a mock of the state store,
        // includse the hello counter in the chain state
        // and sets it to zero.
        stateStore = new testing.mocks.StateStoreMock({
            chain: { "hello:helloCounter": codec.encode(helloCounterSchema,  { helloCounter: 0 })}
        });

        jest.spyOn(channel, 'publish');
        jest.spyOn(stateStore.chain, 'get');
        jest.spyOn(stateStore.chain, 'set');
    });
});

Testing afterTransactionApply()

afterTransactionApply() hook of hello_module.ts
public async afterTransactionApply(_input: TransactionApplyContext) {
    // Publish a `newHello` event for every received hello transaction
    // 1. Check for correct module and asset IDs
    if (_input.transaction.moduleID === this.id && _input.transaction.assetID === 0) {

        // 2. Decode the transaction asset
        let helloAsset : HelloAssetProps;
        helloAsset = codec.decode(
            helloAssetSchema,
            _input.transaction.asset
        );

        // 3. Publish the event 'hello:newHello' and
        // attach information about the sender address and the posted hello message.
        this._channel.publish('hello:newHello', {
            sender: _input.transaction.senderAddress.toString('hex'),
            hello: helloAsset.helloString
        });
    }
}

To verify that the function is implemented correctly, write 2 tests to check if the following occurs:

  1. A new event is published for each applied hello transaction.

  2. A new event is not published for each applied other transaction (not hello).

test/unit/modules/hello/hello_module.spec.ts
describe('afterTransactionApply', () => {
    it('should publish a new event for each applied hello transaction.', async () => {
        context = testing.createTransactionApplyContext ({
            transaction: validTestTransaction,
        });

        await helloModule.afterTransactionApply(context);

        expect(channel.publish).toHaveBeenCalledWith("hello:newHello", {
            sender: account.address.toString('hex'),
            hello: asset.helloString
        });
    });
    it('should not publish a new event for each applied other transaction (not hello).', async () => {
        context = testing.createTransactionApplyContext ({
            transaction: invalidTestTransaction,
        });

        await helloModule.afterTransactionApply(context);

        expect(channel.publish).not.toBeCalled();
    });
});

Testing afterGenesisBlockApply()

afterGenesisBlockApply() hook of hello_module.ts
public async afterGenesisBlockApply(_input: AfterGenesisBlockApplyContext) {
    // Set the hello counter to zero after the genesis block is applied
    await _input.stateStore.chain.set(
        CHAIN_STATE_HELLO_COUNTER,
        codec.encode(helloCounterSchema, { helloCounter: 0 })
    );
}

To verify that the function is implemented correctly, write 2 tests to check if the following occurs:

  1. The hello counter is set to zero, after the genesis block is applied.

test/unit/modules/hello/hello_module.spec.ts
describe('afterGenesisBlockApply', () => {
    it('should set the hello counter to zero', async () => {
        context = testing.createAfterGenesisBlockApplyContext ({
            stateStore: stateStore,
        });

        await helloModule.afterGenesisBlockApply(context);

        expect(stateStore.chain.set).toHaveBeenCalledWith(
            CHAIN_STATE_HELLO_COUNTER,
            codec.encode(helloCounterSchema, { helloCounter: 0 })
        );
    });
});

Testing Actions

actions of hello_module.ts
public actions = {
    amountOfHellos: async () => {
        let count = 0;
        const res = await this._dataAccess.getChainState(CHAIN_STATE_HELLO_COUNTER);
        if (res) {
            count = codec.decode(
                helloCounterSchema,
                res
            );
        }

        return count;
    },
};

To verify that the function is implemented correctly, write a test to check if the following occurs:

  1. The absolute amount of sent hello transactions are returned, when the action is invoked.

test/unit/modules/hello/hello_module.spec.ts
describe('amountOfHellos', () => {
    it('should return the value of hello counter stored in chain state of the hello module', async () => {

        jest.spyOn(helloModule['_dataAccess'], 'getChainState').mockResolvedValue(codec.encode(helloCounterSchema, { helloCounter: 13 }));

        const helloCounter = await helloModule.actions.amountOfHellos();

        expect(helloCounter).toEqual({"helloCounter": 13});
    });
});

Run the tests

After the tests have been implemented, run the test suite again to check if all tests pass successfully:

/new_app/
yarn run test

If the logic and the tests of the asset & module were implemented correctly, all tests should pass:

yarn run v1.22.10
$ jest --passWithNoTests
 PASS  test/unit/plugins/latest_hello/latest_hello_plugin.spec.ts (6.517 s)
 PASS  test/unit/modules/hello/assets/create_hello_asset.spec.ts (8.361 s)
 › 1 snapshot written.
 PASS  test/unit/modules/hello/hello_module.spec.ts (8.466 s)
 › 1 snapshot written.
 PASS  test/commands/account/create.spec.ts (9.077 s)

Snapshot Summary
 › 2 snapshots written from 2 test suites.

Test Suites: 4 passed, 4 total
Tests:       10 todo, 21 passed, 31 total
Snapshots:   2 written, 2 total
Time:        10.538 s, estimated 23 s
Ran all test suites.
✨  Done in 13.87s.

The implementation of the unit tests for the asset CreateHelloAsset is now complete.