reNFT Dev Diary #2: Forking the Blockchain to End-to-End test dApps

gm

We hope you all enjoyed the holidays and had some deserved R&R!

These days we often find ourselves reflecting, sharing past and present ( ) with frens and fam, old and new. It’s also a time to reflect on our dreams and aspirations and chart a course toward our desired future.

In reNFT’s case, we dream about end-to-end testing! We’re excited to have it set up and wanted to share our approach. We’ve provided a TLDRepo at the bottom.

In the previous dev diary, I mentioned we wanted to increase our release confidence. Since we handle user assets, we want to doubly make sure that our critical paths (lending, renting, and cancellation thereof) are delivering the experience they should.

The best way we can ensure new features, bug fixes, and improvements don’t regress on critical app behavior is by using end-to-end (E2E) automated testing .

The basic setup we wanted for our E2E was as follows:

  1. Build and mount NextJS application
  2. Spin up a forked local testnet
  3. Run tests against this and watch

A testnet allows us to emulate the blockchain with a high degree of realism without risking loss of funds. When running a testnet, there are several tools available; we selected Foundry because it’s elegant, popular and blazingly fast.

The test runner we really wanted to work with was Playwright. Some of us had experience with this in the past, it’s trusted and supported by a lot of great teams, and they’ve got some nice things in the pipeline

Most of the contemporary userland testing tools out took the approach of vendoring Metamask into other runners. We figured that we wouldn’t want to rely on a specific vendor, since RainbowKit supports so many! We primarily wanted to verify UI responses to wallet state and UX.

We made the decision to allow ourselves some time to deep dive. If we couldn’t find a fitting solution in a few days, we’d resort to one of the out-of-the-box solutions. In most cases, having something greatly outweighs having fun. All the better is the feeling when it ends up being this kind of fun:

Napoleon Dynamite, doing his dance.

How to mock a wallet

We wanted to look for programmatic ways to masquerade as the wallet.

After scouring the GitHub archives, we found our holy grail in the wagmi package and how they set up the environment for testing their React components and core.

Big shoutout to wagmi and OSS. We love you guys! ❤️

Here’s the gist: wagmi provides a MockConnector interface, allowing us some programmatic ways to interface with wallets by setting the signer. In the end, a Wallet is just an implementation of a Signer after all.

Since we’ve migrated to RainbowKit as our front end WalletConnect interface, this was perfect. RainbowKit utilizes wagmi as a dependency for interfacing with Wallets and the chain. Let’s get it set up!

Here’s what we plan to do:

  • Create a mock Wallet using the MockConnector
  • Connect our application to the local anvil testnet
  • See if we can connect!

Boilerplatin’

Before we start, you’ll need to install Foundry if you haven’t already.

We initialized our frontend using Rainbow’s excellent template application; we’ll tweak this slightly to emphasize how this can be generalized for your own use cases. Additionally, we’re going to utilize Foundry’s anvil as our testnet node.

Let’s start by cloning the RainbowKit starter template and installing Playwright alongside its default browsers:

npm init @rainbow-me/rainbowkit@latest # default to allcd my-rainbowkit-appnpm init playwright@latest # default to all

Next, run anvil:

anvil                                                                                                                                                          11:14                             _   _                            (_) | |      __ _   _ __   __   __  _  | |     / _` | | ‘_   / / | | | |    | (_| | | | | |   V /  | | | |     __,_| |_| |_|   _/   |_| |_|    0.1.0 (427c1b5 2022-12-05T00:10:54.092361Z)    https://github.com/foundry-rs/foundryAvailable Accounts==================(0) 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 (10000 ETH)(1) 0x70997970c51812dc3a010c7d01b50e0d17dc79c8 (10000 ETH)(2) 0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc (10000 ETH)(3) 0x90f79bf6eb2c4f870365e785982e1f101e93b906 (10000 ETH)(4) 0x15d34aaf54267db7d7c367839aaf71a00a2c6a65 (10000 ETH)(5) 0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc (10000 ETH)(6) 0x976ea74026e726554db657fa54763abd0c3a0aa9 (10000 ETH)(7) 0x14dc79964da2c08b23698b3d3cc7ca32193d9955 (10000 ETH)(8) 0x23618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f (10000 ETH)(9) 0xa0ee7a142d267c1f36714e4a8f75612f20a79720 (10000 ETH)Private Keys==================(0) 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80(1) 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d(2) 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a(3) 0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6(4) 0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a(5) 0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba(6) 0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e(7) 0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356(8) 0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97(9) 0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6Wallet==================Mnemonic:          test test test test test test test test test test test junkDerivation path:   m/44’/60’/0’/0/Base Fee==================1000000000Gas Limit==================30000000Genesis Timestamp==================1673605968Listening on 127.0.0.1:8545

Just like that, we’re running an Ethereum testnet on our local machine. Pretty magical, if you ask me! ✨

By default, the Ethereum RPC for the testnet will be available on http://localhost:8545.

anvil helpfully provides us with some private keys which are pre-populated with testnet ether. These wallets enable you to start making transactions without having to create actions to manually populate wallet balances or needing to deploy a faucet.

Next, let’s run yarn dev in a second shell to start the RainbowKit template application on http://localhost:3000:

The RainbowKit   example starter page.

Notice that under pages/_app.tsx, the included ethers provider has been configured to use the Alchemy default API Key:

    // This is Alchemy’s default API key.     // You can get your own at <https://dashboard.alchemyapi.io>     apiKey: ‘_gg7wSSi0KMBsdKnGVfHDueq6xMB9EkC’,

Since this is shared by many application instances, your performance may vary. It’s advisable to create your own key to avoid having your requests throttled.

For our demo, we plan to remove the example chains and providers altogether, and instead configure the application to target our local testnet.

Getting our hands dirty

Spongebob Jellyfish catching licking.

Applications usually need to be configured for different environments. To start out, let’s quickly add some environment variables we might want to change depending on where we run the application:

const TESTNET_URL =  process.env.NEXT_PUBLIC_TESTNET_URL || ‘http://localhost:8545’;const TESTNET_WALLET_KEY = process.env.NEXT_PUBLIC_TESTNET_WALLET_KEY;

These allow us to test with different wallets and different deployment URLs.

Now for a big chunk. We want to add a mockWallet() to integrate with RainbowKit . We need to set up connections to the local testnet:

// Note: I’m changing the imports generated through the Rainbowkit// starter package to `from ‘@wagmi/…’;`. See the example repo for// a complete view of these changes.// Add these importsimport type { Wallet as RainbowWallet } from ‘@rainbow-me/rainbowkit’;// wagmi provides a lot of chains out of the box, including testnets.// Here we can just import their definitions for `foundry`!import { foundry } from ‘@wagmi/core/chains’;// Here it is!import { MockConnector } from ‘@wagmi/core/connectors/mock’;import { providers, Wallet } from ‘ethers’; // // Signers help us authenticate transactions.// Passing a private key in our environment will initialize an ethers // Wallet as a signer, else we’ll fallback to a random signer.const signer = TESTNET_WALLET_KEY  ? new Wallet(TESTNET_WALLET_KEY, new providers.JsonRpcProvider(TESTNET_URL))  : Wallet.createRandom();// And create a function which returns a Rainbowkit-compatible Wallet// using the Signer created above with wagmi’s MockConnector.const mockWallet = (): RainbowkitWallet => ({  createConnector: () => ({    connector: new MockConnector({      chains: [foundry],      options: {        // It is possible to create different kinds of wallets        // which have different behaviours. These allow you to        // test different user flows!        flags: {          failConnect: false,          failSwitchChain: false,          isAuthorized: true,          noSwitchChain: false,        },        signer, // ✅      },    }),  }),  id: ‘mock’,  iconBackground: ‘tomato’,  iconUrl: async () => ‘<http://placekitten.com/100/100>’,  name: ‘Mock Wallet’,});

Instead of using getDefaultWallets() to fetch our connectors for wagmi client, we’ll import connectorsForWallets() and set it up like so:

import { connectorsForWallets } from ‘@rainbow-me/rainbowkit’;const connectors = connectorsForWallets([  {    groupName: ‘Testing’,    wallets: [mockWallet()],  },]);

Once this is done, we’ll modify the existing call to configureChains() to go to the foundry testnet. We’ll replace the preconfigured networks and providers with a provider which pointing to our TESTNET_URL:

import { jsonRpcProvider } from ‘@wagmi/core/providers/jsonRpc’;const { chains, provider, webSocketProvider } = configureChains(  [foundry],  [jsonRpcProvider({ rpc: () => ({ http: TESTNET_URL }) })]);

Finally, let’s hook up our environment variables.

We can populate our freshly required NEXT_PUBLIC_TESTNET_WALLET_KEY with one of the default keys provided by anvil. Lets ploink the first private key anvil gave us into a .env:

NEXT_PUBLIC_TESTNET_WALLET_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

We still have our dev server running on http://localhost:3000. Restart the dev server. (Don’t forget this. It’ll wreck you.) And let’s see what happens once we refresh the page and connect our wallet!

The RainbowKit   starter page with the test wallet connecting.

Looks like our balance is 10k ETH!

Whale alert!

We can be a whale in our tiny testing corner if we want to

To discover the wallet balance, the application frontend had to make requests to our local RPC to determine what blockchain it is running on, and then query for the balance of the wallet’s address.

All of this information was served as JSON via our local RPC. In fact, you can see these incoming requests logged in the anvil debug output:

eth_chainIdeth_getBalance

We’ve successfully got our dApp talking to our local testnet! With this setup you can get a lot of things going.

What about the testing though?

There is one last key to the puzzle. The example test will be pretty far from a real-world one, where you don’t merely want to connect and check balance, but also execute transactions. As the blockchain chugs along, our tests might will get out of sync.

The solution here is creating a fork of the blockchain. anvil has us covered with the CLI options –fork-url and –fork-block-number. By setting these options to something meaningful, anvil will create a fork at a specific block number. These allow you to make the tests idempotent, because you’re starting your test at a specific point in history of a live blockchain!

A –fork-url needs to point to an archival node. There are some public endpoints available on the web.
Shoutout to
pokt.network who offer a freely-available 10M req/d archival endpoint

Testing should be frictionless. Having to spin up anvil and NextJS manually gets super-tedious super-fast. Let’s solve that by letting Playwright orchestrate that for us. Append the following to the .env file created earlier:

FORK_BLOCK_NUMBER=16391695FORK_URL=https://eth-archival-rpc.gateway.pokt.network

We’ll hit a small snag though. We need a consistent way to propagate our environment to the child processes, though. Since the demo is built on NextJS it makes most sense to adhere to their heuristics:

npm install -D @next/env

Now we can head over to playwright.config.js, hook up environment initialization, and use the webServer property to easily spin up anvil alongside our NextJS application:

// Add this somewhere at the topimport { loadEnvConfig } from ‘@next/env’;// The Playwright config doesn’t like @next/env’s Env type.const env: Record<string, string> = Object.entries(  loadEnvConfig(process.cwd()).combinedEnv).reduce(  (env, [key, value]) => ({ …env, …(value && { [key]: value }) }),  {});const config = {  // Now modify the webServer property. It’s just sub shells  webServer: [    {      command: [        `anvil`,        `–fork-block-number=${env.FORK_BLOCK_NUMBER}`,        `–fork-url=${env.FORK_URL}`,      ].join(‘ ‘),      env,      port: 8545,    },    {      command: ‘npm run dev’,      env,      port: 3000,    },  ],}

Let’s add a simple test case which check whether the connected wallet has 10ETH on start, just to confirm we are indeed still whales.

Let this be our minimal test in tests/example.spec.ts ‍♂️:

test(‘ ‘, async ({ page }) => {  await page.goto(‘<http://localhost:3000/>’);  // Note that we don’t see the balance on small viewports  await expect(page.getByText(’10k ETH’)).toBeVisible();  await expect(page.getByText(‘0xf3…2266’)).toBeVisible();});

Finally, let’s add the on top in our package.json. Below, we add a new script e2e which will allow us to start our tests using the command yarn e2e:

“scripts”: {  “e2e”: “playwright”,  …}

The hard part is over. We have our application talking to a local chain, we’ve been able to fork the chain state, Playwright is installed and configured, and we have a test. Moment of truth! Let’s see if it works:

npm run e2e test # > [email protected] e2e> playwright testRunning 3 tests using 3 workers[WebServer] [DEPRECATED] `getStorage`, `serialize` and `deserialize` options are deprecated. Please use `storage` option instead.[WebServer] [DEPRECATED] `getStorage`, `serialize` and `deserialize` options are deprecated. Please use `storage` option instead.  3 passed (4s)To open last HTML report run:  npx playwright show-report

Of course it does. It’s a tutorial!

I mean, sure something’s complaining. I haven’t had a npm install without deprecation warnings in years.

Testing dApps

Hopefully you have found the guide above helpful! By sharing our approach, we hope to aid other teams in getting solid testing up and running for dApps. We all share the same goals of providing our users delightful experiences. Helping others helps us all. ❤️

We think end-to-end smoke tests are one of the most critical parts to get right in your testing stack. They test a lot of interdependent, connected systems at once. Having these set up will get you a lot of mileage!

The same setup should work for integration testing as well. wagmi solves this by firing up a testnet and testing against that using the amazing @testing-library and jest. The reNFT team will be using a similar setup for our lighter integration testing too.

TLDRepo: Example Repository

You can find the complete code for the tutorial above is available in an example repository on the reNFT GitHub. As a bonus, you’ll also find some example GitHub Actions configured to run our End-to-End tests on every push or Pull Request into the main branch.

Ship it!