Skip to main content

Writing tests with TypeScript

Introduction#

Here we discuss the process of writing end to end (e2e) tests for your Wasm wrapper using TypeScript. By end-to-end, we mean tests that make calls to the wrapper through a Polywrap Client. E2e testing is both necessary and useful. It is necessary because calls between wrappers depend on the Polywrap Client, and therefore cannot always be made in a Wasm wrapper's native language. It is useful because such tests can more accurately represent the user experience of your wrapper when it is integrated into an application.

We will focus on setting up a test environment and a Polywrap Client. Once these tasks are completed, testing a Wasm wrapper is similar to testing a traditional SDK.

Prerequisites#

This guide uses TypeScript to implement the Polywrap test environment. We will also use the Jest testing framework, though you can use any framework and follow without difficulty.

tip

In the future, TypeScript will be one of many supported languages with a Polywrap client. As more Polywrap Clients are released in various languages, implementing plugin wrappers in those languages will be supported as well.

You'll need the following installed as developer dependencies before testing your wrapper:

These can be added to your TypeScript project using the npm or yarn package managers:

npm install --save-dev polywrap @polywrap/test-env-js @polywrap/client-js jest @types/jest
yarn add -D polywrap @polywrap/test-env-js @polywrap/client-js jest @types/jest

The Polywrap Test Environment#

An ideal Polywrap test environment will integrate everything you need to test your wrapper. In the case of this guide, we will need an IPFS node and an Ethereum test network.

tip

Polywrap makes it easy to create a custom test environment that you can start and stop with the Polywrap CLI. See Configure Polywrap infrastructure pipeline for more information.

This guide will use the @polywrap/test-env-js package. The @polywrap/test-env-js package uses the default infrastructure module included with the polywrap CLI. We can use @polywrap/test-env-js to start and stop the test environment programmatically. The package also exports the ethereum provider, ipfs provider, and ens addresses used by the default infrastructure module.

Starting and stopping a Polywrap Test environment with @polywrap/test-env-js#

If you're unfamiliar with @polywrap/test-env-js, feel free to check out the reference documentation before we continue.

Let's start a new file where we will write our first test.

Like many testing frameworks in JavaScript, the Jest framework includes hooks that run before and after all tests. I've added a call to initTestEnvironment in the beforeAll hook, so we can start our test environment before running our tests. I added a call to stopTestEnvironment in the afterAll hook to make sure the test environment does not continue running on our system after we finish testing.

import { initTestEnvironment, stopTestEnvironment } from "@polywrap/test-env-js";
jest.setTimeout(360000);
describe('Wrapper Test', () => {
beforeAll(async () => {
// initialize test environment
await initTestEnvironment();
});
afterAll(async () => {
// stop test environment
await stopTestEnvironment();
});
test("", async () => { });
});

Building and deploying a Wasm wrapper for testing with @polywrap/test-env-js#

To invoke a Wasm wrapper in a test, we first need to build it. In this guide, we will do this with @polywrap/test-env-js using the buildWrapper function.

tip

Alternatively, we could have used the @polywrap/test-env-js function buildAndDeployWrapper. The buildAndDeployWrapper function will build a wrapper, deploy it to IPFS, point an ENS domain to it. It returns both the IPFS content hash and the ENS domain, either of which could be used to invoke the wrapper.

The buildWrapper function requires an absolute path to the directory containing the wrapper's polywrap manifest. We will obtain the absolute path to our wrapper project in three steps. First we get the directory of the folder containing our test script, using the node.js __dirname global variable. We then append the path from our test script file to the manifest directory. Finally, we import the node.js feature path, and use path.resolve to get direct absolute path to our wrapper project.

We will invoke our wrapper using the path to our build folder. We prefix the path with wrap://fs/ to conform to the WRAP URI standard, though prefixing the path with fs/ alone would work just as well. See Understanding URIs to learn more.

import { buildWrapper, initTestEnvironment, stopTestEnvironment } from "@polywrap/test-env-js";
import path from "path";
jest.setTimeout(360000);
describe('Wrapper Test', () => {
// path to the wrapper's build folder
let wrapperPath: string;
beforeAll(async () => {
// initialize test environment
await initTestEnvironment();
// absolute path to directory with polywrap.yaml
const wrapperDirectory: string = path.resolve(__dirname + "/../../../");
// build the wrapper
await buildWrapper(wrapperDirectory);
wrapperPath = `wrap://fs/${wrapperDirectory}/build`
});
afterAll(async () => {
// stop test environment
await stopTestEnvironment();
});
test("", async () => { });
});

Setting up a Polywrap Client#

Now that we have deployed our Wasm wrapper to a local test environment, we need to set up a Polywrap Client that can invoke it. We can make sure our client is ready to invoke the wrapper by configuring two of the default plugin wrappers.

  • The ipfsResolverPlugin can be configured using the test environment's local IPFS node.
  • The ethereumPlugin can be configured with an Ethereum provider for the Ganache Ethereum test network.

Configure the IPFS Resolver Plugin#

The ipfsResolverPlugin can be configured with a default IPFS provider and an array of fallback providers that will be tried if the default provider fails.

