Testing a solidity house-swap contract using HardHat and Typescript

Nacho Colomina Torregrosa - Oct 2 '23 - - Dev Community

In the previous post of this series, we saw how to build an smart-contract using solidity which managed a house-swap between two users.

In this post, we are going to learn how to test the contract using hardhat and typescript.

Preparing hardhat

Before installing hardhat you have to install node in your computer. You can see how to install node in this link. It is a simple process.
Once you have node installed, you are ready to install hardhat. Let's do it using the next steps:

Initialize a node project

To initialize a node project, create a folder on which you want to hold your project, move into the new folder and then start a node project.



mkdir <myprojectfolder>
cd <myprojectfolder>
npm init


Enter fullscreen mode Exit fullscreen mode

Install hardhat

To install hardhat, keep on your project folder and run:



npm install --save-dev hardhat
npx hardhat init


Enter fullscreen mode Exit fullscreen mode

The first command will install hardhat as a dev dependency in your project and the next one will start a hardhat project.

Choose Create an empty hardhat.config.js after executing the hardhat init command.

Now you have initialized a hardhat project, move the solidity contract (the file with a .sol extension) to the contracts directory. If that directory does not exist, create it.

Install Typescript

To install and use typescript, you will have to install the @nomicfoundation/hardhat-toolbox plugin provided by hardhat. This plugin contains all the common features to work with hardhat. You can learn more about it here.

After installing this plugin, you are ready to move to typescript. First of all, find the file hardhat.config.js on your project root folder and change its name to hardhat.config.ts.



mv hardhat.config.js hardhat.config.ts


Enter fullscreen mode Exit fullscreen mode

After doing it, you will have to change the contents to adapt them to typescript. You can copy the contents from the github project



import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import "@typechain/hardhat"

const config: HardhatUserConfig = {
  solidity: "0.8.19",
};

export default config;


Enter fullscreen mode Exit fullscreen mode

As you can see in the code above, we import hardhat-toolbox and also typechain/hardhat, but, what is typechain ?. Typechain is a library that, basically, generate typescript types for your contracts and its functions. This is really useful since you do not need to remember the contract function names. They will be listed by your ide as if they were methods of a class.
In the next sections, we will see how to generate this types ans use them.

Finally, you only have to create a file named tsconfig.json in your project root folder and paste the following content:



{
    "compilerOptions": {
      "target": "es2020",
      "module": "commonjs",
      "esModuleInterop": true,
      "forceConsistentCasingInFileNames": true,
      "strict": true,
      "skipLibCheck": true,
      "resolveJsonModule": true
    }
}


Enter fullscreen mode Exit fullscreen mode

Write the tests

The tests should be located under test directory so, if it is no created, create the folder under your project root folder. Then, move into this new folder and create a file named HouseSwap.ts.
This file will hold our contract tests. In this post we are going to show and explain each test separately. Check the github project to see the complete file.

Imports and Common variables for all tests



import { expect } from "chai";
import { ethers } from "hardhat";
import { HouseSwap } from "../typechain-types";
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";


Enter fullscreen mode Exit fullscreen mode


let houseSwapContract: HouseSwap;
let owner: SignerWithAddress;
let addr1: SignerWithAddress;
let addr2: SignerWithAddress;
let targetAddress: string;
let targetAddress2: string;
let house: HouseSwap.HouseStruct;
let houseToSwap: HouseSwap.HouseStruct;
let amountPayOriginToTarget: number;
let amountPayTargetToOrigin: number;

beforeEach(async function() {
  [owner, addr1, addr2] = await ethers.getSigners();
  targetAddress   = await addr1.getAddress();
  targetAddress2  = await addr2.getAddress();
  house = {houseType: "semi-detached house", value: 16698645, link: "https://example.com", propietary: targetAddress};
  houseToSwap = {houseType: "duplex", value: 16698645, link: "https://example2.com", propietary: targetAddress}
  amountPayOriginToTarget = 0;
  amountPayTargetToOrigin = 0;

  houseSwapContract = await ethers.deployContract("HouseSwap") as HouseSwap;
  });


Enter fullscreen mode Exit fullscreen mode

Let's start with the imports. As we can see, the first one imports chai. Chai is a javascript assertion library (which is included with the hardhat installation).

As a test runner, hardhat uses mocha which is also included with the hardhat installation. Mocha is internally called by hardhat when you run the test suite (we will see it later).

The second one imports ethers. Ethers is a library for interacting with the ethereum blockchain and its escosystem. In this post, we will use ethers to get the test addresses and deploy the contract.

The third one is really important. Here is where typechain comes into play. This line imports HouseSwap from typechain-types directory. HouseSwap type contains all contract types and functions mapped to typescript types. So, how we can generate such folder?. Let's do a break here and show it.
As we have imported typechain in our hardhat.config.ts file, this folder will be automatically generated after compiling the contract.



npx hardhat compile


Enter fullscreen mode Exit fullscreen mode

