/eth-testing

A set of tools in order to generate a mock Web3 Provider and simulate blockchain interactions in tests

Primary LanguageTypeScriptMIT LicenseMIT

Eth Testing

A set of tools in order to generate a mocked Web3 Provider and simulate blockchain interactions in tests.

Because the mocking happens at the level of the JSON-RPC requests, the core functionnalities of this package do not make any assumptions on what other libraries or packages, such as web3.js or ethers, are used in order to interact with the blockchain.

In this spirit, this package tries at best to expose testing utilitaries that are not tied to an external library.

⚠️ The package is quite recent, any contributions, issues or feedbacks are welcome ⚠️

Get started

The recommended way to use Eth-Testing with an application is to install it a development dependency:

Using npm

npm install eth-testing --save-dev

Or using yarn

yarn add eth-testing --dev

As a very simple example, let us consider a React app using metamask-react for handling MetaMask and tested using React Testing Library.

// App.tsx
function App() {
    const { status, connect, account, chainId } = useMetaMask();

    if (status !== "connected") return <button onClick={connect}>Connect</button>

    return <div>Connected account {account} on chain ID {chainId}</div>
}

// app.test.tsx
import { generateTestingUtils } from "eth-testing";
import { render, screen, waitForElementToBeRemoved } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "App";
...
describe("app connection", () => {
    const testingUtils = generateTestingUtils({ providerType: "MetaMask" });
    beforeAll(() => {
        // Manually inject the mocked provider in the window as MetaMask does
        global.window.ethereum = testingUtils.getProvider();
    })
    afterEach(() => {
        // Clear all mocks between tests
        testingUtils.clearAllMocks();
    })
    test("a user should be able to connect using MetaMask", async () => {
        // Start with not connected wallet
        testingUtils.mockNotConnectedWallet();
        // Mock the connection request of MetaMask
        testingUtils.mockRequestAccounts(["0xf61B443A155b07D2b2cAeA2d99715dC84E839EEf"]);

        render(<App />);
        const connectButton = await screen.findByRole("button", { name: /connect/i });
        // Click on the button
        userEvent.click(connectButton);

        await waitForElementToBeRemoved(connectButton);

        expect(screen.getByText(/connected account 0xf61B443A155b07D2b2cAeA2d99715dC84E839EEf on chain id 0x1/)).toBeInTheDocument();
    });
    test("a connected user should be able to see the wallet informations", async () => {
        // Start with a connected wallet
        testingUtils.mockConnectedWallet(["0xf61B443A155b07D2b2cAeA2d99715dC84E839EEf"]);

        render(<App />);

        await screen.findByText(/connected account 0xf61B443A155b07D2b2cAeA2d99715dC84E839EEf on chain id 0x1/);
    });
})

Examples

As a next step, multiple examples of a simple React components with contract interactions are available in the examples/react-apps folder. It uses jest and @testing-library for the tests.

Almost Real World Application

For a more serious application with more complete features and tests, one can take a look at the Rainbow Token application.

Usage and API description

The first step is to generate the utils

const testingUtils = generateTestingUtils({ providerType: "MetaMask" });

The argument is only the provider type, the three choices for now are "MetaMask", "WalletConnect" or "default".

The provider will then need to be injected in the application, this mechanism depends on the implementation details of the application. As an example for MetaMask, provider is injected in the window object so as an example, using jest hooks one may inject the mock provider as

beforeAll(() => {
    global.window.ethereum = testingUtils.getProvider();
});

It is strongly advised to clean the mocks between each tests, this may be done using the clearAllMocks function exposed through the testingUtils object. Again, using jest hooks, this can be done as

afterEach(() => {
    testingUtils.clearAllMocks();
});

High levels mocks

High level mocking functions allows anyone, even without a knowledge of the underlying mechanics to properly mock the interactions with the provider/blockchain. This is the advised way to perform mocking.

The main functions are described below:

  • mockConnectedWallet: allows to mock the connected accounts, the chain ID / network and the block number
// The chain ID and block number can be set in the options
// They default to "0x1" (Ethereum main net) and the block number to "0x1"
testingUtils.mockConnectedWallet(["0xf61B443A155b07D2b2cAeA2d99715dC84E839EEf"], options);
  • mockReadonlyProvider: allows to mock the chain ID / network and the block number, the accounts are mocked to an empty array
// The chain ID and block number can be set in the options
// They default to "0x1" (Ethereum main net) and the block number to "0x1"
testingUtils.mockReadonlyProvider(options);
  • mockNotConnectedWallet: allows to mock the accounts as an empty array
testingUtils.mockNotConnectedWallet(options);
  • mockBalance: allows to mock the balance of an account
// Mock balance of 0xf61B443A155b07D2b2cAeA2d99715dC84E839EEf to 1 ether
testingUtils.mockBalance("0xf61B443A155b07D2b2cAeA2d99715dC84E839EEf", "0xde0b6b3a7640000");
  • mockChainId: allows to mock the chain ID / network to which the provider is connected
