Developing on the blockchain is an incredible experience. The ecosystem is open and permissionless; each project becoming a lego brick in whatever idea a developer has in mind. Because of the open nature of the blockchain, it's not uncommon to have your smart contracts interact with another project’s contracts. It may be a Chainlink Oracle, a Dex like Uniswap, or a Lending platform like the QiDAO, or maybe you interact with all three in a single contract?
But how do you test your contract based on responses and interactions with these external contracts?
There are two ways: you can deploy "mock contracts" or you can use a mocking library. There are tradeoffs, but for this post I am going to focus on using a Smock's mocking library to put external contracts into a place for testing.
Smock depends on Hardhat so you need to have a Hardhat project. For the sake of this post let's write and test a smart contract that can liquidate a loan on the QiDAO.
The QiDAO contracts can be found in their docs and the source can be found on their github.
Specifically we will be using the erc20Stablecoin contract deployed for LINK - 0x61167073E31b1DAd85a3E531211c7B8F1E5cAE72
Our simple liquidation contract looks like this.
contract LoanLiquidator {
address const vaultAddress = 0x61167073E31b1DAd85a3E531211c7B8F1E5cAE72
function liquidate(uint256 vaultId) external {
erc20Stablecoin vault = erc20Stablecoin(vaultAddress);
require(vault.checkLiquidation(vaultId), "Vault not below liquidation threshold");
vault.liquidateVault(vaultId);
}
}
For simplicity let's test the two cases of checkLiquidation
as liquidateVault
doesn't return anything. First we will test but there is a gotcha. We can get into that later!
describe("LoanLiquidator", () => {
describe("#liquidate", () => {
it("should revert if the vault cannot be liquidated")
it("call the vaults liquidateVault if the loan can be liquidated")
})
})
If we aren't using Smock then this is pretty difficult. I would either need to inject a contract address into the LoanLiquidator
and then have that address implement erc20Stablecoin
's interface. That's for another blog post.
In this post it's a lot simpler because we will use Smock, but there are limitations. First let's focus on it("should revert if the vault cannot be liquidated")
#...
it("should revert if the vault cannot be liquidated", async () => {
const VAULT_ADDRESS = "0x61167073E31b1DAd85a3E531211c7B8F1E5cAE72"
# I am using Typechain to generate types for the erc20Stablecoin ABI
const fake = await smock.fake<Erc20QiStablecoin>(
Erc20QiStablecoin.abi,
{address: VAULT_ADDRESS}
);
fake.checkLiquidation.returns(false);
const LoanLiquidatorFactory = await ethers.getContractFactory("LoanLiquidatator") as LoanLiquidator__factory;
const loanLiquidator = await LoanLiquidatatorFactory.deploy();
await loanLiquidator.deployed();
await expect(loanLiquidator.liquidate(1)).to
.be
.revertedWith("Vault not below liquidation threshold")
expect(fake.liquidateVault).not.to.have.been.called
})
#...
The magic here lies in the opts
for Smock's #fake()
method. You can pass an existing contract address to #fake()
and Smock will use Hardhat's [hardhat_setCode](https://hardhat.org/hardhat-network/reference/#hardhat-setcode)
rpc call to replace the contract at the address given with Smock's fake implementation of the contract.
Next lets test it("call the vaults liquidateVault if the loan can be liquidated")
.
it("call the vaults liquidateVault if the loan can be liquidated", async () => {
const VAULT_ADDRESS = "0x61167073E31b1DAd85a3E531211c7B8F1E5cAE72"
# I am using Typechain to generate types for the erc20Stablecoin ABI
const fake = await smock.fake<Erc20QiStablecoin>(
Erc20QiStablecoin.abi,
{address: VAULT_ADDRESS}
);
fake.checkLiquidation.returns(true);
const LoanLiquidatorFactory = await ethers.getContractFactory("LoanLiquidatator") as LoanLiquidator__factory;
const loanLiquidator = await LoanLiquidatorFactory.deploy();
await loanLiquidator.deployed();
await expect(loanLiquidator.liquidate(1)).not.to
.be
.reverted
expect(fake.liquidateVault).to.have.been.called
})
In this case you'll get green lights and you can keep on coding. In the real world there's a gotcha. When you fake a contract you fake all of it. By default, functions will now return their Zero value. If you have calls to later in your implementation that require non-zero values.
A clear example of this is if we add the method #getVaultAddress()
to our LoanLiquidator
contract:
function getVaultAddress() public view returns (address) {
return vaultAddress;
}
Now in test, after faking, if you call #getVaultAddress()
you will get the zero address 0x0000000000000000000000000000000000000000
If you had code that used the returned address you may see an error like:
Error: Transaction reverted: function call to a non-contract account
This just scratches the surface of what's possible with Smock and Solidity. The Web3 space is one of the most test driven development friendly and open ecosystems I have ever encountered.
If you're interested in TDD, writing great software, and developing cutting edge technology, don't hesitate to check out our careers page. Or if you’re looking for a partner to help build your next dApp, backend, or frontend and up-skill your team please reach out to us at work@withfocus.com.
Top comments (2)
I am curious how to mock a value like ftmscan.com/address/0x230917f8a262..., line 735, which holds a Mapping of a Mapping to a struct:
mapping (uint256 => mapping (address => UserInfo)) public userInfo;
Thanks and great question!
Mocking return values is the same, if it's a complex object like arrays or structs or a primitive type. The short answer is you can mock it like this
For
userInfo
it's a nested map. Nested maps in solidity create a getter functions that have N parameters where N is the number of maps nested. The final return from your getter isThe longer answer is Solidity creates getter functions for maps. Each of these functions has N parameters, where N is the number of maps in storage. If you take a look at the ABI for the
Farm
contractuserInfo
has two params.This represents a solidity interface
You can mock the
userInfo
function to return based on specific parameters or have a single general return value.Deeper info can be found in their docs smock.readthedocs.io/en/latest/fak...