import { ipfsPlugin, IpfsPluginConfig } from "@polywrap/ipfs-plugin-js";
const ipfsConfig: IpfsPluginConfig = {
provider: providers.ipfs,
fallbackProviders: undefined,
};

Configure the Ethereum Plugin#

The ethereumPlugin can be configured with Ethereum providers and signers. It accepts JSON RPC provider strings, Providers from the ethers.js package, and providers from the web3.js package.

To configure the plugin, we need to provide it with an object containing network names along with a provider. We can optionally provide a signer as well, but by default the plugin will attempt to obtain a signer from the provider if it is needed.

We can give networks any name we want in our configuration. We can later use these names to choose which network to query. Here we will use the name "testnet" for our local Ethereum test network.

We also need to tell the Ethereum plugin which network should be used when a network is not specified. We do this by setting the value of the defaultNetwork property to one of our network names.

import { ethereumPlugin, EthereumPluginConfig } from "@polywrap/ethereum-plugin-js";
const ethereumConfig: EthereumPluginConfig = {
networks: {
testnet: {
provider: providers.ethereum // Ganache test network
},
},
defaultNetwork: "testnet",
};

Create the Polywrap Client Instance#

Now we are ready to add the plugins to our Polywrap Client configuration and create the client. The URI's we assign in our client configuration will redirect to the plugin instances when queried.

import { ClientConfig, PolywrapClient } from "@polywrap/client-js";
import { ethereumPlugin, EthereumPluginConfig } from "@polywrap/ethereum-plugin-js";
import { ipfsPlugin, IpfsPluginConfig } from "@polywrap/ipfs-plugin-js";
import { buildWrapper, initTestEnvironment, stopTestEnvironment, providers } from "@polywrap/test-env-js";
import path from "path";
jest.setTimeout(360000);
describe('Wrapper Test', () => {
// path to the wrapper's build folder
let wrapperPath: string;
// an instance of the Polywrap Client
let client: PolywrapClient;
beforeAll(async () => {
// initialize test environment
await initTestEnvironment();
// absolute path to directory with polywrap.yaml
const wrapperDirectory: string = path.resolve(__dirname + "/../../../");
// build the wrapper
await buildWrapper(wrapperDirectory);
wrapperPath = `wrap://fs/${wrapperDirectory}/build`
// configure the ipfs plugin
const ipfsConfig: IpfsPluginConfig = {
provider: providers.ipfs,
fallbackProviders: undefined,
};
// configure the ethereum plugin
const ethereumConfig: EthereumPluginConfig = {
networks: {
testnet: {
provider: providers.ethereum // Ganache test network
},
},
defaultNetwork: "testnet",
};
// configure the client
const clientConfig: Partial<ClientConfig> = {
plugins: [
{
uri: "wrap://ens/ipfs.polywrap.eth",
plugin: ipfsPlugin(ipfsConfig),
},
{
uri: "wrap://ens/ethereum.polywrap.eth",
plugin: ethereumPlugin(ethereumConfig),
},
],
};
// create client
client = new PolywrapClient(clientConfig);
});
afterAll(async () => {
// stop test environment
await stopTestEnvironment();
});
test("", async () => { });
});

Deploy the Smart Contract#

The SimpleStorage wrapper is designed to interact with a SimpleStorage smart contract. We need to deploy an instance of the contract to work with. While there are many tools developers can use to deploy a smart contract on a local network, the SimpleStorage API has a method deployContract we can use to get the job done. We'll call it before running our test to make sure the contract is ready.

import { ClientConfig, PolywrapClient } from "@polywrap/client-js";
import { ethereumPlugin, EthereumPluginConfig } from "@polywrap/ethereum-plugin-js";
import { ipfsPlugin, IpfsPluginConfig } from "@polywrap/ipfs-plugin-js";
import { buildWrapper, initTestEnvironment, stopTestEnvironment, providers } from "@polywrap/test-env-js";
import path from "path";
jest.setTimeout(360000);
describe('Wrapper Test', () => {
// the Ethereum address of the SimpleStorage smart contract
let simpleStorageAddress: string;
// path to the wrapper's build folder
let wrapperPath: string;
// an instance of the Polywrap Client
let client: PolywrapClient;
beforeAll(async () => {
// initialize test environment
await initTestEnvironment();
// absolute path to directory with polywrap.yaml
const wrapperDirectory: string = path.resolve(__dirname + "/../../../");
// build the wrapper
await buildWrapper(wrapperDirectory);
wrapperPath = `wrap://fs/${wrapperDirectory}/build`
// configure the ipfs plugin
const ipfsConfig: IpfsPluginConfig = {
provider: providers.ipfs,
fallbackProviders: undefined,
};
// configure the ethereum plugin
const ethereumConfig: EthereumPluginConfig = {
networks: {
testnet: {
provider: providers.ethereum // Ganache test network
},
},
defaultNetwork: "testnet",
};
// configure the client
const clientConfig: Partial<ClientConfig> = {
plugins: [
{
uri: "wrap://ens/ipfs.polywrap.eth",
plugin: ipfsPlugin(ipfsConfig),
},
{
uri: "wrap://ens/ethereum.polywrap.eth",
plugin: ethereumPlugin(ethereumConfig),
},
],
};
// create client
client = new PolywrapClient(clientConfig);
// deploy simple storage contract
const { data, error } = await client.invoke<string>({
uri: wrapperPath,
method: "deployContract",
});
if (error) throw error;
simpleStorageAddress = data as string;
});
afterAll(async () => {
// stop test environment
await stopTestEnvironment();
});
test("", async () => { });
});