If you import typechain after compiling the contract, you will have to clean before compiling again to generate the typechain folder.



npx hardhat clean
npx hardhat compile


Enter fullscreen mode Exit fullscreen mode

Going back to the imports, the last one imports the SignerWithAddress class. We will assing this type to the vars that we will use as addresses since the addresses are retrieved using ethers getSigners method and the addresses returned by this method are SignerWithAddress instances.

Now, let's move to the next piece of code. There we can see the beforeEach function. This function is executed before the execution of each test. In this case, this function creates a set of variables which will be available for all the tests, and deploys the contract so we can invoke its functions. Let's explain those variables one by one:

  • [owner, addr1, addr2]: These are the addresses which we are going to use to communicate with the contract. The owner address will be the one which will deploy the contract.
  • targetAddress and targetAddress2: These variables holds the addr1 and addr2 raw addresses. When we send an address as a function parameter to solidity, we have to send it as a string.
  • house and houseToSwap: These variable are HouseSwap.HouseStruct type variable. This type contains the house information which our contract expects as a parameter in some of its functions.
  • amountPayOriginToTarget: This is a number variable which specifies if the origin has to pay some amount to the target.
  • amountPayTargetToOrigin: This is a number variable which specifies if the target has to pay some amount to the origin.

After declaring and initialize the variables, the beforeEach function deploys the contract using ethers. After deployed, we get a HouseSwap type (thanks to typechain) which holds all the mapped contract functions.

Expects initial status to be PENDING



it("Expects initial status to be P", async function () {
    expect(await houseSwapContract.getStatus()).to.equal(0);
});


Enter fullscreen mode Exit fullscreen mode

This test is pretty straightforward. It simply ensures that contract status is PENDING just after the contract is deployed. If we go back to the contract code, we could see that the contract sets such status in the constructor (which is executed when the contract is deployed).

Should reject adding a new offer since contract has not initialized



await expect(houseSwapContract.connect(addr1).addOffer(house, amountPayOriginToTarget, amountPayTargetToOrigin)).to.be.revertedWith('An offer has been already accepted or contract has not been initialized');


Enter fullscreen mode Exit fullscreen mode

In this case, we ensure that the contract funcion reverts since the contract has not been initialized and we cannot send an offer yet.

Let's look at the use of the connect method. It allows us to change the contract caller.

Should emit a NewOffer event after sending an offer



await houseSwapContract.initialize(house);
expect(await houseSwapContract.getStatus()).to.equal(1);
await expect(houseSwapContract.connect(addr1).addOffer(house, amountPayOriginToTarget, amountPayTargetToOrigin)).to.emit(houseSwapContract, "NewOffer");


Enter fullscreen mode Exit fullscreen mode

In this test, we check two cases.

  • We ensure that the contract status holds the INITIALIZED value after calling the initialize function.
  • Then, we ensure that a NewOffer event is emitted after sending an offer using addOffer method.

Should not accept the offer since only the owner can accept an offer



await houseSwapContract.initialize(house);
await expect(houseSwapContract.connect(addr1).addOffer(houseToSwap, amountPayOriginToTarget, amountPayTargetToOrigin)).to.emit(houseSwapContract, "NewOffer");
await expect(houseSwapContract.connect(addr2).acceptOffer(targetAddress, house, amountPayOriginToTarget, amountPayTargetToOrigin)).to.be.revertedWith('Required contract owner');


Enter fullscreen mode Exit fullscreen mode

In this test, we check that an offer cannot be accepted since the address which is calling acceptOffer is not the contract owner.

Should accept an offer and reject others



await houseSwapContract.initialize(house);
await expect(houseSwapContract.connect(addr1).addOffer(houseToSwap, amountPayOriginToTarget, amountPayTargetToOrigin)).to.emit(houseSwapContract, "NewOffer");
houseSwapContract.acceptOffer(targetAddress, houseToSwap, amountPayOriginToTarget, amountPayTargetToOrigin);
expect(await houseSwapContract.getStatus()).to.equal(2);
await expect(houseSwapContract.connect(addr1).addOffer(houseToSwap, amountPayOriginToTarget, amountPayTargetToOrigin)).to.be.revertedWith('An offer has been already accepted or contract has not been initialized');


Enter fullscreen mode Exit fullscreen mode

In this case, we also test two cases:

  • We initialize the contract, send an offer and accept it. The we ensure that the contract status holds the value ACCEPTED.
  • Then, we ensure that if we try to send another offer, the function reverts since an offer has already accepted.

Should not perform swap because sender is not who sent the offer



await houseSwapContract.initialize(house);
await houseSwapContract.connect(addr1).addOffer(houseToSwap, amountPayOriginToTarget, amountPayTargetToOrigin);
await houseSwapContract.acceptOffer(targetAddress, houseToSwap, amountPayOriginToTarget, amountPayTargetToOrigin);
await expect(houseSwapContract.connect(addr2).performSwap()).to.be.revertedWith('Only target user can confirm swap');


Enter fullscreen mode Exit fullscreen mode

