Introduction
Probably you already know the benefits of testing your applications: how it can boost your team's confidence, help in refactoring, avoid regressions, make part of documentation and, generally, save you time in the long run.
With that in mind, this article will assume you already understand these advantages and will instead focus on how to effectively test web3 apps built with React Native, highlighting the similarities and differences compared to non-web3 apps.
If, instead, you want to know how to bootstrap and use Viem, Wagmi, WalletConnect Web3Modal, and more web3 libraries in React Native, check out these articles:
- Build Modern Web3 dApps on Ethereum With React Native and Viem
- Best DX for React Native Web3 dApps With Web3Modal and Wagmi
- Cross-Platform Web3 dApps With React Native
- How to Build Universal Lens Protocol Social Apps With React Native
What we are testing
The example we'll use resembles a contacts list, where the user connects their wallet to see a list of contacts with their addresses. Pressing a contact brings up a bottom sheet, allowing the user to send a transaction with some ETH to the selected contact.
Though simple, this example covers enough real use cases to help you understand the best approach to test features like connecting a wallet, sending a transaction, and interacting with a blockchain instead of a server.
All our blockchain interactions will be done through Wagmi, the most popular React Hooks library for Ethereum. Wagmi is built on top of Viem, the one that actually implements the RPC calls, transactions, wallet connection, etc. If you want to jump straight to the code, it is available open-source on GitHub.
Here's what it looks like:
In this example we're using WalletConnect Web3Modal, which is the most popular “Connect Wallet Modal” option with good support for React Native. We're using an alpha version of Web3Modal with support for Wagmi v2. While things might break, we found it stable for the contents of this article.
Configuration
To configure the project for testing, refer to the Expo docs on Unit Testing, and the React Native Testing Library docs.
We will be running our tests against Anvil, a local blockchain server. This allows us to have realistic interactions with the blockchain but without the cost of the network delay or paying gas fees. Anvil is part of Foundry, a smart contract development toolchain. Start by installing foundryup, the Foundry toolchain installer, and then running itself to install the Anvil binary.
Then, install the project testing dependencies:
In package.json, add the test scripts and Jest settings:
Here's a breakdown of what these settings are changing:
- First, we added a test script to run the tests. Since we will run our tests against a local blockchain, we need to run it in parallel with the tests. We use concurrently to be able to run both anvil and jest at the same time.
- In the Jest config, "preset": "jest-expo" will facilitate testing Expo apps by mocking common expo native modules as well as adding utilities for testing universal apps.
- Also, "transformIgnorePatterns" specifies which packages inside node_modules shall be transformed by Babel. Many React Native packages don't pre-compile their source code before publishing, so they need to be transformed by Babel before being interpreted by Jest. The default list is provided by Expo as a starter, and should cover the majority of packages in the ecosystem. However, note that we also appended @walletconnect/react-native-compat, wagmi and @wagmi/.* at the end, which will cover these web3 libraries specific to our project.
- Finally, "setupFilesAfterEnv" specifies a script to configure the testing framework and will be run before each test. We will create this file next.
Now, create the setup-jest.ts file, in the project root:
This file will be run before all tests, so it's a good place to prepare global settings. We will import the React Native specific Jest Matchers, which will allow us to use matchers that wouldn't be present in jest-dom, such as .toBeOnTheScreen(), and others that behave differently on mobile, such as toBeVisible().
It's also a good place to mock native modules that won't work outside the native environment. Let's put those mocks in a separate file, src/test/mocks.tsx to keep things organized since we will be consuming those from other places later.
If you're following on your own project, you might not need some of the mocks being used here, such as @gorhom/bottom-sheet, and instead need to mock other of your dependencies.
Remember, though: mocking replaces the code you use in production with something else during tests. While it's useful to mock parts that wouldn't work in the test environment (such as native modules that depend on the device OS), you should strive to mock as little as possible. The less your testing code resembles your production code, the less confidence you can have that it will actually catch bugs a real user might encounter.
Done with the configuration, let's get to actually writing tests.
Connecting to the local blockchain
The primary difference when running tests for our web3 application is that we need to direct all our actions to our local blockchain instance, rather than interacting with deployed blockchains. This is necessary because even testnets like Sepolia require gas fees and involve waiting for network calls.
We need our tests to be fast, reliable and reproducible. Using a local running blockchain such as Anvil we skip the network call (as it is running in localhost) and it provides us with several persistent accounts (which don't change between Anvil runs) with huge amount of tokens that can be used to test transactions. Moreover, we have the ability to reset the blockchain state between each test to make sure they're independent of each other.
Using Wagmi, to change the network we're pointing to, it's just a matter of changing the wagmiConfig passed to the provider.
If you look at our application's Wagmi setup in App.tsx, you’ll notice that we create it using WalletConnect's Web3Modal util functions. Web3Modal provides this to facilitate integration with their components.
But during tests, what we need to do is recreate this config, using the standard Wagmi configuration, and choose the foundry chain, which will automatically point to the Anvil instance running in localhost.
Let's do this in a new file, src/test/config.tsx:
Here, notice a few things:
- At the beginning we define a chains constant, and put the foundry chain inside, which will point to Anvil.
- Then we define a TEST_ACCOUNTS array to hold the static addresses and private keys we will use for testing. These accounts are automatically funded high amounts of ETH when Anvil starts.
- Then we define the testClient, a Viem client to communicate with the blockchain:
- Pass the chain defined earlier.
- Pass the account and private key defined earlier, so we can sign for transactions.
- Use a low pollingInterval so changes in the blockchain are reflected faster on the client, speeding up tests.
- Extend the testClient with public and wallet actions, so it can also act as a publicClient (query info from the blockchain) and walletClient (transact, sign messages, and do other wallet actions).
- Finally, we define a renderWithProviders() function, that will be used during the actual tests. This function creates a test Wagmi config using everything we defined above, and passes it to the actual component tree, mimicking the same tree we have in the application code, but for tests.
Writing tests
When writing tests, we need to put ourselves in the users shoes. What actions can they take in our system? We need to be able to describe it non-technically, as if we were describing our favorite cake recipe to someone.
Let's go over the main features of the example app, writing the tests in a new file, src/features/home/HomeScreen.test.tsx.
Test: User is disconnected
When the user hasn't connected a wallet yet, we expect him to:
- See a “Connect Wallet” button.
- See a message indicating that it needs to connect to view the contacts.
Always start by rendering the app. Usually, you should use the render function from React Native Testing Library, but in our case we created the wrapper renderWithProviders, which will call render internally, wrapping it with the necessary providers for our specific needs.
To get the connect button, use screen.getByRole, which will try to find elements in the render output with the "button" accessibility role and "Connect Wallet" text.
To get the message, use screen.getByText, which will try to find an element with that written text in the render output.
Finally, expect all elements toBeOnTheScreen().
Going
into detail on all React Native Testing Library APIs would require a separate article. If you're not familiar with these, please check out the API docs.
Test: User connects the wallet
When the user connects the wallet, we now expect that a list should appear, showing contact names.
In this example, we're not only querying elements on the screen but also interacting with them using user.press(). This is a React Native Testing Library API called User Events, which simulates realistic user events such as press, long press, type, and scroll.
To be able to use User Events, a one-liner setup is needed. Place it at the top of the test file, outside all tests:
Test: Transaction succeeds
Sending a transaction is the critical feature of our example, so this might be the most important test.
When the user wants to send a transaction, a couple of things must happen:
- They need to connect the wallet
- They need to press a contact
- They need to see the transaction details
- They need to press the confirm button
- They expect some kind of loading indicator to appear
- They expect some kind of success indicator to appear
So let's put those assumptions in our tests:
Notice how every test assumption is based on elements and actions the user can actually see. We're not checking for functions being called or considering that any piece of code must be called with param X, Y or Z. To better understand why testing implementation details is bad, head over to the article by Kent C. Dodds.
Also, notice that in this test we're altering the state of the blockchain as we're settling a transaction and moving ETH from one account to another.
To make sure that tests stay independent of each other, that is, one test does not depend on the result of the previous one, we should set up a mechanism to reset the blockchain state to the same initial state after each test, so all of them rely on the same pristine state.
This is trivial using Jest's beforeAll() and afterEach() hooks, and using the testClient we defined before to manually dump and load the blockchain state:
Test: transaction fails
When testing for a failed transaction, the flow is pretty much the same - except at the end a failure toast should appear instead of a success toast.
To simulate a transaction failing at the blockchain level, such as a transaction being reverted, we can mock Wagmi's useWaitForTransaction hook, which watches the submitted transaction until it completes or fails.
In this case, it's as simple as returning a loading state and after some time mutating it to an error state. Let's create this in the same src/test/mocks.tsx file we used before:
Then, we just need to tell Jest to mock this hook specifically for this test so it doesn't affect others. We can do this by using Jest spies:
Remember to call spy.mockRestore() at the end of the test so the mock is not kept active and interferes with the next tests!
You may also have noticed some repeating patterns at this point. We always need to connect the wallet, and also most of times need to find and press a contact in the list. To avoid repeating the same lines of code all the time, we can extract those in util functions, in a new file called src/test/utils.tsx:
And then use it in all our tests:
Test: Insufficient balance
When a user has insufficient balance to send a transaction, the button to confirm must be disabled from the start.
To test this case, instead of mocking another Wagmi hook to artificially return a zero balance, wouldn't it be better if we could just tell our blockchain to set an account's balance to zero? This way we don't need to mock and diverge from our application code.
That is exactly why testClient.setBalance() exists. This Viem utility will send an RPC call to our local blockchain setting the balance of an account to any amount we want. Of course, this doesn't exist in a real blockchain — it's a feature from Anvil and other development blockchains.
Viem helps us to use this and other development features through Test Actions, like setBalance. Read more in the setBalance docs.
Test: Confirm button should disable while loading
Our last test will be related to testing elements while they are in the loading state. In our example, the confirm button should also be disabled while the transaction is being processed on the blockchain side.
Usually, this is hard to test because we depend on an external system (the blockchain). Luckily, another of Anvil's testing features allows us to programatically stop mining new blocks. That way, the transaction will never be confirmed and our app will be frozen in a loading state — perfect situation to check our elements.
The following must happen:
- Disable automining new blocks
- Send the transaction (will be stuck in a loading state)
- Check if the loading state is correct
- Enable mining blocks again (the transaction will confirm)
- Expect success
This feature is also accessible through a Viem Test Action. Read more in the setAutomine docs.
Summary
By this point, you may have noticed that testing a web3 app is mostly like testing all other apps. All testing good practices still apply, such as not testing implementation details and avoid mocking as much as possible.
What differs is that the external system we depend on is the blockchain — and we use a local development instance to simulate our transactions.
In this article, we've covered how to setup your Expo React Native web3 app for testing with popular tools like Anvil, Wagmi, Viem, and Web3Modal. And we've also talked about a few tricks to keep in your toolbelt:
- Creating a test config and pointing to a development blockchain during tests
- What to test, and how to avoid testing implementation details
- Using beforeAll and afterEach to reset the blockchain state and achieve isolated tests
- Manually changing the blockchain state with Test Actions for more realistic tests
- Extracting repeated tests into util functions
The final code is available open-source on GitHub.