Skip to content

Testing your chaincode

The GalaChain SDK includes a comprehensive set of tools in the @gala-chain/test package to facilitate the testing of your chaincode. This package supports both unit testing for individual contracts and integration/end-to-end testing for running networks.

Unit testing

The @gala-chain/test package offers utilities designed for straightforward unit testing of your chaincode. The recommended library for tests is Jest.

Writing unit tests

Consider a contract AppleContract with the following methods:

export class AppleContract extends GalaContract {
  public async PlantTree(ctx: GalaChainContext, dto: AppleTreeDto): Promise<void> { ... }
  public async PickApple(ctx: GalaChainContext, dto: PickAppleDto): Promise<void> { ... }
}

Let's create tests for the following scenarios: 1. AppleContract should allow to plant a tree. 2. AppleContract should fail to plant a tree if tree already exists. 3. AppleContract should allow to pick an apple.

Note: AppleContract with the referenced implementation and all relevant tests are available in chaincode template. You can follow the instructions in Getting started to create a new chaincode project with AppleContract included.

Test 1. AppleContract should allow to plant a tree

This test ensures that the AppleContract allows users to successfully plant a new apple tree. It validates the contract's behavior during the tree planting process.

import { fixture, transactionSuccess, writesMap } from "@gala-chain/test";
import { AppleTree, AppleTreeDto, Variety } from "../apples";
import { AppleContract } from "./AppleContract";

it("should allow to plant a tree", async () => {
  // Given
  const {contract, ctx, writes} = fixture(AppleContract);
  const dto = new AppleTreeDto(Variety.GALA, 1);
  const expectedTree = new AppleTree(ctx.callingUser, dto.variety, dto.index, ctx.txUnixTime);

  // When
  const response = await contract.PlantTree(ctx, dto);

  // Then
  expect(response).toEqual(transactionSuccess());
  expect(writes).toEqual(writesMap(expectedTree));
});

In this test, we set up the initial environment using the fixture utility from @gala-chain/test. The fixture contains: - contract -- instance of the AppleContract class, - ctx -- test chaincode context, - writes -- object capturing changes to the blockchain state.

Also, we define the AppleTreeDto instance containing details about the apple tree to be planted, and the expectedTree instance containing the expected object to be written to the blockchain state.

The primary action involves invoking the PlantTree method on the contract instance.

Then, we assert that the response from planting the tree aligns with the expected success result with transactionSuccess matcher from @gala-chain/test. Furthermore, we verify that the changes to the blockchain state (writes) match the expected modifications. Since writes is a map of key-value pairs, we use the writesMap utility from @gala-chain/test to get a key-value representation of the expectedTree instance.

Test 2. AppleContract should fail to plant a tree if tree already exists

In this test case, we aim to verify the behavior of the AppleContract when attempting to plant a new apple tree that already exists. In our case a tree is considered to exist if it has the same variety and index as is planted by the same user.

import { fixture, transactionErrorMessageContains } from "@gala-chain/test";
import { ChainUser } from "@gala-chain/client";
import { AppleTree, AppleTreeDto, Variety } from "../apples";
import { AppleContract } from "./AppleContract";

it("should fail to plant a tree if tree already exists", async () => {
  // Given
  const user = ChainUser.withRandomKeys();

  const {contract, ctx, writes} = fixture(AppleContract)
    .callingUser(user)
    .savedState(new AppleTree(user.identityKey, Variety.GOLDEN_DELICIOUS, 1, 0));

  // When
  const response = await contract.PlantTree(ctx, new AppleTreeDto(Variety.GOLDEN_DELICIOUS, 1));

  // Then
  expect(response).toEqual(transactionErrorMessageContains("Tree already exists"));
  expect(writes).toEqual({});
});

In this test case, we also use the fixture utility from @gala-chain/test to set up the initial environment. However, we use the callingUser method to specify the user who will invoke the contract method, and we use the savedState method to specify the initial state of the blockchain. Also, the saved AppleTree instance is marked to be planted by the user who invokes the contract method (user.identityKey is the same as ctx.callingUser in this setup).

