Chapter 4: Tests

Writing unit tests

In the final chapter of this tutorial, we’ll explore how the application can be tested with unit and network tests.

Writing tests for the application is more and more important, the more complex the blockchain application becomes. Once a certain complexity is reached, it will not be convenient anymore to test the application functionality with the dashboard plugin, the CLI or via a frontend; and writing tests becomes a crucial part to verify the correct behavior of the blockchain application.

For this purpose, a couple of unit tests are added to the application in this chapter, which is followed by a couple of network tests in the next chapter.

To ensure the creation of the tests can be performed in a convenient manner, the The Lisk SDK testing utilities will be used.

Navigate into the test/ folder of the LNS application. The test folder is the correct location to store all kinds of different tests for the blockchain application.

The unit tests are stored in the test/unit/ folder, as the name suggests.

lisk-name-service/lns/test/
.
├── _setup.js
├── commands
├── integration
├── network
├── tsconfig.json
├── unit
│   └── modules
│       └── lns
│           ├── assets
│           │   ├── register.spec.ts
│           │   └── reverse_lookup.spec.ts
│           └── lns.spec.ts
└── utils

As can be seen, there are already some existing test files for the lns module and the assets. These files were auto-generated by Lisk Commander, when the LNS module and the assets were generated.

The existing test files already contain test skeletons, providing a rough structure how to write the required tests.

Testing the Register asset

The complete code of the tests for the Register asset is described below. Most of the code is self-explanatory, but the most important parts of the tests are summarized here to provide a better overview:

Testing the validate() function

Tests for the validate() function of the Register asset.

Write tests to check the following:

  • It should not be possible to set the TTL in the asset to a value lower than 3600 seconds (1 hour).

  • It should throw an error if the name is registered for less than a year.

  • It should throw an error if the name is registered for more than 5 years.

  • It should throw an error if the domain contains an invalid tld.

  • If no errors are thrown, then all asset parameters are valid.

Before each test:

  • Create a new instance of the Register asset.

Use of the SDK testing utilities:

  • testing.createValidateAssetContext(): Returns valid parameters for the validate() function. If the function is called with an empty object, it returns the default parameters for the validate() function. For the test, overwrite the default asset value (undefined) with a valid transaction asset for the Register asset. For the function to be called successfully, overwrite the default transaction value of the context (undefined) with a transaction containing a property senderAddress with a Buffer of size 0. It is not necessary to put a real address here in this case, as it is not used in the tests.

Testing the apply() function

Tests for the apply() function of the Register asset.

Write tests to check the following:

  • Valid cases:

    • It should update the state store with the name hash key.

    • It should update the state store with updated sender account.

    • It should update the state store with correct ttl value.

    • It should update the state store with correct expiry date.

  • Invalid cases:

    • It should throw an error if the name is already registered.

Before each test:

  • Create a new default account for the LNS application.

  • Add the newly created account to the accounts list of the StateStore mock.

  • Spy on the functions stateStore.chain.get and stateStore.chain.set. This allows checking in the tests if the respective functions have been called or not.

Use of the SDK testing utilities:

  • testing.createApplyAssetContext(): Returns valid parameters for the apply() function. If the function is called with an empty object, it returns the default parameters for the apply() function. For the test, overwrite the default asset value (undefined) with a valid transaction asset for the Register asset. Additionally, overwrite the default transaction value of the context (undefined) with a transaction containing a property senderAddress which equals the address of the newly created account.

  • testing.fixtures.createDefaultAccount<LNSAccountProps>([LnsModule]): Used to create a default account for the LNS application.

  • new testing.mocks.StateStoreMock({accounts: [account]}): Creates a mock for the StateStore.

Unit tests for the Register asset

lisk-name-service/lns/test/unit/modules/lns/assets/register.ts
import { addYears } from 'date-fns';
import { StateStore, testing } from 'lisk-sdk';
import { RegisterAsset } from '../../../../../src/app/modules/lns/assets/register';
import { LNSAccountProps } from '../../../../../src/app/modules/lns/data';
import { LnsModule } from '../../../../../src/app/modules/lns/lns_module';
import {
	getKeyForNode,
	getLNSObject,
	getNodeForName,
} from '../../../../../src/app/modules/lns/storage';

