Testing your Chainlink VRF powered Smart Contract
This article will be short (hopefully) and try to answer one of the burning questions of developing a smart contract which uses Chainlink Verifiable Random Function – “How do I test my smart contract which uses Chainlink VRF?”. To be frank, it is not that difficult.
And while you can find tons of articles on how to use hardhat, deploy on xyz chain and even on using Chainlink’s products (though Chainlink’s own docs are second to none), this is a question that has been seldom explored.
The answer actually lies in the question – “How do I emulate a Chainlink VRF?”. You do that via another contract (popular terminology being mock contract) which should contain the functions that are invoked by your contract when trying to use Chainlink’s VRF. Specifically speaking, requestRandomWords
and fulfillRandomWords
that you find on Chainlink’s VRF oracle contract on any particular chain. Read that bit again. We are not talking about functions of similar name present in your own contract but on the VRF oracle contract.
Lucky for us, at the time of writing, Chainlink provides such mock contracts. Under the current package version of @chainlink/contracts, the mocks provided are MockAggregator.sol
, MockAggregatorValidator.sol
, VRFCoordinatorMock.sol
and VRFCoordinatorV2Mock.sol
. The first two are for Chainlink Price Feeds Oracle, the third one is for Chainlink VRF version 1. We are more interested in the last and the latest one - VRFCoordinatorV2Mock.sol
.
Before We Begin
This article will be a follow-up on the previous article in the series but can also be read independently. It assumes basic knowledge of solidity smart contract development and familiarity with Chai testing framework. The code has been open-sourced at the GitHub Repo below:
It’s a Hardhat Project. You do not need to have familiarity with Hardhat as Chai works with Truffle as well and we will be focusing on only one hardhat command – npx hardhat test
.
Also, I know that there are paradigms of unit testing, staging tests and so on but I won’t be faithful to those in this article since it focuses on just showing how a Chainlink VRF enabled smart contract can be tested with Mock Contracts.
Inside the contracts
folder, in the above repo you will find a test
folder which contains our Mock contract. It is a simple 2-line code which imports the VRFCoordinatorV2Mock.sol
inside the contract. This a general pattern of deploying a contract which may exist inside node_modules
folder.
Writing the Tests
Head over to the test folder and you will notice the sample-test.js which contains our tests. In total we will discuss 4 tests:
- One for testing that our contract requests random numbers successfully.
- One to show that the Mock Coordinator receives the request from our contract.
- Once for testing that the Mock Coordinator sends back the random numbers.
- And One test to bind them all.
Sorry about that last bit… my inner Ringer came out on that last test. The last test will check if our smart contract receives the random numbers successfully from Mock Coordinator. You may have a different logic with what you do with those random numbers but in our smart contract (discussed in the previous article in this series) we mint an NFT (bleh… boring).
const { expect } = require("chai");
const { BigNumber } = require("ethers");
const { ethers } = require("hardhat");
describe("OurNFTContract", function () {
let owner;
let hardhatOurNFTContract, hardhatVrfCoordinatorV2Mock;
beforeEach(async () => {
[owner] = await ethers.getSigners();
let ourNFTContract = await ethers.getContractFactory("OurNFTContract");
let vrfCoordinatorV2Mock = await ethers.getContractFactory("VRFCoordinatorV2Mock");
hardhatVrfCoordinatorV2Mock = await vrfCoordinatorV2Mock.deploy(0, 0);
await hardhatVrfCoordinatorV2Mock.createSubscription();
await hardhatVrfCoordinatorV2Mock.fundSubscription(1, ethers.utils.parseEther("7"))
hardhatOurNFTContract = await ourNFTContract.deploy(1, hardhatVrfCoordinatorV2Mock.address);
})
/*
*
Our Tests
*
*/
});
The above is some housekeeping before we start with the tests. describe is used to (and you guessed it!) describe the purpose of the collection of tests. A general convention is to group your tests around your smart contract. So, the name OurNFTContract
in describe means the following tests are for that contract. The beforeEach
hook is run before every test is executed. Inside this hook we are initializing the owner address (this will make all the invocations in our tests). We deploy VRFCoordinatorV2Mock.sol
i.e., the Mock VRF contract.
After this, we create a subscription in our Mock VRF contract and fund it. It doesn’t really matter here since this is all done locally but it goes the length to emulate an actual Chainlink Verifiable Random Function Oracle Contract.
Lastly, we deploy NFT contract by passing in the subscription ID and the address of the Mock VRF contract. It may be noted here this since the whole contract deployment process is done for the first time for Mock VRF, the subscription ID generated will always be 1.
Keep in mind that the code blocks discussed below for tests will be placed in that little section in the code above which says "Our Test".
Testing if our smart contract places the request correctly
Our NFT contract has the flow where invoking the safeMint()
function will initiate a request to Chainlink VRF. The function concludes with emitting RequestedRandomness
event. If the call has succeeded, then we should get that event from our contract when we invoke the safeMint()
function.
The event contains the following arguments:
- Subscription ID
- Invoker Address
- Name of the Character to be minted.
it("Contract should request Random numbers successfully", async () => {
await expect(hardhatOurNFTContract.safeMint("Halley")).to.emit(
hardhatOurNFTContract,
"RequestedRandomness"
).withArgs( BigNumber.from(1), owner.address, "Halley");
});
So, in the above test, we check for that precisely. If you are new to smart contract testing, know that every contract invocation made will be made from the owner’s address by default. If you want to use another address while invoking a function on that smart contract, then it can be done by <smart contract name>.connect(<address>).<function to invoke>
every single time.
Mock Coordinator should receive the request
When the Chainlink VRF Oracle Contract receives a successful request from any contract to send back random numbers, it emits a RandomWordsRequested event. Among other arguments, the event contains the request ID and the address of the contract which sent the request.
Note, here I use the words “successful request” because on mainnet/testnet, any contract can place request to Chainlink VRF contract on that chain. But the request won't be successful if there isn't a valid and funded subscription.
In case of our Mock VRF contract, on invoking safeMint()
function from our NFT smart contract, it emits the RandomWordsRequested
event then we can be sure that the first phase of invocation has been successful and the test has achieved its purpose.
it("Coordinator should successfully receive the request", async function () {
await expect(hardhatOurNFTContract.safeMint("Halley")).to.emit(
hardhatVrfCoordinatorV2Mock,
"RandomWordsRequested"
);
});
So, in the test above, we are invoking the safeMint()
function. But unlike in the previous test where we passed in our NFT contract and event name to .emit
clause, we are passing in the Mock VRF contract and the event we expect the Mock VRF to emit when our NFT contract requests random words on safeMint()
invocation.
Mock Coordinator should process and send back random numbers
In the next test, the second phase of Chainlink VRF invocation completes. Here, the fulfillRandomWords()
function on the VRF oracle contract is invoked. Normally this is done automatically. But in this case, it needs to be invoked manually since there is no agent (like Chainlink Keepers) to automate that action.
The fulfillRandomWords()
function in the VRF Coordinator contract takes in the request ID and the address of the consumer contract (the contract requesting the random numbers). It then invokes the rawFulfillRandomWords()
function on the consumer contract. This function on the consumer contract then invokes the fulfillRandomWords() function on the consumer contract itself and passes in the request ID and the random numbers returned by the VRF Coordinator Oracle Contract. On the completion of this sequence of events, the VRF Coordinator Contract emits a RandomWordsFulfilled
event.
We don’t see this happening generally. Any contract which wants to use Chainlink VRF v2 needs to inherit from VRFConsumerBaseV2.sol
like we have in our NFT Contract. The rawFulfillRandomWords()
function is inherited automatically and is, thus, present in the consumer contract.
The code below contains the test which invokes the fulfillRandomWords()
function of the Mock VRF contract by passing in the request ID and the address of our NFT contract. The emission of RandomWordsFulfilled
event would serve as confirmation of successful invocation of the function and we check for this.
In the above code there is an extra bit of code which is used to obtain the request ID from our NFT Contract. Remember how our NFT contract emits RequestedRandomness
event that contains our request ID? We need that.
it("Coordinator should fulfill Random Number request", async () => {
let tx = await hardhatOurNFTContract.safeMint("Halley");
let { events } = await tx.wait();
let [reqId, invoker] = events.filter( x => x.event === 'RequestedRandomness')[0].args;
await expect(
hardhatVrfCoordinatorV2Mock.fulfillRandomWords(reqId, hardhatOurNFTContract.address)
).to.emit(hardhatVrfCoordinatorV2Mock, "RandomWordsFulfilled")
});
The lines above the test is something general to ethers.js
library and to Ethereum transaction logs. When a transaction is successful, the transaction receipt contains an array of events emitted from that transaction. One can filter through these events by the event name. We filter out the RequestedRandomness
event and then from that event we extract the request ID which is then passed on.
Our Contracts should receive Random numbers from Mock VRF
Lastly, we need to bring things full circle. We started by making a request from our NFT contract to the Mock VRF Contract. The Mock VRF Contract received our request and recorded it. It then sent out the random numbers we asked from. Now we need to confirm that we are correctly receiving it in our contract.
We once again go through a similar process in the code of the test below as the test before. But this time, when we invoke the fulfillRandomWords()
function on the Mock VRF contract, we check if our NFT contract’s fulfillment function is invoked from the Mock VRF.
it("Contract should receive Random Numbers", async () => {
let tx = await hardhatOurNFTContract.safeMint("Halley");
let { events } = await tx.wait();
let [reqId] = events.filter( x => x.event === 'RequestedRandomness')[0].args;
await expect(
hardhatVrfCoordinatorV2Mock.fulfillRandomWords(reqId, hardhatOurNFTContract.address)
).to.emit(hardhatOurNFTContract, "ReceivedRandomness")
expect(await hardhatOurNFTContract.getCharacter(0))
.to.include(owner.address.toString(), "Halley");
});
In our NFT contract, the fulfillRandomWords()
function receives the request ID and an array of random numbers. This is a general pattern and is enforced by design. This function on our contract emits ReceivedRandomness
event on successful minting of the NFT inside this function. We check for that event when we pass in our NFT contract and the ReceivedRandomness
event to the .emit
clause above.
A surer test would be to see if the NFT exists or not after this whole procedure concludes. In the next expect
test we do exactly that. Since this is the first NFT, its token ID will be 0. So if we invoke the getCharacter()
function in our NFT contract passing in 0 as token ID, we should expect to get back an NFT which is owned by the owner address and is named Halley. The function returns an array in our case and we check if the array includes these using the .include
clause.
This completes the whole saga of testing our Chainlink Verifiable Random Function enabled smart contract.
So long folks!
This is the last article in our Chainlink VRF series. We started by discussing Chainlink VRF version 2 and then went on to write and deploy a boring NFT smart contract on Polygon’s Mumbai testnet which used this functionality. To conclude we tested out our smart contract (even though Testing should come before deployment LOL).
Before I end this one, I would like to recommend that you check out the contracts VRFCoordinatorV2Interface.sol
and
VRFCoordinatorV2Mock.sol
. You can use VRFCoordinatorV2Interface.sol
to implement your own custom Mock Contract or even your own VRF. VRFCoordinatorV2Mock.sol
is the Mock contract we used in this tutorial. At the time of writing this is under heavy development and some functions are yet to be completely implemented. But it can still be used to test out functionalities like we did here.
If you have any doubts or discussions, feel free to post them below this article. If you have any article suggestions feel free to get in contact with me via my email or social handles mentioned on my dev.to profile.
Until then, keep developing awesome things on Web3 and WAGMI!