Generate TypeScript Types#

Since we are using TypeScript, we will want types to work with. It is possible to automatically generate TypeScript types from a GraphQL schema using the Polywrap CLI's app command.

Let's set up a polywrap.app.yaml manifest in a new folder called types. We will provide the manifest with the path to the composed schema in our build folder.

polywrap.app.yaml
format: 0.1.0
name: generate-typescript-types
language: app/typescript
schema: ../../../build/schema.graphql

We can then call the app command of the Polywrap CLI.

yarn polywrap app codegen -m ./src/__tests__/types/polywrap.app.yaml -g ./src/__tests__/types/wrap

The generated output includes TypeScript types for the SimpleStorage wrapper and its imports.

types.ts
...
export type String = string;
export type Boolean = boolean;
export interface SetIpfsDataOptions {
address: Types.String;
data: Types.String;
}
export interface SetIpfsDataResult {
ipfsHash: Types.String;
txReceipt: Types.String;
}
/// Imported Objects START ///
/* URI: "wrap://ens/ethereum.polywrap.eth" */
export interface Ethereum_Connection {
node?: Types.String | null;
networkNameOrChainId?: Types.String | null;
}
...

Testing a Wrapper Method#

From this point, testing a function in your wrapper is no different from testing a traditional SDK. Instead of calling a method in a traditional SDK, you will invoke your wrapper.

We will test the setIpfsData method we added to the SimpleStorage API in Adding new functions. For arguments, the setIpfsData method takes the Ethereum address of a deployed SimpleStorage contract and the data the user wants to add to IPFS. It returns the IPFS hash of the data.

We will test the setIpfsData method using the string "Hello from IPFS!" as the data for our test case. To be sure our method returns the correct IPFS hash, we can compare the method's return value to the value we get from the free File CID Checker service provided by Pinata.

Final test file
import { ClientConfig, PolywrapClient } from "@polywrap/client-js";
import { ethereumPlugin, EthereumPluginConfig } from "@polywrap/ethereum-plugin-js";
import { ipfsPlugin, IpfsPluginConfig } from "@polywrap/ipfs-plugin-js";
import { buildWrapper, initTestEnvironment, stopTestEnvironment, providers } from "@polywrap/test-env-js";
import path from "path";
import { SetIpfsDataResult } from '../types/wrap';
jest.setTimeout(360000);
describe('Wrapper Test', () => {
// the Ethereum address of the SimpleStorage smart contract
let simpleStorageAddress: string;
// path to the wrapper's build folder
let wrapperPath: string;
// an instance of the Polywrap Client
let client: PolywrapClient;
beforeAll(async () => {
// initialize test environment
await initTestEnvironment();
// absolute path to directory with polywrap.yaml
const wrapperDirectory: string = path.resolve(__dirname + "/../../../");
// build the wrapper
await buildWrapper(wrapperDirectory);
wrapperPath = `wrap://fs/${wrapperDirectory}/build`
console.log(wrapperPath);
// configure the ipfs plugin
const ipfsConfig: IpfsPluginConfig = {
provider: providers.ipfs,
fallbackProviders: undefined,
};
// configure the ethereum plugin
const ethereumConfig: EthereumPluginConfig = {
networks: {
testnet: {
provider: providers.ethereum // Ganache test network
},
},
defaultNetwork: "testnet",
};
// configure the client
const clientConfig: Partial<ClientConfig> = {
plugins: [
{
uri: "wrap://ens/ipfs.polywrap.eth",
plugin: ipfsPlugin(ipfsConfig),
},
{
uri: "wrap://ens/ethereum.polywrap.eth",
plugin: ethereumPlugin(ethereumConfig),
},
],
};
// create client
client = new PolywrapClient(clientConfig);
// deploy simple storage contract
const { data, error } = await client.invoke<string>({
uri: wrapperPath,
method: "deployContract",
});
if (error) throw error;
simpleStorageAddress = data as string;
console.log(simpleStorageAddress);
});
afterAll(async () => {
// stop test environment
await stopTestEnvironment();
});
test("setIpfsData", async () => {
// invoke setIpfs method
const { data, error } = await client.invoke<SetIpfsDataResult>({
uri: wrapperPath,
method: "setIpfsData",
args: {
options: {
address: simpleStorageAddress,
data: "Hello from IPFS!",
},
}
});
// check for errors
expect(error).toBeFalsy(); // will be undefined if no exception is thrown in the wrapper
expect(data).toBeTruthy(); // will be undefined if an exception is thrown in the wrapper
// compare results
expect(data?.ipfsHash).toEqual("QmPhAJz5QbidN3LgT2eDiu6Z3nCFs2gYQMbjgEAncrGsis");
});
});