Write unit tests
It's time to write unit tests for your first Equito App. In this section, you will learn how to write comprehensive unit tests for the PingPong contract featured in the previous section of this tutorial.
Setting up the environment
- Foundry
- Hardhat
Foundry provides a built-in testing framework that allows you to write and run tests for your contracts. You can create test files in the test/
folder and use the Test
contract from the forge-std
library to define test cases.
Create a new file PingPongTest.t.sol
in the test/
folder with the following content:
pragma solidity ^0.8.23;
import {Test, console} from "forge-std/Test.sol";
import {Vm} from "forge-std/Vm.sol";
import {PingPong} from "@equito/src/examples/PingPong.sol";
import {Router} from "@equito/src/Router.sol";
import {MockVerifier} from "@equito/test/mock/MockVerifier.sol";
import {MockEquitoFees} from "@equito/test/mock/MockEquitoFees.sol";
import {bytes64, EquitoMessage, EquitoMessageLibrary} from "@equito/src/libraries/EquitoMessageLibrary.sol";
import {Errors} from "@equito/src/libraries/Errors.sol";
contract PingPongTest is Test {
// Test cases go here...
}
Hardhat provides a comprehensive testing framework with the ability to write and run tests using Mocha and Chai. You can create test files in the test/
folder and use the Hardhat runtime environment to interact with your contracts.
Create a new file PingPong.ts
in the test/
folder with the following content:
import { ethers } from "hardhat";
import { expect } from "chai";
import { AbiCoder } from "ethers";
import { Bytes64Struct } from "../utils";
import { generateHash } from "@equito-sdk/ethers";
describe("PingPong Contract Tests", function () {
// Test cases go here...
});
Notice that we are importing utility functions from utils/util.ts
.
The addressToBytes64
function converts an Ethereum address to a Bytes64Struct
structure, adapted from the Solidity method in the equito-evm-contracts repository. The generateHash
function helps to hash a provided message into a bytes32
hash.
Make sure the external Solidity files that your project depends on, are included in dependencyCompiler configuration in Hardhat. This feature allows you to specify the paths to external contract files that should be compiled along with your project's contracts. It should be placed as follows in hardhat.config.ts
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import "@nomicfoundation/hardhat-foundry";
import "@nomicfoundation/hardhat-ethers";
import "hardhat-dependency-compiler";
const config: HardhatUserConfig = {
solidity: {
version: "0.8.23",
settings: {
optimizer: {
enabled: true,
runs: 200,
},
},
},
--snip---
--snip---
dependencyCompiler: {
paths: [
"lib/equito/src/ECDSAVerifier.sol",
"lib/equito/src/Router.sol",
"lib/equito/test/mock/MockOracle.sol",
"lib/equito/test/mock/MockEquitoFees.sol",
"lib/equito/test/mock/MockVerifier.sol",
],
},
};
export default config;
Initialize the contracts
Before writing test cases, you need to initialize the contracts that you want to test and their dependencies. In this case, you need to initialize the PingPong
contract and its dependencies such as the Router
, MockVerifier
, and MockEquitoFees
contracts.
The Router will be deployed with chain selector 0, and the PingPong contract will have a couple of simulated peers with chain selectors 1 and 2, meaning that these addresses do not match any deployed contract.
- Foundry
- Hardhat
We start from defining which contracts we want to deploy, some useful addresses, and the events that we expect to be emitted.
contract PingPongTest is Test {
Router router;
MockVerifier verifier;
MockEquitoFees fees;
PingPong pingPong;
address sender = address(0xa0);
address equitoAddress = address(0xe0);
address peer1 = address(0x01);
address peer2 = address(0x02);
event PingSent(
uint256 indexed destinationChainSelector,
bytes32 messageHash
);
event PingReceived(
uint256 indexed sourceChainSelector,
bytes32 messageHash
);
event PongReceived(
uint256 indexed sourceChainSelector,
bytes32 messageHash
);
event MessageSendRequested(
EquitoMessage message,
bytes messageData
);
// Test cases go here...
}
Then we define the setUp
function, which Foundry will call before running each test case.
contract PingPongTest is Test {
// Previous code...
function setUp() public override {
vm.prank(sender);
// Deploy the Equito contracts
verifier = new MockVerifier();
fees = new MockEquitoFees();
router = new Router(
0, // local chainSelector
address(verifier),
address(fees),
EquitoMessageLibrary.addressToBytes64(equitoAddress)
);
// Deploy the PingPong Contract
pingPong = new PingPong(address(router));
// Set the peers
uint256[] memory chainSelectors = new uint256[](2);
chainSelectors[0] = 1;
chainSelectors[1] = 2;
bytes64[] memory addresses = new bytes64[](2);
addresses[0] = EquitoMessageLibrary.addressToBytes64(peer1);
addresses[1] = EquitoMessageLibrary.addressToBytes64(peer2);
pingPong.setPeers(chainSelectors, addresses);
// Fund the sender
vm.deal(address(sender), 10 ether);
}
// Test cases go here...
}
Before writing test cases, you need to initialize the contracts that you want to test and their dependencies. In this case, you need to initialize the PingPong
contract and its dependencies such as the Router
, MockVerifier
, and MockEquitoFees
contracts. Then write before
function, which Hardhat will call before running each test case.
Here the Router will be deployed with chain selector 0, and the PingPong contract will have a simulated peers with chain selectors 1, meaning that this addresse do not match any deployed contract. A second Router and PingPong contract will be deployed at different chain selector 2.
// Previous code...
let owner: any;
let routerAddress: string;
let routerAddressAtChain2: string;
let equitoAddress: Bytes64Struct;
let peer1: Bytes64Struct;
let peer2: Bytes64Struct;
let pingPongAddress: Bytes64Struct;
let pingPongAtChain2: any;
let pingPongAddressAtChain2: Bytes64Struct;
let pingPong: any;
let router: any;
let routerAtChain2: any;
let defaultAbiCoder: AbiCoder;
let fees: any;
let verifiers: any;
const chain_selector_0 = 0;
const chain_selector_1 = 1;
const chain_selector_2 = 2;
before(async function () {
defaultAbiCoder = new ethers.AbiCoder();
[owner] = await ethers.getSigners();
equitoAddress = Bytes64Struct.fromEvmAddress(
"0xD42086961E21BC9895E649CE421b8328655D962D"
);
peer1 = Bytes64Struct.fromEvmAddress(
"0x7EdaC442D616D5D3cBe7F3d82D28569873541aCf"
);
peer2 = Bytes64Struct.fromEvmAddress(
"0x9C57A42B6B9289a5306671B28cBC8C5fBC95Dcc3"
);
const Verifiers = await ethers.getContractFactory("MockVerifier");
verifiers = await Verifiers.deploy();
const Fees = await ethers.getContractFactory("MockEquitoFees");
fees = await Fees.deploy();
const Router = await ethers.getContractFactory("Router");
router = await Router.deploy(
chain_selector_0,
verifiers,
fees,
equitoAddress
);
routerAtChain2 = await Router.deploy(
chain_selector_2,
verifiers,
fees,
equitoAddress
);
routerAddress = await router.getAddress();
routerAddressAtChain2 = await routerAtChain2.getAddress();
const PingPong = await ethers.getContractFactory("PingPong");
pingPong = await PingPong.deploy(routerAddress);
pingPongAddress = Bytes64Struct.fromEvmAddress(await pingPong.getAddress());
pingPongAtChain2 = await PingPong.deploy(routerAddressAtChain2);
pingPongAddressAtChain2 = Bytes64Struct.fromEvmAddress(
await pingPongAtChain2.getAddress()
);
await pingPong.setPeers(
[0, 1, 2],
[pingPongAddress, peer1, pingPongAddressAtChain2]
);
await pingPongAtChain2.setPeers(
[0, 1, 2],
[pingPongAddress, peer1, pingPongAddressAtChain2]
);
});
// Test cases go here...
Write test cases
Test sending a ping
The first test case is to send a ping message from the sender to a peer. We expect the PingSent
event to be emitted.
- Foundry
- Hardhat
Since the event emits the destinationChainSelector
and the messageHash
, we need to calculate the messageHash
based on the expected message. We should always remember to pay the right amount of fees to the router contract.
function testSendPing() public {
// Useful variables for the message construction
uint256 destinationChainSelector = 2;
string memory pingMessage = "Ping!";
bytes memory messageData = abi.encode("ping", pingMessage);
// Calculate the fee
uint256 fee = router.getFee(address(pingPong));
// We expect a PingSent event, containing the destination chain and message hash
vm.prank(sender);
vm.expectEmit(true, true, true, true);
emit PingSent(
destinationChainSelector,
keccak256(
abi.encode(
EquitoMessage({
blockNumber: 1,
sourceChainSelector: 0,
sender: EquitoMessageLibrary.addressToBytes64(
address(pingPong)
),
destinationChainSelector: destinationChainSelector,
receiver: EquitoMessageLibrary.addressToBytes64(peer2),
hashedData: keccak256(messageData)
})
)
)
);
// Send the ping message, paying the fee
pingPong.sendPing{value: fee}(destinationChainSelector, pingMessage);
}
Since the event emits the destinationChainSelector
and the messageHash
, we need to calculate the messageHash
based on the expected message. We should always remember to pay the right amount of fees to the router contract.
it("Should send a ping", async function () {
const ownChainSelector = chain_selector_0;
const peerChainSelector = chain_selector_1;
const pingMessage = "Ping!";
const messageData = defaultAbiCoder.encode(
["string", "string"],
["ping", pingMessage]
);
const blockNumber = await ethers.provider.getBlockNumber();
const message = [
blockNumber + 1, // blockNumber after the tx call
ownChainSelector, // sourceChainSelector
pingPongAddress, // sender
peerChainSelector, // destinationChainSelector
peer1, // receiver
ethers.keccak256(messageData), // hashedData
];
const fee = await router.getFee(owner);
const feesBalanceBefore = await ethers.provider.getBalance(
await fees.getAddress()
);
await expect(
pingPong.connect(owner).sendPing(peerChainSelector, pingMessage, {
value: fee,
})
)
.to.emit(pingPong, "PingSent")
.withArgs(peerChainSelector, generateHash(message));
const feesBalanceAfter = await ethers.provider.getBalance(
await fees.getAddress()
);
expect(feesBalanceAfter - feesBalanceBefore).to.equal(fee);
});
Test receiving a ping and sending a pong
The second test case is to receive a ping message from a peer and send a pong message back. We expect the emission of a PingReceived
and a MessageSendRequest
, the event that Equito uses to send messages.
- Foundry
- Hardhat
function testReceivePingAndSendPong() public {
// Forge a valid ping message
string memory pingMessage = "Equito";
bytes memory messageData = abi.encode("ping", pingMessage);
EquitoMessage memory message = EquitoMessage({
blockNumber: 1,
sourceChainSelector: 2,
sender: EquitoMessageLibrary.addressToBytes64(peer2),
destinationChainSelector: 0,
receiver: EquitoMessageLibrary.addressToBytes64(address(pingPong)),
hashedData: keccak256(messageData)
});
// Calculate the fee and the current balance of the fee contract
uint256 feeContractBalance = address(fees).balance;
uint256 fee = router.getFee(address(pingPong));
// We expect a PingReceived event and a MessageSendRequested event
vm.expectEmit(address(pingPong));
emit PingReceived(2, keccak256(abi.encode(message)));
vm.expectEmit(address(router));
emit MessageSendRequested(
EquitoMessage({
blockNumber: 1,
sourceChainSelector: 0,
sender: EquitoMessageLibrary.addressToBytes64(
address(pingPong)
),
destinationChainSelector: 2,
receiver: EquitoMessageLibrary.addressToBytes64(peer2),
hashedData: keccak256(abi.encode("pong", pingMessage))
}),
abi.encode("pong", pingMessage)
);
// Receive the message and send a pong back
// Make sure to pay the fee for the pong message
vm.prank(address(sender));
router.deliverAndExecuteMessage{value: fee}(
message,
messageData,
0,
abi.encode(1) // The MockVerifier returns true for non-empty proofs
);
// Ensure the fee was paid correctly
assertEq(address(fees).balance, feeContractBalance + fee);
}
it("Receive a ping and send a pong", async function () {
const pingMessage = "Ping from a peer at chain 1";
const pingMessageData = defaultAbiCoder.encode(
["string", "string"],
["ping", pingMessage]
);
const blockNumber = await ethers.provider.getBlockNumber();
const message1 = [
blockNumber + 1, // blockNumber after the tx call
chain_selector_1, // sourceChainSelector
peer1, // sender
chain_selector_0, // destinationChainSelector
pingPongAddress, // receiver
ethers.keccak256(pingMessageData), // hashedData
];
const fee = await router.getFee(owner);
const pongMessage = pingMessage; // same payload is used in Pong
const pongMessageData = defaultAbiCoder.encode(
["string", "string"],
["pong", pongMessage]
);
const message2 = [
blockNumber + 1, // blockNumber
chain_selector_0, // sourceChainSelector
pingPongAddress, // sender
chain_selector_1, // destinationChainSelector
peer1, // receiver
ethers.keccak256(pongMessageData), // hashedData
];
// Receive Ping then send a Pong
const dummyProof = ethers.randomBytes(8);
await expect(
router
.connect(owner)
.deliverAndExecuteMessage(message1, pingMessageData, 0, dummyProof, {
value: fee,
})
)
.to.emit(pingPong, "PingReceived")
.withArgs(chain_selector_1, generateHash(message1))
.to.emit(pingPong, "PongSent")
.withArgs(chain_selector_1, generateHash(message2));
});
Test receiving a pong
To finalize the correct path, we test receiving a pong message from a peer. We expect the emission of a PongReceived
event.
- Foundry
- Hardhat
function testReceivePong() public {
string memory pongMessage = "Pong!";
// Forge a valid pong message
bytes memory messageData = abi.encode("pong", pongMessage);
EquitoMessage memory message = EquitoMessage({
blockNumber: 1,
sourceChainSelector: 1,
sender: EquitoMessageLibrary.addressToBytes64(peer1),
destinationChainSelector: 2,
receiver: EquitoMessageLibrary.addressToBytes64(peer2),
hashedData: keccak256(messageData)
});
// Pretend to be the router
vm.prank(address(router));
// We expect a PongReceived event
vm.expectEmit(true, true, true, true);
emit PongReceived(1, keccak256(abi.encode(message)));
// Receive the pong message
pingPong.receiveMessage(message, messageData);
}
it("Should receive a ping", async function () {
const ownChainSelector = chain_selector_0;
const peerChainSelector = chain_selector_1;
const pingMessage = "Ping!";
const messageData = defaultAbiCoder.encode(
["string", "string"],
["ping", pingMessage]
);
const blockNumber = await ethers.provider.getBlockNumber();
const message = [
blockNumber + 1, // blockNumber
peerChainSelector, // sourceChainSelector
peer1, // sender
ownChainSelector, // destinationChainSelector
pingPongAddress, // receiver
ethers.keccak256(messageData), // hashedData
];
// owner is to call the router, get the fee for account owner
const fee = await router.getFee(owner);
// Dummy verifier checks the len of the proof only
const dummyProof = ethers.randomBytes(8);
await expect(
router
.connect(owner)
.deliverAndExecuteMessage(message, messageData, 0, dummyProof, {
value: fee,
})
)
.to.emit(pingPong, "PingReceived")
.withArgs(peerChainSelector, generateHash(message));
});
Test receiving an invalid message
We start testing the wrong paths by testing the reception of an invalid message, where we expect the emission of an InvalidMessageType
error.
- Foundry
- Hardhat
function testInvalidMessageType() public {
string memory invalidMessage = "Invalid";
// Forge a message with invalid type
// Supported types are "ping" and "pong"
bytes memory messageData = abi.encode("invalid", invalidMessage);
EquitoMessage memory message = EquitoMessage({
blockNumber: 1,
sourceChainSelector: 1,
sender: EquitoMessageLibrary.addressToBytes64(peer1),
destinationChainSelector: 2,
receiver: EquitoMessageLibrary.addressToBytes64(peer2),
hashedData: keccak256(messageData)
});
// Pretend to be the router
vm.prank(address(router));
// We expect an InvalidMessageType error
vm.expectRevert(PingPong.InvalidMessageType.selector);
// Receive the invalid message
pingPong.receiveMessage(message, messageData);
}
it("Should revert on invalid message type", async function () {
const invalidMessage = "Invalid";
const messageData = defaultAbiCoder.encode(
["string", "string"],
["invalid", invalidMessage]
);
const message = [
1, // blockNumber
1, // sourceChainSelector
peer1, // sender
0, // destinationChainSelector
pingPongAddress, // receiver
ethers.keccak256(messageData), // hashedData
];
// owner is to call the router, get the fee for account owner
const fee = await router.getFee(owner);
// Dummy verifier checks the len of the proof only
const dummyProof = ethers.randomBytes(8);
await expect(
router
.connect(owner)
.deliverAndExecuteMessage(message, messageData, 0, dummyProof, {
value: fee,
})
).to.be.revertedWithCustomError(pingPong, "InvalidMessageType");
});
Test receiving a message from an unauthorized sender
We can finally test that the behavior of the contract is correct when receiving a message from an invalid sender, i.e. when it is not a peer. We expect the emission of an InvalidMessageSender
error.
- Foundry
- Hardhat
function testInvalidSender() public {
string memory pongMessage = "message";
// Forge a valid message from an invalid sender
bytes memory messageData = abi.encode("pong", pongMessage);
EquitoMessage memory message = EquitoMessage({
blockNumber: 1,
sourceChainSelector: 1,
sender: EquitoMessageLibrary.addressToBytes64(address(0x00)),
destinationChainSelector: 0,
receiver: EquitoMessageLibrary.addressToBytes64(address(pingPong)),
hashedData: keccak256(messageData)
});
// Pretend to be the router
vm.prank(address(router));
vm.expectRevert(Errors.InvalidMessageSender.selector);
pingPong.receiveMessage(message, messageData);
}
it("Should revert on wrong peer", async function () {
const ownChainSelector = chain_selector_0;
const peerChainSelector = chain_selector_1;
const invalidMessage = "Invalid";
const messageData = defaultAbiCoder.encode(
["string", "string"],
["invalid", invalidMessage]
);
const message = [
1, // blockNumber
peerChainSelector, // sourceChainSelector
peer2, // sender: peer 2 instead of peer1
ownChainSelector, // destinationChainSelector
pingPongAddress, // receiver
ethers.keccak256(messageData), // hashedData
];
// owner is to call the router, get the fee for account owner
const fee = await router.getFee(owner);
// Dummy verifier checks the len of the proof only
const dummyProof = ethers.randomBytes(8);
await expect(
router
.connect(owner)
.deliverAndExecuteMessage(message, messageData, 0, dummyProof, {
value: fee,
})
).to.be.revertedWithCustomError(pingPong, "InvalidMessageSender");
});
Running the tests
- Foundry
- Hardhat
To run the test with Foundry, you can simply execute the following command:
forge test
To run the test with hardhat, you can simply execute the following command:
npm run hardhat:test
Related contracts
- Foundry
- Hardhat