This way calling PlantTree method with the same variety and index will result in an error.

During validation, we assert that the response from planting the tree contains the expected error message, and no changes to the blockchain state (writes) are made. To assert the error we use the transactionErrorMessageContains matcher from @gala-chain/test. Other useful matchers include transactionError (for providing an exact error object) and transactionErrorKey (for providing the error key).

Test 3. AppleContract should allow to pick an apple

In this test case, we aim to verify the behavior of the AppleContract when attempting to pick an apple from an existing apple tree.

import { fixture, transactionSuccess, writesMap } from "@gala-chain/test";
import { plainToInstance } from "class-transformer";
import { AppleTree, PickAppleDto, Variety } from "../apples";
import { AppleContract } from "./AppleContract";

it("should allow to pick apples", async () => {
  // Given
  const twoYearsAgo = new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 365 * 2).getTime();
  const existingTree = new AppleTree("client|some-user", Variety.GALA, 1, twoYearsAgo);
  const {contract, ctx, writes} = fixture(AppleContract).savedState(existingTree);

  const dto = new PickAppleDto(existingTree.plantedBy, existingTree.variety, existingTree.index);

  // When
  const response = await contract.PickApple(ctx, dto);

  // Then
  expect(response).toEqual(transactionSuccess());
  expect(writes).toEqual(writesMap(plainToInstance(AppleTree, {
    ...existingTree,
    applesPicked: existingTree.applesPicked.plus(1)
  })));
});

In our case a tree has apples if a given time passes. That's why we start from a tree that was planted two years ago.

During validation, we assert that the response from picking an apple is successful, and the change to the blockchain state (writes) is overriding current apple tree with the updated picked apples count.

Using fixture for regular functions

fixture can be used for regular functions as well, without the need to call contract methods directly. However, the ctx parameter is tied to the contract, and you must provide any contract class, such as AppleContract or any class that extends GalaContract from the @gala-chain/chaincode package.

import { ChainUser } from "@gala-chain/client";
import { fixture, writesMap } from "@gala-chain/test";
import { GalaContract } from "@gala-chain/chaincode";
import { AppleTreeDto, AppleTreesDto } from "./dtos";
import { Variety } from "./types";
import { AppleTree } from "./AppleTree";
import { plantTrees } from "./plantTrees";

class TestContract extends GalaContract {
  constructor() {
    super("TestContract", "0.0.1");
  }
}

it("should allow to plant trees", async () => {
  // Given
  const user = ChainUser.withRandomKeys();

  const {ctx, writes} = fixture(TestContract).callingUser(user);

  const dto = new AppleTreesDto([
    new AppleTreeDto(Variety.GALA, 1),
    new AppleTreeDto(Variety.MCINTOSH, 2),
  ]);

  const expectedTrees = dto.trees.map(t => new AppleTree(user.identityKey, t.variety, t.index, ctx.txUnixTime));

  // When
  const response = await plantTrees(ctx, dto);

  // Then
  expect(response).toEqual(expectedTrees);

  await ctx.stub.flushWrites();
  expect(writes).toEqual(writesMap(...expectedTrees));
});

Using fixture for regular functions is useful when you want to test the behavior of the function without the need to call the contract method. However, if you want to verify writes, you need to explicitly call contract.afterTransaction or ctx.stub.flushWrites method. This is required, because all writes actually are added to internal cache, and are executed after the contract method is successfully executed.

Additional notes

Signatures

In most transactions, DTOs require a secp256k1 signature to verify the identity of the user invoking the contract method. When using fixture, there's no need to provide a signature as it's handled automatically.

beforeTransaction and afterTransaction

In the context of testing contract methods with fixture, you don't need to manually call contract.beforeTransaction and contract.afterTransaction methods; they are invoked automatically.

Integration testing

