Smock 2: The powerful mocking tool for Hardhat
Features of smock v2 and how to use them with examples
We’ve covered mocking contracts before as well as the first version of the new mocking tool Smock 2. It simplifies the mocking process greatly and also gives you more testing power. You’ll be able to change the return values for functions as well as changing internal contract storage directly!
With the new v2 you can have mocked contracts which work either as a regular contract or as a mocked contract depending on your configuration. Let's see how you use the new smock version.
How to set up ?
Requirements: Currently only Hardhat with Waffle is supported. You can follow the setup here. If you use Truffle, you can take a look at mock-contract or wait for future support.
The smock tool is available as plugin in Hardhat. You will need an existing Hardhat project. If you don't have one, run npm init
followed by npx hardhat
and create a sample project. Now you can install Smock v2 via Npm using:
$ npm install --save-dev @defi-wonderland/smock
And just have add the plugin inside hardhat.config.js
/ts
:
const config: HardhatUserConfig = {
solidity: {
version: "0.8.4",
settings: {
outputSelection: {
"*": {
"*": ["storageLayout"],
},
},
},
},
[...]
};
That's all, let's go ahead and use it.
What are Mocks?
The main functionality in Smock 2 evolves around the mocks. They are powerful copies of your smart contract with have the full functionality of Smock. Alternatively you can use Fakes, but we won't go into those here. Fakes have the same functions as Mocks, but with the difference that you can only call mocked functions on them, since they have no actual code behind them and that you can modify storage directly for mocks. In other words if you want to call real functions that behave like normal, use Mocks. Or if you want to modify a smart contract storage directly, use Mocks.
When would you ever use Fakes? Since Mocks include the functionality of Fakes, you could just use Mocks for everything. But if you have a very large test-suite with complex contracts and a long test run time, you may speed up the testing by using Fakes.
The functionality of changing internal storage is available. With Mocks you can directly change the contract storage:
- modify primitive types directly
- change data in mappings
- modify stored structs
Let's see an example
MyERC20.sol
Imagine we have an ERC-20 contract with a mintUpTo
function. This function will mint whatever amount is required to mint to get the passed address to the requested balance amount.
This function as you can see is onlyOwner
. Now imagine a complex system where the onlyOwner
is really just another contract who's allowed to call this function. Let's see an example second contract for this...
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "hardhat/console.sol";
contract MyERC20 is ERC20, Ownable {
constructor() ERC20("MyERC20", "MYE") {
this;
}
function mintUpTo(
address to, uint256 amount
) external onlyOwner returns (uint256) {
uint256 currentBalance = balanceOf(to);
if (currentBalance >= amount) return 0;
uint256 mintBalance = amount - currentBalance;
_mint(to, mintBalance);
return mintBalance;
}
}
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "hardhat/console.sol";
import "./MyERC20.sol";
contract MyOtherContract {
MyERC20 private myERC20;
constructor(MyERC20 myERC20_) {
myERC20 = myERC20_;
}
function myOtherFunction(
address to, uint256 amount
) external {
// do stuff
uint256 returnAmount = myERC20.mintUpTo(to, amount);
console.log("The returned amount was", returnAmount);
console.log("The passed amount was", amount);
// do more stuff
}
}
MyOtherContract.sol
In this contract we call the mintUpTo
function. Remember this will only work if the contract is actually the owner. This is the intended behavior we want in a mainnet setup. But wouldn't it be useful if we could avoid this just for our unit-tests?
Figuring out the system state in where MyOtherContract is actually the owner of myERC20 might require some complex initialization steps. Let's avoid this by mocking the mintUpTo
function.
Mocking mintUpTo return value
We can first create the smocked contract by creating a mocked contract factory:
const myMockedContractFactory = await smock
.mock<MyContract__factory>("MyContract");
When using this factory to create a mocked contract, it will create a regular copy of the contract with its own address and fully working functions.
You can call functions on the contract directly or mock the return value. If you want to mock the function's return value, you can use:
MyMockContract.mintUpTo
<.whenCalledWith(to, amount)}> (optional)
.returns(mockedMintAmount);
The whenCalledWith
is optional, if you leave it out any call to mintUpTo will be mocked.
We now call the myOtherFunction
from the MyOtherContract
twice:
- Once with the parameters which are expected to be mocked
- Once with parameters which won't be mocked
The output from the console.logs inside the contract will be:
The returned amount was 30
The passed amount was 100
The returned amount was 300
The passed amount was 300
You can see the return value on the first call was mocked to 30, but keep in mind that the function still tries to execute the code like normal. In our case the wrong owner was calling it, meaning the function was reverting and no state change was happening. But in cases where it's a valid transaction, the state will also change even if the return value is mocked!
And so we also have to make sure to call transferOwnership before the second myOtherFunction
call since otherwise this would revert and fail.
On top we can actually check and verify the calls to mintUpTo
. So in our unit test we can assert that the mintUpTo
does indeed happen with the expected call parameters available via:
MyMockContract.smocked.mintUpTo.atCall(0)
import { expect } from "chai";
import { smock } from "@defi-wonderland/smock";
import { BigNumber } from "@ethersproject/bignumber";
import {
MyERC20__factory,
MyOtherContract__factory,
} from "../typechain";
describe("MyOtherContract", function () {
it("Should mock the mintUpTo function", async function () {
const myOtherContractFactory = await smock
.mock<MyOtherContract__factory>("MyOtherContract");
const myERC20Factory = await smock
.mock<MyERC20__factory>("MyERC20");
const myERC20 = await myERC20Factory.deploy();
const myOtherContract = await myOtherContractFactory.deploy(
myERC20.address
);
const mockedMintAmount = 30;
const to = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const amount1 = 100;
const amount2 = 300;
myERC20.mintUpTo.whenCalledWith(to, amount1)
.returns(mockedMintAmount);
expect((await myERC20.totalSupply()).toString())
.to.be.equal("0");
await myOtherContract.myOtherFunction(to, amount1);
// would be 100 if we called transferOwnership before
expect((await myERC20.totalSupply()).toString())
.to.be.equal("0");
expect(myERC20.mintUpTo.atCall(0).getCallCount())
.to.be.equal(1);
expect(myERC20.mintUpTo.getCall(0).args[0]).to.be.equals(to);
expect(
(myERC20.mintUpTo.getCall(0).args[1] as BigNumber).toString()
).to.equal(amount1.toString());
await myERC20.transferOwnership(myOtherContract.address);
await myOtherContract.myOtherFunction(to, amount2);
expect((await myERC20.totalSupply()).toString())
.to.be.equal("300");
});
});
it("Should set internal storage for mocking", async function () {
const myERC20Factory = await smock
.mock<MyERC20__factory>("MyERC20");
const myERC20 = await myERC20Factory.deploy();
expect(await myERC20.name()).to.be.equal("MyERC20");
await myERC20.setVariable("_name", "Hello world!");
expect(await myERC20.name()).to.be.equal("Hello world!");
});
Changing internal storage of mocks
We can also modify the internal storage of a contract. Create a mocked contract factory like before and use it to deploy the contract.
The resulting contract can be modified directly. In our case we can for example change the ERC-20 balance of the contract:
await Contract.setVariable("_name", "NewName")
Solidity Developer