// Tests for the Register asset
describe('RegisterAsset', () => {
	let transactionAsset: RegisterAsset;

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

	// Tests for the constructor of the Register asset
	describe('constructor', () => {
	    // Verify that the asset ID is equal to 1.
		it('should have valid id', () => {
			expect(transactionAsset.id).toEqual(1);
		});

		// Verify that the asset name equals `'register'`
		it('should have valid name', () => {
			expect(transactionAsset.name).toEqual('register');
		});

		// Verify that the correct asset schema is used
		it('should have valid schema', () => {
			expect(transactionAsset.schema).toMatchSnapshot();
		});
	});

	describe('validate', () => {
		describe('schema validation', () => {

			it('should throw error if ttl is set less than an hour', () => {
				const context = testing.createValidateAssetContext({
					asset: { name: 'nazar.hussain', ttl: 60 * 60 - 1, registerFor: 1 },
					transaction: { senderAddress: Buffer.alloc(0) } as any,
				});

				expect(() => transactionAsset.validate(context)).toThrow(
					'Must set TTL value larger or equal to 3600',
				);
			});

			it('should throw error if name is registered for less than a year', () => {
				const context = testing.createValidateAssetContext({
					asset: { name: 'nazar.hussain', ttl: 60 * 60, registerFor: 0 },
					transaction: { senderAddress: Buffer.alloc(0) } as any,
				});

				expect(() => transactionAsset.validate(context)).toThrow(
					'You can register name at least for 1 year.',
				);
			});

			it('should throw error if name is registered for more than 5 years', () => {
				const context = testing.createValidateAssetContext({
					asset: { name: 'nazar.hussain', ttl: 60 * 60, registerFor: 6 },
					transaction: { senderAddress: Buffer.alloc(0) } as any,
				});

				expect(() => transactionAsset.validate(context)).toThrow(
					'You can register name maximum for 5 year.',
				);
			});

			it('should throw error if domain contains invalid tld', () => {
				const context = testing.createValidateAssetContext({
					asset: { name: 'nazar.hussain', ttl: 60 * 60, registerFor: 1 },
					transaction: { senderAddress: Buffer.alloc(0) } as any,
				});

				expect(() => transactionAsset.validate(context)).toThrow(
					'Invalid TLD found "hussain". Valid TLDs are "lsk"',
				);
			});

			it('should be ok for valid schema', () => {
				const context = testing.createValidateAssetContext({
					asset: { name: 'nazar.lsk', ttl: 60 * 60, registerFor: 1 },
					transaction: { senderAddress: Buffer.alloc(0) } as any,
				});

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

	describe('apply', () => {
		let stateStore: StateStore;
		let account: any;

		beforeEach(() => {
			account = testing.fixtures.createDefaultAccount<LNSAccountProps>([LnsModule]);

			stateStore = new testing.mocks.StateStoreMock({
				accounts: [account],
			});

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

		describe('valid cases', () => {
			it('should update the state store with nameahsh key', async () => {
				const name = 'nazar.lsk';
				const node = getNodeForName(name);
				const key = getKeyForNode(node);
				const context = testing.createApplyAssetContext({
					stateStore,
					asset: { name: 'nazar.lsk', ttl: 60 * 60, registerFor: 1 },
					transaction: { senderAddress: account.address } as any,
				});

				await transactionAsset.apply(context);

				expect(stateStore.chain.set).toHaveBeenCalledWith(key, expect.any(Buffer));
			});

			it('should update the state store with updated sender account', async () => {
				const name = 'nazar.lsk';
				const node = getNodeForName(name);
				const context = testing.createApplyAssetContext({
					stateStore,
					asset: { name: 'nazar.lsk', ttl: 60 * 60, registerFor: 1 },
					transaction: { senderAddress: account.address } as any,
				});
				await transactionAsset.apply(context);

				const updatedSender = await stateStore.account.get<LNSAccountProps>(account.address);

				expect(updatedSender.lns.ownNodes).toEqual([node]);
			});

			it('should update the state store with correct ttl value', async () => {
				const name = 'nazar.lsk';
				const node = getNodeForName(name);
				const context = testing.createApplyAssetContext({
					stateStore,
					asset: { name: 'nazar.lsk', ttl: 60 * 70, registerFor: 1 },
					transaction: { senderAddress: account.address } as any,
				});
				await transactionAsset.apply(context);

				const lsnObject = await getLNSObject(stateStore, node);

				expect(lsnObject?.ttl).toEqual(60 * 70);
			});

			it('should update the state store with correct expiry date', async () => {
				const name = 'nazar.lsk';
				const node = getNodeForName(name);
				const context = testing.createApplyAssetContext({
					stateStore,
					asset: { name: 'nazar.lsk', ttl: 60 * 70, registerFor: 2 },
					transaction: { senderAddress: account.address } as any,
				});
				const expiryTimestamp = Math.ceil(addYears(new Date(), 2).getTime() / 1000);

				await transactionAsset.apply(context);

				const lsnObject = await getLNSObject(stateStore, node);

				expect(lsnObject?.expiry).toBeGreaterThanOrEqual(expiryTimestamp);
			});
		});

		describe('invalid cases', () => {
			it('should throw error if name is already registered', async () => {
				const context = testing.createApplyAssetContext({
					stateStore,
					asset: { name: 'nazar.lsk', ttl: 60 * 60, registerFor: 1 },
					transaction: { senderAddress: account.address } as any,
				});

				await transactionAsset.apply(context);

				await expect(transactionAsset.apply(context)).rejects.toThrow(
					'The name "nazar.lsk" already registered',
				);
			});
		});
	});
});

Testing the Reverse Lookup asset

The entire code for the tests of the Reverse Lookup asset is described below. The majority of the code is self-explanatory, however, the most important parts of the tests are summarized here to provide a better overview:

Testing the apply() function

Tests for the apply() function of the Reverse Lookup asset.

Write tests to check the following:

  • Valid cases:

    • It should update the lns reverse-lookup of the sender account with the given node if it is not already set.

    • It should update the lns reverse-lookup of the sender account with the given node even if it is already set.

  • Invalid cases:

    • It should throw an error if the node to set-lookup is not owned by the sender.

Before each test:

  • Create a new default account for the LNS application.

  • Add two registered names to the account: john.lsk and doe.lsk.

  • Add the newly created account to the accounts list of the StateStore mock.

  • Spy on the functions stateStore.chain.get and stateStore.chain.set. This allows checking in the tests, if the respective functions have been called or not.

Use of the SDK testing utilities:

  • testing.createApplyAssetContext(): Returns valid parameters for the apply() function. If the function is called with an empty object, it returns the default parameters for the apply() function. For the test, overwrite the default asset value (undefined) with a valid transaction asset for the Reverse Lookup asset. Additionally, overwrite the default transaction value of the context (undefined) with a transaction containing a property senderAddress which equals the address of the newly created account.

  • testing.fixtures.createDefaultAccount<LNSAccountProps>([LnsModule]): Used to create a default account for the LNS application.

  • new testing.mocks.StateStoreMock({accounts: [account]}): Creates a mock for the StateStore.

Unit tests for the Reverse Lookup asset

lisk-name-service/lns/test/unit/modules/lns/assets/reverse_lookup.ts
import { chain, cryptography, StateStore, testing } from 'lisk-sdk';
import { ReverseLookupAsset } from '../../../../../src/app/modules/lns/assets/reverse_lookup';
import { LNSAccountProps } from '../../../../../src/app/modules/lns/data';
import { LnsModule } from '../../../../../src/app/modules/lns/lns_module';
import { getNodeForName } from '../../../../../src/app/modules/lns/storage';

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

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

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

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

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

	describe('apply', () => {
		let stateStore: StateStore;
		let account: chain.Account<LNSAccountProps>;
		let ownNodes: Buffer[];

		beforeEach(() => {
			ownNodes = [getNodeForName('john.lsk'), getNodeForName('doe.lsk')];
			account = testing.fixtures.createDefaultAccount<LNSAccountProps>([LnsModule]);
			account.lns.ownNodes = ownNodes;

			stateStore = new testing.mocks.StateStoreMock({
				accounts: [account],
			});

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

		describe('valid cases', () => {
			it('should update sender account lns reverse-lookup with given node if not already set', async () => {
				const context = testing.createApplyAssetContext({
					stateStore,
					asset: { name: 'john.lsk' },
					transaction: { senderAddress: account.address } as any,
				});
				await transactionAsset.apply(context);

				const updatedAccount = stateStore.account.get<LNSAccountProps>(account.address);

				expect((await updatedAccount).lns.reverseLookup).toEqual(ownNodes[0]);
			});

			it('should update sender account lns reverse-lookup with given node even if already set', async () => {
				account.lns.reverseLookup = cryptography.getRandomBytes(20);
				stateStore = new testing.mocks.StateStoreMock({
					accounts: [account],
				});
				const context = testing.createApplyAssetContext({
					stateStore,
					asset: { name: 'john.lsk' },
					transaction: { senderAddress: account.address } as any,
				});
				await transactionAsset.apply(context);

				const updatedAccount = stateStore.account.get<LNSAccountProps>(account.address);

				expect((await updatedAccount).lns.reverseLookup).toEqual(ownNodes[0]);
			});
		});

		describe('invalid cases', () => {
			it('should throw error if node to set-lookup is not owned by sender', async () => {
				const context = testing.createApplyAssetContext({
					stateStore,
					asset: { name: 'alpha.lsk' },
					transaction: { senderAddress: account.address } as any,
				});

				await expect(transactionAsset.apply(context)).rejects.toThrow(
					'You can only assign lookup node which you own.',
				);
			});
		});
	});
});

Writing functional tests

Functional testing is a quality assurance (QA) process and a type of black-box testing that bases its test cases on the specifications of the software component under test. Functions are tested by feeding them input and examining the output, and internal program structure is rarely considered (unlike white-box testing). Functional testing is conducted to evaluate the compliance of a system or component with specified functional requirements. Functional testing usually describes what the system does.

As a final exercise, write a functional test, that checks if a domain name was correctly resolved after calling the action lns:resolveName.

Create a new file lns_modules.spec.ts in the test/network/ folder:

.
├── _setup.js
├── commands
├── integration
├── network
│   └── lns_modules.spec.ts
├── tsconfig.json
├── unit
│   └── modules
│       └── lns
│           ├── assets
│           │   ├── register.spec.ts
│           │   └── reverse_lookup.spec.ts
│           └── lns.spec.ts
└── utils

The functional test should verify the following:

  • resolveName action of the LNS module.

    • It should throw an error on resolving a non-registered name.

    • It should resolve the name after registration.

Before all tests:

  • Create a new default environment for the LNS application.

  • Start the application of the application environment.

Use of the SDK testing utilities:

  • testing.createDefaultApplicationEnv({ modules: [LnsModule] }): Create a default application environment for the functional test.

  • testing.fixtures.createDefaultAccount([LnsModule], { address }): Creates a default account for the LNS application.

  • testing.fixtures.defaultFaucetAccount.passphrase: Use the passphrase of the default faucet account to send tokens from the faucet to the newly created default account.

Functional tests for the action resolveName of the LNS module

lisk-name-service/lns/test/network/lns_modules.spec.ts
import { cryptography, passphrase, testing, transactions } from 'lisk-sdk';
import { LnsModule } from '../../src/app/modules/lns/lns_module';

jest.setTimeout(150000);

describe('LnsModule', () => {
	let appEnv: testing.ApplicationEnv;

	beforeAll(async () => {
		appEnv = testing.createDefaultApplicationEnv({ modules: [LnsModule] });
		await appEnv.startApplication();
	});

	afterAll(async () => {
		jest.spyOn(process, 'exit').mockImplementation((() => {}) as never);
		await appEnv.stopApplication();
	});

	describe('actions', () => {
		describe('resolveName', () => {
			it('should throw error on resolving non-registered name', async () => {
				await expect(appEnv.ipcClient.invoke('lns:resolveName', { name: 'nazar' })).rejects.toThrow(
					'Name "nazar" could not resolve.',
				);
			});

			it('should resolve name after registration', async () => {
				// Create an account
				const accountPassphrase = passphrase.Mnemonic.generateMnemonic();
				const { address } = cryptography.getAddressAndPublicKeyFromPassphrase(accountPassphrase);
				const account = testing.fixtures.createDefaultAccount([LnsModule], { address });

				// Fund with some tokens
				let tx = await appEnv.ipcClient.transaction.create(
					{
						moduleName: 'token',
						assetName: 'transfer',
						asset: {
							recipientAddress: account.address,
							amount: BigInt(transactions.convertLSKToBeddows('100')),
							data: '',
						},
						fee: BigInt(transactions.convertLSKToBeddows('0.1')),
					},
					testing.fixtures.defaultFaucetAccount.passphrase,
				);
				await appEnv.ipcClient.transaction.send(tx);
				await appEnv.waitNBlocks(1);

				tx = await appEnv.ipcClient.transaction.create(
					{
						moduleName: 'lns',
						assetName: 'register',
						asset: {
							registerFor: 1,
							name: 'nazar.lsk',
							ttl: 36000,
						},
						fee: BigInt(transactions.convertLSKToBeddows('0.1')),
					},
					accountPassphrase,
				);
				await appEnv.ipcClient.transaction.send(tx);
				await appEnv.waitNBlocks(1);

				await expect(
					appEnv.ipcClient.invoke('lns:resolveName', { name: 'nazar.lsk' }),
				).resolves.toEqual(
					expect.objectContaining({
						name: 'nazar.lsk',
						ownerAddress: address.toString('hex'),
						ttl: 36000,
						records: [],
					}),
				);
			});
		});
	});
});