The @gala-chain/test package, combined with the @gala-chain/client package, provides utilities for integration testing your chaincode. The primary objective of integration or end-to-end tests is to call transactions on the actual Hyperledger Fabric network and verify the results.

The recommended library for tests is Jest.

Writing integration tests

Assume you have a contract AppleContract with the following methods:

export class AppleContract extends GalaContract {
  public async PlantTrees(ctx: GalaChainContext, dto: AppleTreesDto): Promise<void> { ... }
  public async FetchTrees(ctx: GalaChainContext, dto: FetchTreesDto): Promise<PagedTreesDto> { ... }
  public async PickApple(ctx: GalaChainContext, dto: PickAppleDto): Promise<void> { ... }
}

Let's write tests for the following scenarios: 1. Plant a bunch of trees 2. Fetch GALA trees planted by a user 3. Fail to pick a GOLDEN_DELICIOUS apple because tree is too young

Note: AppleContract with the referenced implementation and all relevant tests (file: e2e/apples.spec.ts) are available in chaincode template.

Setup

Before writing integration tests, ensure you have a running GalaChain network. You can use the npm run network:start command provided by the chaincode template to start a local network in dev mode with hot-reload enabled.

Integration tests are executed against the running network, which is not recreated after each test. To make tests independent, you may need to randomize test data or clean up the data on the chain after tests.

In our case for apples, we use random users defined at the test suite level to create different users for each run, ensuring test suite independence. However, each test in the suite uses the same user and is not independent. Thus, we use scenario-like testing in the apples test suite, and each test is dependent on the previous one.

Also, since we are using the running network, we need a client to interact with the network. It needs to be connected to the network, anf it needs to be disconnected after the tests are finished.

Here is an example of the test setup:

import { AdminChainClients, TestClients, transactionErrorKey, transactionSuccess, } from "@gala-chain/test";
import { GalaChainResponse } from "@gala-chain/api";
import { ChainClient, ChainUser } from "@gala-chain/client";
import { AppleTreeDto, AppleTreesDto, FetchTreesDto, PagedTreesDto, PickAppleDto, Variety } from "../src/apples";