// Mock the network to Ethereum main net
testingUtils.mockChainId("0x1");
  • mockBlockNumber: allows to mock the block number
// Mock the network to Ethereum main net
testingUtils.mockBlockNumber("0x1");
  • mockAccounts: allows to mock the accounts with which the provider is connected
// Mock the connected account as 0x138071e4e810f34265bd833be9c5dd96f01bd8a5
testingUtils.mockAccounts(["0x138071e4e810f34265bd833be9c5dd96f01bd8a5"]);
  • mockChainChanged: allows to simulate a change in the chain ID / network
// Simulate a change to the Ropsten network
testingUtils.mockChainChanged("0x3");
  • mockAccountsChanged: allows to simulate a change in the chain ID / network
// Simulate a change of account to 0xf61B443A155b07D2b2cAeA2d99715dC84E839EEf
testingUtils.mockAccountsChanged(["0xf61B443A155b07D2b2cAeA2d99715dC84E839EEf"]);
  • mockRequestAccounts: this is a special utils created in the case of MetaMask, it allows to mock the connection request. The accounts will automatically be mocked to the input value once the connection has been simulated.
// Mock the next request to eth_requestAccounts as 0x138071e4e810f34265bd833be9c5dd96f01bd8a5
testingUtils.mockRequestAccounts(["0xf61B443A155b07D2b2cAeA2d99715dC84E839EEf"]);

Testing contract interactions

Most of the application interacts with deployed contracts, interactions are generally more complex with contracts, hence a dedicated object has been created for it.

The testing utils expose a generateContractUtils method, it allows to generate high level utils for contract interactions based on their ABI.

const abi = [...];
// An address may be optionally given as second argument, advised in case of multiple similar contracts
const contractTestingUtils = testingUtils.generateContractUtils(abi);

Let us consider a very simple contract

contract Storage {
    uint public value;

    event ValueUpdated(uint value);

    constructor() {
        emit ValueUpdated(0);
    }

    function setValue(uint newValue) public {
        value = newValue;
        emit ValueUpdated(newValue);
    }
}

These utils expose multiple functions

  • mockCall: allows to mock the result of a call to a contract using the name of the function to be called and the orderered list of values to be returned
// Mock a call to the "value" function of the contract and returning the uint 100
contractTestingUtils.mockCall("value", ["100"]);
  • mockTransaction: allows to mock the result of a transaction from a contract method using the name of the function to be called
// Mock a transaction based from the `setValue` function of the contract
contractTestingUtils.mockTransaction("setValue");
  • mockGetLogs: allows to mock the next retrieval of the logs for a particular event type
// Two events will be retrieved with value "0" and "12"
contractTestingUtils.mockGetLogs("ValueUpdated", [["0"], ["12"]]);
  • mockEmitLog: allows to mock the emission of a a log for a particular event type. ⚠️ there is a difference of usage between web3.js and ethers
// With ethers
contractTestingUtils.mockEmitLog("ValueUpdated", ["13"]);
// With web3.js
// Needs to be done before the test or before the subcription happens
testingUtils.mockSubscribe("0x123");
...
contractTestingUtils.mockEmitLog("ValueUpdated", ["13"], "0x123");

Low levels mocks

These functions handles mock at a lower level, use it if you are confident with JSON-RPC requests.

  • emit: emits a notification for the provider, associated subscribers will be triggered
// Simulate a change of account to 0xf61B443A155b07D2b2cAeA2d99715dC84E839EEf
testingUtils.lowLevel.emit("accountsChanged", ["0xf61B443A155b07D2b2cAeA2d99715dC84E839EEf"]);
  • mockRequest: registers a mock for a JSON-RPC request
// Simulate 0x138071e4e810f34265bd833be9c5dd96f01bd8a5 as connected account
testingUtils.lowLevel.mockRequest("eth_accounts", ["0xf61B443A155b07D2b2cAeA2d99715dC84E839EEf"]);

Mock options

Some methods allows to specify the mock options, or part of the mock options.

type MockCondition = (params: unknown[]) => boolean;

type MockOptions = {
  // If true, the mock will not be cleared after being used 
  persistent?: boolean;
  // If true, the request will throw, the data field will be used as error
  shouldThrow?: boolean;
  // Timeout in milliseconds for the resolve or the reject
  timeout?: number;
  // Condition for the mock to be triggered
  condition?: MockCondition;
  // Callback that is triggered once the mock has been used, it is particularly useful when mocks needs to be triggered after certain user actions, like a transaction or a wallet connection
  triggerCallback?: (data?: unknown, params?: unknown[]) => void
};

Verbose mode

It is sometimes handy to display which JSON-RPC requests are triggered and for each, show the associated mock if it exists. For that, the setup function allows to set the utils in verbose mode

const testingUtils = generateTestingUtils({ verbose: true });

Contributing 🚀

Contributions are welcome! Please follow the guidelines in the contributing document.