Web3.js and ethers.js Integration Testing: DApp Backend Test Patterns
Integration testing for Web3 applications verifies that your application code correctly interacts with smart contracts under realistic conditions. Using ethers.js with a local Hardhat node gives you fast, deterministic tests with full control over blockchain state. Mainnet forking adds real protocol state when you need to test against live contracts.
Key Takeaways
Integration tests live between unit tests and E2E tests. They verify that your application's contract interaction code — not just the contracts themselves — behaves correctly across multiple operations and contracts.
Always test against a local node first. A local Hardhat node is fast, free, and fully controllable. Reserve mainnet fork tests for scenarios that genuinely require real protocol state.
TypeChain generated types eliminate a class of bugs. Generated TypeScript types for your contracts catch wrong argument types, missing arguments, and incorrect return type assumptions at compile time rather than runtime.
Multicall batches multiple reads into one RPC call. Reading 20 token balances with 20 separate calls is slow and expensive. Multicall3 bundles them into one network request.
Test the full approval flow, not just the happy path. ERC20 approvals are a common failure point — test zero allowance, partial allowance, max allowance, and the race condition between approve calls.
What Integration Testing Covers
Unit tests for smart contracts verify that individual functions behave correctly. Integration tests for Web3 applications verify that:
- Your application correctly encodes and sends transactions
- Your application correctly reads and interprets contract state
- Multiple contracts interact correctly when your code orchestrates them
- Error states propagate correctly from the contract back through your application code
- Operations requiring multiple steps (approve + deposit, create + configure) succeed as sequences
This guide focuses on testing the application layer — the JavaScript or TypeScript code that calls contracts — rather than the contracts themselves.
Setting Up the Test Environment
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install ethersFor TypeChain type generation (included in hardhat-toolbox):
# Types are generated automatically on compile:
npx hardhat compile
<span class="hljs-comment"># Output in typechain-types/Project Structure
project/
├── contracts/
│ ├── Vault.sol
│ └── MockERC20.sol
├── src/
│ └── VaultService.ts # ← application code being tested
├── test/
│ └── VaultService.test.ts # ← integration tests
├── typechain-types/ # ← generated types
└── hardhat.config.tsThe Application Service Under Test
// src/VaultService.ts
import { ethers, Signer, Provider } from "ethers";
import { Vault, Vault__factory, IERC20, IERC20__factory } from "../typechain-types";
export class VaultService {
private vault: Vault;
private token: IERC20;
constructor(
private readonly vaultAddress: string,
private readonly signer: Signer
) {
this.vault = Vault__factory.connect(vaultAddress, signer);
this.token = IERC20__factory.connect("", signer); // connected lazily
}
async getTokenAddress(): Promise<string> {
return this.vault.token();
}
async getBalance(userAddress: string): Promise<bigint> {
return this.vault.balances(userAddress);
}
async getTotalDeposited(): Promise<bigint> {
return this.vault.totalDeposited();
}
async deposit(amount: bigint): Promise<ethers.TransactionReceipt> {
const tokenAddress = await this.getTokenAddress();
this.token = IERC20__factory.connect(tokenAddress, this.signer);
// Check allowance
const userAddress = await this.signer.getAddress();
const allowance = await this.token.allowance(userAddress, this.vaultAddress);
// Approve if needed
if (allowance < amount) {
const approveTx = await this.token.approve(this.vaultAddress, amount);
await approveTx.wait();
}
const tx = await this.vault.deposit(amount);
const receipt = await tx.wait();
if (!receipt) throw new Error("Transaction failed");
return receipt;
}
async withdraw(amount: bigint): Promise<ethers.TransactionReceipt> {
const balance = await this.vault.balances(
await this.signer.getAddress()
);
if (balance < amount) {
throw new Error(
`Insufficient vault balance: have ${balance}, requested ${amount}`
);
}
const tx = await this.vault.withdraw(amount);
const receipt = await tx.wait();
if (!receipt) throw new Error("Transaction failed");
return receipt;
}
async getDepositedEvent(receipt: ethers.TransactionReceipt) {
const iface = this.vault.interface;
for (const log of receipt.logs) {
try {
const parsed = iface.parseLog(log);
if (parsed?.name === "Deposited") {
return parsed;
}
} catch {
// not a Vault log
}
}
return null;
}
}Writing Integration Tests
Fixture Setup
// test/VaultService.test.ts
import { expect } from "chai";
import { ethers } from "hardhat";
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
import { VaultService } from "../src/VaultService";
import { Vault, MockERC20 } from "../typechain-types";
async function deployFixture() {
const [owner, alice, bob] = await ethers.getSigners();
// Deploy mock ERC20
const TokenFactory = await ethers.getContractFactory("MockERC20");
const token = (await TokenFactory.deploy("Test Token", "TTK", 18)) as MockERC20;
// Deploy vault
const VaultFactory = await ethers.getContractFactory("Vault");
const vault = (await VaultFactory.deploy(await token.getAddress())) as Vault;
const vaultAddress = await vault.getAddress();
const tokenAddress = await token.getAddress();
// Mint tokens
const MINT_AMOUNT = ethers.parseEther("10000");
await token.mint(alice.address, MINT_AMOUNT);
await token.mint(bob.address, MINT_AMOUNT);
// Create services
const aliceService = new VaultService(vaultAddress, alice);
const bobService = new VaultService(vaultAddress, bob);
return {
vault, token, owner, alice, bob,
vaultAddress, tokenAddress,
aliceService, bobService,
MINT_AMOUNT,
};
}Testing Contract Reads
describe("VaultService - reads", function () {
it("should return the correct token address", async function () {
const { aliceService, tokenAddress } = await loadFixture(deployFixture);
expect(await aliceService.getTokenAddress()).to.equal(tokenAddress);
});
it("should return zero balance for new user", async function () {
const { aliceService, alice } = await loadFixture(deployFixture);
expect(await aliceService.getBalance(alice.address)).to.equal(0n);
});
it("should return zero totalDeposited initially", async function () {
const { aliceService } = await loadFixture(deployFixture);
expect(await aliceService.getTotalDeposited()).to.equal(0n);
});
});Testing the Deposit Flow
describe("VaultService - deposit", function () {
it("should handle approve + deposit in one call", async function () {
const { aliceService, alice, token, vaultAddress } =
await loadFixture(deployFixture);
const amount = ethers.parseEther("100");
// Before: no allowance
expect(
await token.allowance(alice.address, vaultAddress)
).to.equal(0n);
const receipt = await aliceService.deposit(amount);
// After: balance recorded
expect(await aliceService.getBalance(alice.address)).to.equal(amount);
expect(await aliceService.getTotalDeposited()).to.equal(amount);
// Transaction was mined
expect(receipt.status).to.equal(1);
});
it("should skip approve if allowance is sufficient", async function () {
const { aliceService, alice, token, vaultAddress } =
await loadFixture(deployFixture);
// Pre-approve a large amount
const PRE_APPROVE = ethers.parseEther("10000");
await token.connect(alice).approve(vaultAddress, PRE_APPROVE);
const amount = ethers.parseEther("100");
// Count total transactions — if no approve, only 1 tx
const txCountBefore = await ethers.provider.getTransactionCount(
alice.address
);
await aliceService.deposit(amount);
const txCountAfter = await ethers.provider.getTransactionCount(
alice.address
);
// Only 1 tx (the deposit), not 2 (approve + deposit)
expect(txCountAfter - txCountBefore).to.equal(1);
});
it("should correctly parse Deposited event from receipt", async function () {
const { aliceService, alice } = await loadFixture(deployFixture);
const amount = ethers.parseEther("100");
const receipt = await aliceService.deposit(amount);
const event = await aliceService.getDepositedEvent(receipt);
expect(event).to.not.be.null;
expect(event!.args.user).to.equal(alice.address);
expect(event!.args.amount).to.equal(amount);
});
it("should accumulate balances across multiple deposits", async function () {
const { aliceService, alice } = await loadFixture(deployFixture);
await aliceService.deposit(ethers.parseEther("100"));
await aliceService.deposit(ethers.parseEther("200"));
await aliceService.deposit(ethers.parseEther("50"));
expect(await aliceService.getBalance(alice.address)).to.equal(
ethers.parseEther("350")
);
});
});Testing Error Propagation
describe("VaultService - error handling", function () {
it("should throw application error when withdraw exceeds balance", async function () {
const { aliceService } = await loadFixture(deployFixture);
await aliceService.deposit(ethers.parseEther("100"));
await expect(
aliceService.withdraw(ethers.parseEther("200"))
).to.be.rejectedWith(/Insufficient vault balance/);
});
it("should propagate contract revert when vault is paused", async function () {
const { vault, aliceService } = await loadFixture(deployFixture);
await vault.pause();
// The contract revert (VaultPaused) should surface here
await expect(
aliceService.deposit(ethers.parseEther("100"))
).to.be.reverted;
});
});Testing with a Local Hardhat Node
For tests that involve a running server or browser code, spin up a persistent Hardhat node:
npx hardhat node
# Starts JSON-RPC at http://127.0.0.1:8545Connect from application code:
const provider = new ethers.JsonRpcProvider("http://127.0.0.1:8545");
const signer = await provider.getSigner(0); // first pre-funded accountFor Jest-based test suites (not Hardhat Mocha):
// jest.config.ts
export default {
globalSetup: "./test/setup/hardhat-node.ts",
globalTeardown: "./test/setup/hardhat-node-teardown.ts",
};
// test/setup/hardhat-node.ts
import { spawn } from "child_process";
export default async function setup() {
const node = spawn("npx", ["hardhat", "node"], { stdio: "pipe" });
// Wait for node to be ready
await new Promise<void>((resolve) => {
node.stdout?.on("data", (data: Buffer) => {
if (data.toString().includes("Started HTTP")) resolve();
});
});
(global as any).__HARDHAT_NODE__ = node;
}Multicall: Batching RPC Calls
Reading state for multiple addresses or tokens in a loop is slow — each await is a round trip. Multicall3 (deployed on mainnet at 0xcA11bde05977b3631167028862bE2a173976CA11) bundles them:
import { ethers } from "ethers";
const MULTICALL3 = "0xcA11bde05977b3631167028862bE2a173976CA11";
const MULTICALL3_ABI = [
"function aggregate3(tuple(address target, bool allowFailure, bytes callData)[] calls) view returns (tuple(bool success, bytes returnData)[])",
];
async function getMultipleBalances(
tokenAddress: string,
userAddresses: string[],
provider: ethers.Provider
): Promise<bigint[]> {
const multicall = new ethers.Contract(MULTICALL3, MULTICALL3_ABI, provider);
const token = new ethers.Interface([
"function balanceOf(address) view returns (uint256)",
]);
const calls = userAddresses.map((addr) => ({
target: tokenAddress,
allowFailure: false,
callData: token.encodeFunctionData("balanceOf", [addr]),
}));
const results = await multicall.aggregate3(calls);
return results.map((result: { success: boolean; returnData: string }) => {
if (!result.success) return 0n;
return token.decodeFunctionResult("balanceOf", result.returnData)[0] as bigint;
});
}
// Test:
it("should fetch multiple balances in one call", async function () {
const { token, alice, bob } = await loadFixture(deployFixture);
const tokenAddress = await token.getAddress();
const balances = await getMultipleBalances(
tokenAddress,
[alice.address, bob.address],
ethers.provider
);
expect(balances[0]).to.equal(await token.balanceOf(alice.address));
expect(balances[1]).to.equal(await token.balanceOf(bob.address));
});Testing Token Approvals Thoroughly
ERC20 approvals are a frequent source of bugs. Test all the edge cases:
describe("ERC20 approval flows", function () {
it("should work with exact allowance (no excess)", async function () {
const { token, alice, vault } = await loadFixture(deployFixture);
const amount = ethers.parseEther("100");
// Approve exactly the deposit amount
await token.connect(alice).approve(await vault.getAddress(), amount);
await vault.connect(alice).deposit(amount);
// After deposit, allowance should be 0
expect(
await token.allowance(alice.address, await vault.getAddress())
).to.equal(0n);
});
it("should work with max allowance (approve once, deposit many)", async function () {
const { token, alice, vault } = await loadFixture(deployFixture);
await token.connect(alice).approve(await vault.getAddress(), ethers.MaxUint256);
await vault.connect(alice).deposit(ethers.parseEther("100"));
await vault.connect(alice).deposit(ethers.parseEther("200"));
// Max allowance is not consumed by transfers (ERC20 spec)
expect(
await token.allowance(alice.address, await vault.getAddress())
).to.equal(ethers.MaxUint256);
});
it("should revert deposit with insufficient allowance", async function () {
const { token, alice, vault } = await loadFixture(deployFixture);
const depositAmount = ethers.parseEther("100");
// Approve less than deposit amount
await token
.connect(alice)
.approve(await vault.getAddress(), ethers.parseEther("50"));
await expect(
vault.connect(alice).deposit(depositAmount)
).to.be.reverted; // ERC20InsufficientAllowance
});
});Mainnet Fork Integration Tests
// test/fork/UniswapIntegration.test.ts
import { reset, impersonateAccount } from "@nomicfoundation/hardhat-network-helpers";
describe("Uniswap V3 integration (mainnet fork)", function () {
before(async function () {
if (!process.env.MAINNET_RPC_URL) {
this.skip(); // skip if no RPC URL configured
}
await reset(process.env.MAINNET_RPC_URL, 19_500_000);
});
it("should swap USDC for WETH", async function () {
const USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const WHALE = "0x37305B1cD40574E4C5Ce33f8e8306Be057fD7341";
await impersonateAccount(WHALE);
const whale = await ethers.getSigner(WHALE);
const usdc = await ethers.getContractAt("IERC20", USDC_ADDRESS, whale);
const balanceBefore = await usdc.balanceOf(WHALE);
// ... perform swap
expect(await usdc.balanceOf(WHALE)).to.be.lt(balanceBefore);
});
});Run fork tests separately in CI to avoid slowing down the unit test suite:
// package.json scripts
"test": "hardhat test test/**/*.test.ts",
"test:fork": "MAINNET_RPC_URL=$MAINNET_RPC hardhat test test/fork/**/*.test.ts"Summary
Web3 integration testing with ethers.js sits between smart contract unit tests and full E2E tests. It catches bugs in your application code — wrong argument encoding, missing approval steps, incorrect error handling — that unit tests on the contract won't reveal. Using loadFixture, TypeChain types, and the local Hardhat node gives you fast, deterministic integration tests that run in CI without external dependencies. Reserve mainnet fork tests for scenarios that genuinely need real protocol state.