In this case, we add an offer and the owner accepts it. Then, when the performSwap function is called, as the sender is not the same that sent the accepted offer, the function reverts.

Should perform swap without extra transfers



await houseSwapContract.initialize(house);
await houseSwapContract.connect(addr1).addOffer(houseToSwap, amountPayOriginToTarget, amountPayTargetToOrigin);
await houseSwapContract.acceptOffer(targetAddress, houseToSwap, amountPayOriginToTarget, amountPayTargetToOrigin);
await houseSwapContract.connect(addr1).performSwap();

expect(await houseSwapContract.getStatus()).to.equal(3);


Enter fullscreen mode Exit fullscreen mode

In this test, we ensures that contract change the status to FINISHED value after performing swap. As amountPayOriginToTarget and amountPayTargetToOrigin holds a 0 value, no transfers need to be sent.

Deposit should fail since owner must send funds



await houseSwapContract.initialize(house);
await houseSwapContract.connect(addr1).addOffer(houseToSwap, 1, amountPayTargetToOrigin);
await houseSwapContract.acceptOffer(targetAddress, houseToSwap, 1, amountPayTargetToOrigin);
await expect(houseSwapContract.connect(addr1).deposit({ value: ethers.utils.parseEther('1') })).to.be.revertedWith("Origin must deposit enougth funds");


Enter fullscreen mode Exit fullscreen mode

Now, we test deposit function. In this case, as amountPayOriginToTarget is greater that 0, the origin must deposit enought ETH so the contract can send the transfer. The test will revert since it is not the owner who tries to deposit.

Deposit should fail since target must send funds



await houseSwapContract.initialize(house);
await houseSwapContract.connect(addr1).addOffer(houseToSwap, amountPayOriginToTarget, 1);
await houseSwapContract.acceptOffer(targetAddress, houseToSwap, amountPayOriginToTarget, 1);
await expect(houseSwapContract.deposit({ value: ethers.utils.parseEther('1') })).to.be.revertedWith("Target must deposit enougth funds");


Enter fullscreen mode Exit fullscreen mode

This test the same case as the last one, but in this case it reverts because the target should be who sends the deposit.

Owner deposit should work



await houseSwapContract.initialize(house);
await houseSwapContract.connect(addr1).addOffer(houseToSwap, 1, amountPayTargetToOrigin);
await houseSwapContract.acceptOffer(targetAddress, houseToSwap, 1, amountPayTargetToOrigin);
await expect(houseSwapContract.deposit({ value: ethers.utils.parseEther('1') })).to.emit(houseSwapContract, "BalanceUpdated");


Enter fullscreen mode Exit fullscreen mode

In this case, as the deposit is sent by the right address, it is done successfully and the function emits a BalanceUpdated event.

Swap should fail because funds deposited are not enought



await houseSwapContract.initialize(house);
await houseSwapContract.connect(addr1).addOffer(houseToSwap, 1, amountPayTargetToOrigin);
await houseSwapContract.acceptOffer(targetAddress, houseToSwap, 1, amountPayTargetToOrigin);
await houseSwapContract.deposit({ value: ethers.utils.parseEther('0.8') });

await expect(houseSwapContract.connect(addr1).performSwap()).to.be.revertedWith("Deposit has not been sent or is lower than required")


Enter fullscreen mode Exit fullscreen mode

In this test, we succcessfully deposit 0.8 ETH but, as origin must sent 1 ETH to the target, the swap process reverts as there are not enought funds.

Swap transferring funds from origin to target should be done



await houseSwapContract.initialize(house);
await houseSwapContract.connect(addr1).addOffer(houseToSwap, 1, amountPayTargetToOrigin);
await houseSwapContract.acceptOffer(targetAddress, houseToSwap, 1, amountPayTargetToOrigin);
await houseSwapContract.deposit({ value: ethers.utils.parseEther('1.05') });

await houseSwapContract.connect(addr1).performSwap();
expect(await houseSwapContract.getStatus()).to.equal(3);


Enter fullscreen mode Exit fullscreen mode

Finally, as we deposit 1.05 ETH and origin must sent to target 1 ETH, the swap process is performed and the contract change its status to FINISHED.

Executing the tests

To execute the tests, we have to compile the contract first. We've shown how to do it at the begining of the article. Let's remember again here:



npx hardhat compile


Enter fullscreen mode Exit fullscreen mode

Then, after the contract is compiled without errors, we have to execute the following command to run the tests. Here, is when hardhat relies on mocha to run them (this is transparent for us).



npx hardhat test


Enter fullscreen mode Exit fullscreen mode

After executing the tests, you should see the following output:

Image description

Conclusion

This post, as a continuation of the previous one, has shown us how to test a contract using hardhat. Those tests tries to ensure the functions works as expected but also tries to test that the contract reverts when it has to.
After explaining the tests, we have seen how to use hardhat shell to compile and run the tests.
In the next post, we will learn how to deploy the contract to a test network and how to call the contract functions.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player