Solidity Smart Contract Interaction and Testing with Web3.Js and Mocha
In this article we will discuss how to connect, interact and deploy our smart contract written in Solidity from our JavaScript environment and using JavaScript libraries such as Truffle, web3.js to interact with our smart contract and lastly, we will test it with Mocha and deploy it to Rinkeby testnet using Infura node API.
Writing a clean and (cost-) efficient smart contract poses a challenge, however, interacting it with DApps using truffle framework and web3.js introduces another challenges. Since when we’re writing smart contract , will somehow sooner or later involves real money, we need to test each of the function in the smart contract.
Let’s start with the smart contract. We will just write a short smart contract with a getter function and a setter function so we know the different between “calling a function” and “invoking/sending a transaction”.
// SPDX-License-Identifier:GPL-3.0pragma solidity ^0.4.17;
contract Inbox {string public message;function Inbox(string initialMessage) public {
message = initialMessage;
}function setMessage(string memory newMessage) public {
message = newMessage;
}function getMessage() public view returns(string memory) {
return message;
}}
Please note that since we’re using Solidity version 0.4.17 (which is an outdated version, for the sake of compatibility), the constructor function is still using the function with the same exact name as the contract name which is Inbox.
Looking at the contract, we have one getter function message and getMessage() , (two to be exact, but perform the same operation), and one setter function setMessage() to update the value of state variable message.
A couple of notes here when writing the contract, this contract was written using Remix and somehow it threw errors when writing all the functions without saving the arguments in the memory. This should not be happen if we use the updated version ^0.8.0.
Project Boilerplate
The project boilerplate will consist of following files and folders :
contract
--->Inbox.sol
|
|
node_modules
|
|
test
--->Inbox.test.js
|
|
compile.js
deploy.js
package-lock.json
package.json
There are three folders; contracts, node_modules and test. We will discuss each one of them and the files inside each folders.
After running npm init, we need to define and install our dependencies for this project :
{
"name": "inbox",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "mocha"
},
"author": "",
"license": "ISC",
"dependencies": {
"ganache-cli": "^6.12.2",
"mocha": "^9.0.3",
"solc": "^0.4.17",
"truffle-hdwallet-provider": "0.0.3",
"web3": "^1.0.0-beta.26"
}
}
To install the dependencies we run the following command :
npm install --save ganache-cli mocha solc@0.4.17 truffle-hdwallet-provider@0.0.3 web3@1.0.0-beta.26
Note that for the sake of compatibility, in some dependencies, we need to define the package version.
For testing with mocha, we change the test script to mocha.
"test": "mocha"
Now we have our smart contract and node.js dependencies ready, we will try to compile it first by writing the compile.js file :
const path = require('path');
const fs = require('fs');
const solc = require('solc');//pointing to file location
const inboxPath = path.resolve(__dirname, 'contracts', 'Inbox.sol');//read the file content
const source = fs.readFileSync(inboxPath, 'utf8');//exporting solc compiler output for consumption by other file
module.exports = solc.compile(source, 1).contracts[':Inbox'];
We will walkthrough each of the line in code snippet above;
JavaScript can import content from another file using require statement, however, our smart contract is not a JavaScript file hence can not be read using require syntax. We will need to read the solidity file manually and to do so, we will need to import 2 built-in functions to read the content of the file which are; path and fs.
We’ll assign path to point to the contract directory location and pass the variable to fs module to read the file.
Then we’ll compile the contract using solc and make it available by exporting using module.exports.
After our attempt to compile it, we want to write a test to test out each getter/setter function in the contract.
We will now create a new file called Inbox.test.js which has following lines of code and walkthrough it ;
const assert = require('assert');
const ganache = require('ganache-cli');
const Web3 = require('web3');
const { interface, bytecode } = require('../compile');
We use assert module to perform testing and ganache to become our provider to communicate with our Ethereum network later on.
Notice that we pass the module web3 to Web3, by using uppercase. This is because Web3 is not a variable, instead it will serve as constructor function, from which web3 instances can be created from using lowercase.
To create a web3 provider, we add the following code
const web3 = new Web3(ganache.provider());
Then we will write the testing environment using Mocha
let accounts;
let inbox;beforeEach(async () => {
// get list of all accounts
accounts = await web3.eth.getAccounts();// this variable teaches web3 what methods an Inbox contract has and //access the ABI
inbox = await new web3.eth.Contract(JSON.parse(interface)) // tells web3 we want to deploy a copy of the contract with an //argument
.deploy({
data: bytecode,
arguments: ['Hi there!'],
})// tells web3 to send out a transaction that creates this contract //and point which account and how many gas responsible in contract //deployment
.send({ from: accounts[0], gas: '1000000' });
});describe('Inbox', () => {
it('deploys a contract', () => {
assert.ok(inbox.options.address);
}); it('has a default message', async () => { const message = await inbox.methods.message().call();
assert.equal(message, 'Hi there!');
}); it('updates the message', async () => { await inbox.methods.setMessage('bye').send({ from:accounts[0] });
const message = await inbox.methods.message().call();
assert.equal(message, 'bye');
});});
As we know, using Mocha for testing, will involve 3 components :
- beforeEach()
- describe()
- it()
I will not deep dive into each one of the components and will have a short tutorial on how to use Mocha, but in short, beforeEach() is used as some sort of constructor function where it will run commands which invoked in every test and to make our code more cleaner and to have a DRY code, we will put the command in this component.
describe() is basically a group of commands to do testing. it() will run each command to test, in this case we will use assert.ok and assert.equal.
First we will declare 2 global variables to be use in Mocha, accounts and inbox. Then we will use web3.eth.getAccounts() module to get the accounts address and web3.eth.Contract(JSON.parse(interface)) to parse the JSON ABI after we compile the contract.
Remember that every time we compile a Solidity smart contract, the compiler will spit out two files :
- ABI in form of JSON
- Bytecode
Since we will actually going to deploy this contract in order to test it, and every command in web3 is async by nature (starting from web3 1.x), we will chain inbox function variable with .deploy and .send. We will deploy the bytecode to EVM provided by Ganache and sending Ether for the gas fee. We have to put also the initial message [‘Hi There’] because the smart contract requires us to pass the initial message when deploying the contract.
There are 2 assert method in the testing script :
- assert.ok, to test the contract has been deployed and spit out the smart contract address
- assert.equal, to check the both the initial message in the getter function and to check the setter function really doing its job by updating the message.
Notice there 2 type of methods in both inbox.methods.message().call() and inbox.methods.setMessage(‘bye’).send({ from:accounts[0] }). This part is, in my opinion is the most important thing in this post, that .call() will actually only calling a value of a function and does not modify the blockchain, hence costs no gas and .send() will actually modify the blockchain by updating the value hence will cost gas for this operation.
To run the test, invoke this
npm run test
Time to Deploy
Now we will write our deploy.js file
const HDWalletProvider = require('truffle-hdwallet-provider');
const Web3 = require('web3');
const { interface, bytecode} = require('./compile');const provider = new HDWalletProvider(
'[put your 12 mnemonic phrases here]',
'https://rinkeby.infura.io/v3/087d0eb82bcf4c8eb2dcb9dfde3xxxxx');const web3 = new Web3(provider);// we have to create a function in order to use async await syntax
// all functions are the same as in the testing script
const deploy = async () => {const accounts = await web3.eth.getAccounts();console.log('Attempting to deploy from account', accounts[0]);const result = await new web3.eth.Contract(JSON.parse(interface))
.deploy({
data:bytecode,
arguments:['Hi There!'],
}).send({
from:accounts[0],
gas:'30000000',
});console.log('Contract deployed to ', result.options.address);
};
We will use Infura node to connect to Rinkeby Test Network .The code itself is pretty much straight forward and basically almost the same as our testing script without the assert methods but with added console.log so we know what happen in the background. However we import truffle-hdwallet-provider NPM package as our provider so that we can use our own address derived from 12 mnemonic phrases and sign the transaction with it.
Basically we’re done writing the code and we can deploy by typing
node deploy.js
As we can see from the code above, we will spend most of the time to test the function in the contract, and this will be our the most important thing since the transaction on the Mainnet will involve real money and we want to make sure our contract and application running properly in Testnet before deploying in to Mainnet.