describe("Apple trees", () => {
  const appleContractConfig = {apples: {name: "AppleContract", api: appleContractAPI}};
  let client: AdminChainClients<typeof appleContractConfig>;
  let user: ChainUser;

  beforeAll(async () => {
    client = await TestClients.createForAdmin(appleContractConfig);
    user = await client.createRegisteredUser();
  });

  afterAll(async () => {
    await client.disconnect();
  });
  ...

Optional setup -- custom API

By default client is not aware of your chaincode and types, providing only generic methods for submitting or evaluating transactions:

submitTransaction(method: string): Promise<GalaChainResponse<unknown>>;
submitTransaction(method: string, dto: ChainCallDTO): Promise<GalaChainResponse<unknown>>;
submitTransaction<T>(method: string, resp: ClassType<Inferred<T>>): Promise<GalaChainResponse<T>>;
submitTransaction<T>(method: string, dto: ChainCallDTO, resp: ClassType<Inferred<T>>): Promise<GalaChainResponse<T>>;

evaluateTransaction(method: string): Promise<GalaChainResponse<unknown>>;
evaluateTransaction(method: string, dto: ChainCallDTO): Promise<GalaChainResponse<unknown>>;
evaluateTransaction<T>(method: string, resp: ClassType<Inferred<T>>): Promise<GalaChainResponse<T>>;
evaluateTransaction<T>(method: string, dto: ChainCallDTO, resp: ClassType<Inferred<T>>): Promise<GalaChainResponse<T>>;

They are generic, and you need to provide a method name, and optionally a DTO and response type to deserialize the response to a proper type. But you can define a custom API, that will be aware of your chaincode and types.

If you choose not to use a custom API, you can create a test client as follows:

  const appleContractConfig = {apples: "AppleContract"};
  let client: AdminChainClients<typeof appleContractConfig>;
  ...
  beforeAll(async () => {
    client = await TestClients.createForAdmin(appleContractConfig);
    ...

This way you will be able to use only the generic methods to call chaincodes: client.apples.evaluateTransaction(...) or client.apples.submitTransaction(...).

However, defining a custom API offers type-safe calls, as demonstrated in the apples test suite. You can define it as follows:

interface AppleContractAPI {
  PlantTrees(dto: AppleTreesDto): Promise<GalaChainResponse<void>>;
  FetchTrees(dto: FetchTreesDto): Promise<GalaChainResponse<PagedTreesDto>>;
}

function appleContractAPI(client: ChainClient): AppleContractAPI {
  return {
    PlantTrees(dto: AppleTreesDto) {
      return client.submitTransaction("PlantTrees", dto) as Promise<GalaChainResponse<void>>;
    },
    FetchTrees(dto: FetchTreesDto) {
      return client.evaluateTransaction("FetchTrees", dto, PagedTreesDto);
    }
  };
}

And provide it for client creation:

  const appleContractConfig = {apples: {name: "AppleContract", api: appleContractAPI}};
  let client: AdminChainClients<typeof appleContractConfig>;
  ...
  beforeAll(async () => {
    client = await TestClients.createForAdmin(appleContractConfig);
    ...

And it allows you to use type-safe calls, defined in the API, like client.apples.PlantTrees(...) or client.apples.FetchTrees(...).

Test 1. Plant a bunch of trees

  test("Plant a bunch of trees", async () => {
    // Given
    const dto = new AppleTreesDto([
      new AppleTreeDto(Variety.GALA, 1),
      new AppleTreeDto(Variety.GOLDEN_DELICIOUS, 2),
      new AppleTreeDto(Variety.GALA, 3),
    ])
      .signed(user.privateKey, false);

    // When
    const response = await client.apples.PlantTrees(dto);

    // Then
    expect(response).toEqual(transactionSuccess());
  });

In this test case, we create a DTO with three trees to plant. We sign the DTO with the user's private key to prove the identity of the user. This is required, in contrast to unit tests.

Then we call PlantTrees method, defined in our custom API, and we assert that the response is successful.

As a result the test writes three trees to the blockchain, planted by the user. We will use them in the next test.

Test 2. Fetch GALA trees planted by a user

  test("Fetch GALA trees planted by a user", async () => {
    // Given
    const dto = new FetchTreesDto(user.identityKey, Variety.GALA)
      .signed(user.privateKey, false);

    // When
    const response = await client.apples.FetchTrees(dto);

    // Then
    expect(response).toEqual(transactionSuccess({
      trees: [
        expect.objectContaining({plantedBy: user.identityKey, variety: Variety.GALA, index: 1}),
        expect.objectContaining({plantedBy: user.identityKey, variety: Variety.GALA, index: 3})
      ],
      bookmark: ""
    }))
  })

In the previous test, we planted three trees, two of them are GALA. In this test, we fetch all GALA trees planted by the user.

The response contains two trees, planted by the user, and the bookmark for fetching next page (though in this case, it's empty).

Test 3. Fail to pick a GOLDEN_DELICIOUS apple because tree is too young

  test("Fail to pick a GOLDEN_DELICIOUS apple because tree is too young", async () => {
    // Given
    const dto = new PickAppleDto(user.identityKey, Variety.GOLDEN_DELICIOUS, 2)
      .signed(user.privateKey, false);

    // When
    const response = await client.apples.PickApple(dto);

    // Then
    expect(response).toEqual(transactionErrorKey("NO_APPLES_LEFT"));
  });

In this test case, we try to pick an apple from the tree that was planted in the first test. However, the tree is too young, so we expect an error.

These examples provide a comprehensive guide for unit and integration testing of GalaChain smart contracts using the @gala-chain/test package. Adjust and expand the provided code snippets based on your specific contract implementations and testing requirements.