Test a transaction

When the logic inside a custom transaction becomes more complex, it becomes complicated to verify that the custom transaction logic is working as expected.

Therefore it is recommended to write unit tests, that verify the logic of the transaction.

Especially for verifying the code of the undoAsset() function, it is convenient to write unit tests. This is due to the fact that the code in the undoAsset() function is only executed if the node discovers itself on a fork with the main chain.

To be on a fork means that the node added some different blocks to the chain than its peers. In order to sync again with the network, it has to remove the blocks that are different, and undo the transactions inside these blocks. To undo the transaction, the undoAsset() function will be called for each transaction inside of the blocks that need to be reverted.

In order to explain how to write unit tests for custom transactions, the applyAsset() method of the Hello World app is used here as example. It can be used as a template to write unit tests for your own custom transactions.

To test the complete logic of a custom transaction, it is required to write unit tests for all methods that are implemented in the custom transaction: validateAsset(), applyAsset() and undoAsset().

Write a test script

First, let’s look at the applyAsset() function of the HelloTransaction.

Snippet of undoAsset(), File: hello_world/transactions/hello_transaction.js
async applyAsset(store) {
    const errors = [];
    const sender = await store.account.get(this.senderId);
    if (sender.asset && sender.asset.hello) {
        errors.push(
            new TransactionError(
                'You cannot send a hello transaction multiple times',
                sender.asset.hello,
                '.asset.hello',
                this.asset.hello
            )
        );
    } else {
        sender.asset = { hello: this.asset.hello };
        store.account.set(sender.address, sender);
    }
    return errors;
}

To test that the applyAsset() function is working as expected, write a unit test as shown in the example here: hello_transaction.test.js:

File: transactions/tests/hello_transaction.test.js
const HelloTransaction = require('../hello_transaction');
const { TransactionError } = require('@liskhq/lisk-transactions');
const { when } = require('jest-when');

describe('Hello Transaction', () => {
    let storeStub;
    beforeEach(() => {
        storeStub = { (1)
            account: {
                get: jest.fn(),
                set: jest.fn(),
            },
        };
    });

    it('should save the hello string in the senders account assets', async () => { (2)
        // Arrange
        const asset = {
            hello: 'my hello message',
        };

        const senderId = '16313739661670634666L';
        const senderPublicKey = 'c094ebee7ec0c50ebee32918655e089f6e1a604b83bcaa760293c61e0f18ab6f';
        const mockedSenderAccount = {
            address: '16313739661670634666L',
            publicKey: 'c094ebee7ec0c50ebee32918655e089f6e1a604b83bcaa760293c61e0f18ab6f',
            asset: {},
        };

        when(storeStub.account.get) (3)
            .calledWith(senderId)
            .mockReturnValue(mockedSenderAccount);

        // Act
        const tx = new HelloTransaction({ (4)
            senderPublicKey,
            asset,
        });
        await tx.applyAsset(storeStub); (5)

        // Assert
        expect(storeStub.account.set).toHaveBeenCalledWith( (6)
            mockedSenderAccount.address,
            {
                address: mockedSenderAccount.address,
                publicKey: mockedSenderAccount.publicKey,
                asset,
            }
        );

    });

    it('should reject the transaction, if the sender already has a hello string in their account.', async () => {
        // Arrange
        const asset = {
            hello: "my hello message",
        };
        const senderId = '16313739661670634666L';
        const senderPublicKey = 'c094ebee7ec0c50ebee32918655e089f6e1a604b83bcaa760293c61e0f18ab6f';
        const mockedSenderAccount = {
                address: '16313739661670634666L',
                publicKey: 'c094ebee7ec0c50ebee32918655e089f6e1a604b83bcaa760293c61e0f18ab6f',
                asset: {
                    hello: "Hello world!"
                }
            };
        const errors = [];
        errors.push(
            new TransactionError(
                'You cannot send a hello transaction multiple times',
                mockedSenderAccount.asset.hello,
                '.asset.hello',
                asset.hello
            )
        );

        when(storeStub.account.get)
            .calledWith(senderId)
            .mockReturnValue(mockedSenderAccount);

        // Act
        const tx = new HelloTransaction({
            senderPublicKey,
            asset,
        });

        const result = await tx.applyAsset(storeStub);

        // Assert
        expect(result).toMatchObject(errors);
    });
});
1 The get() and set() functions of the stateStore are mocked by creating stubs in the beforeEach() function. Stubbing provides the opportunity to replace the call of a function with a custom return value.
2 Now the first test can begin, with a short and precise description of the actual test itself.
3 When storeStub.account.get is called with asset.senderId,the return value is replaced with the mockedSenderAccount.
4 A new transaction is created.
5 The applyAsset() function of the transaction is called, and the previously defined storeStub is passed to the applyAsset() function.
6 The test passes if the function storeStub.account.set() is called with the expected parameters.

The first test verifies that the hello string is saved in the senders account assets. Therefore, a check is performed to ensure that the storeStub.account.set() was called with the correct parameters:

Sender address
mockedSenderAccount.address,

and .Updated sender account

{
    address: mockedSenderAccount.address,
    publicKey: 'c094ebee7ec0c50ebee32918655e089f6e1a604b83bcaa760293c61e0f18ab6f',
    asset,
}

If the function was called with the expected parameters, then this proves that the sender account was updated correctly.

In the second test it is necessary to verify that the transaction is rejected, in the case whereby the sender already has a hello string in their account. Therefore, it is necessary to check if the expected TransactionError is returned when the applyAsset() is executed.

Expected transaction error
new TransactionError(
    'You cannot send a hello transaction multiple times',
    mockedSenderAccount.asset.hello,
    '.asset.hello',
    asset.hello
)

Run the test script

To run the test from the command-line, install jest:

npm install jest --global

Now, run the test:

jest hello_transaction.test.js

It should print the test results, as shown in the example below:

PASS  ./hello_transaction.test.js
  Hello Transaction
    ✓ should save the hello string in the senders account assets (10ms)
    ✓ should reject the transaction, if the sender already has a hello string in their account. (1ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        2.132s
Ran all test suites matching /hello_transaction.test.js/i.

In the example shown above, all expectations were met and the test passed.

What else needs to be tested?

Is writing unit tests really enough to ensure the functionality of a custom transaction?

Short answer: The unit tests are sufficient.

Explanation: You may wonder if it is required to write additional functional and integration tests. Be aware that the correct reading and writing of the data to the database is already part of the Lisk SDK software testing, and therefore it is not necessary to test it again for your new custom transaction. Hence, unit tests are generally sufficient to test the functionality of a custom transaction.