Hardhatのテストコードを書いてみる
この記事では、Hardhatでテストコードを書いてコントラクトの実行が正しく動作するか確認するための手順を紹介していきます。
テストコードが必要な理由
テストコードを書くメリットは複数あります。
1. バグの早期発見
スマートコントラクトは一度デプロイされると、コードの修正が難しいため、バグをデプロイ前に発見して修正することが重要です。テストコードを実行することで、バグや誤ったロジックを事前に見つけることができます。
2. セキュリティの確認
スマートコントラクトは、特に資金や資産に関わる場合、ハッキングや不正利用のリスクがあります。脆弱性を避けるために、予期しない動作や攻撃のシナリオをテストし、セキュリティ上の問題がないか確認します。
3. ロジックの検証
スマートコントラクトが期待通りに動作しているかどうかを確認するために、テストを通じてロジックを検証します。
4. ガス使用量の最適化
スマートコントラクトは、実行時にガスが消費されます。テストを通じて、不要にガスを消費していないか、最適化の必要があるかどうかを確認できます。
5.デプロイ後の問題を防ぐ
テストを徹底することで、デプロイ後にスマートコントラクトが意図した通りに動作しない問題を防ぐことができます。テストは開発環境での動作確認のために重要であり、コストや手間を節約する効果もあります。
テストコードを書いてみる
ここからはSolidityでコントラクトを開発し、テストコードを実行してコントラクトが正しく動作することを確認していきます。
例題としてクイズアプリを作成したいため、クイズの追加や回答ができるコントラクトを作成します。
コントラクトの仕様は以下です。
オーナーはクイズの問題と回答する選択肢を追加できる。
ユーザーは質問に回答し、正解するとポイントがもらえる。
ユーザーが全問正解したか確認できる。
クイズ結果をブロックチェーンに書き込める。
はじめにHardhatの環境構築をしていきます。
プロジェクトディレクトリを作成します。
mkdir quiz-contract
cd quiz-contract
Hardhatをインストールします。
npm install --save-dev hardhat
Hardhatプロジェクトを初期化します。
JavaScriptのテンプレートを選択します。
npx hardhat init
888 888 888 888 888
888 888 888 888 888
888 888 888 888 888
8888888888 8888b. 888d888 .d88888 88888b. 8888b. 888888
888 888 "88b 888P" d88" 888 888 "88b "88b 888
888 888 .d888888 888 888 888 888 888 .d888888 888
888 888 888 888 888 Y88b 888 888 888 888 888 Y88b.
888 888 "Y888888 888 "Y88888 888 888 "Y888888 "Y888
👷 Welcome to Hardhat v2.22.10 👷
? What do you want to do? …
❯ Create a JavaScript project
Create a TypeScript project
Create a TypeScript project (with Viem)
Create an empty hardhat.config.js
Quit
いくつか質問されますがデフォルトで進めてOKです。
プロジェクトが作成できたら、VSCodeを開きます。
code .
デフォルトで作成された以下のフォルダーにあるファイルを削除します。
- contracts/Lock.sol
- test/Lock.js
contractsフォルダーに、QuizApp.solを作成します。
touch contracts/QuizApp.sol
QuizApp.solに以下のコードを貼り付けます。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract QuizApp {
// クイズの構造体
// 問題、選択肢、正解のインデックスを保持
struct Question {
string question;
string[] options;
uint correctAnswer;
}
// クイズの配列
Question[] public questions;
// ユーザーごとのポイント
mapping(address => uint256) public points;
// ユーザーごとのクイズ完了フラグ
mapping(address => bool) public completed;
// オーナーアドレス
address public owner;
// オーナーのみ実行可能な関数修飾子
modifier onlyOwner() {
require(msg.sender == owner, "Only owner can perform this action");
_;
}
// クイズ回答時のイベント
event QuestionAnswered(
address indexed user,
bool correct,
uint pointsAwarded
);
// コンストラクタ
constructor() {
owner = msg.sender;
}
// クイズの追加 (オーナーのみ実行可能)
function addQuestion(
string memory _question,
string[] memory _options,
uint _correctAnswer
) public onlyOwner {
questions.push(
Question({
question: _question,
options: _options,
correctAnswer: _correctAnswer
})
);
}
// クイズの取得
function getQuestions() public view returns (Question[] memory) {
return questions;
}
// クイズの回答 (ユーザーのみ実行可能)
function answerQuestion(uint questionIndex, uint answer) public {
require(questionIndex < questions.length, "Question does not exist");
require(!completed[msg.sender], "Quiz already completed");
// クイズの取得
Question memory q = questions[questionIndex];
// 回答が正解かどうか判定
if (q.correctAnswer == answer) {
points[msg.sender] += 1;
emit QuestionAnswered(msg.sender, true, points[msg.sender]);
} else {
emit QuestionAnswered(msg.sender, false, 0);
}
// 全問正解した場合の処理
if (points[msg.sender] == questions.length) {
completed[msg.sender] = true;
// 全問正解で報酬を渡す処理(例: NFTミント)
}
}
// クイズの完了状態を取得
function isCompleted(address user) public view returns (bool) {
return completed[user];
}
// ポイントを取得
function getPoints(address user) public view returns (uint256) {
return points[user];
}
// ポイントを追加
function addPoints(address user, uint256 amount) public {
points[user] += amount;
}
}
クイズの構造体を作成し、配列として保持します。
question - 問題文
options- 回答の選択肢
correctAnswer - 正解のインデックス
addQuestion関数は、onlyOwnerでコントラクトをデプロイしたアドレスのみがクイズを追加できます。
// クイズの構造体
// 問題、選択肢、正解のインデックスを保持
struct Question {
string question;
string[] options;
uint correctAnswer;
}
// クイズの配列
Question[] public questions;
// オーナーアドレス
address public owner;
// オーナーのみ実行可能な関数修飾子
modifier onlyOwner() {
require(msg.sender == owner, "Only owner can perform this action");
_;
}
....
// クイズの追加 (オーナーのみ実行可能)
function addQuestion(
string memory _question,
string[] memory _options,
uint _correctAnswer
) public onlyOwner {
questions.push(
Question({
question: _question,
options: _options,
correctAnswer: _correctAnswer
})
);
}
追加したクイズの配列を取得できます。
// クイズの取得
function getQuestions() public view returns (Question[] memory) {
return questions;
}
answerQuestion関数で、クイズに回答します。
引数に追加したクイズの配列番号と回答を渡して、以下ならエラーを出します。
- 存在しないクイズ番号
- すでに正解している
クイズが正解なら、1ポイントをユーザーに付与します。
不正解なら0です。
全問正解したら、ユーザーに全問正解のフラグを立てて特典を渡す処理を入れます。
// クイズの回答 (ユーザーのみ実行可能)
function answerQuestion(uint questionIndex, uint answer) public {
require(questionIndex < questions.length, "Question does not exist");
require(!completed[msg.sender], "Quiz already completed");
// クイズの取得
Question memory q = questions[questionIndex];
// 回答が正解かどうか判定
if (q.correctAnswer == answer) {
points[msg.sender] += 1;
emit QuestionAnswered(msg.sender, true, points[msg.sender]);
} else {
emit QuestionAnswered(msg.sender, false, 0);
}
// 全問正解した場合の処理
if (points[msg.sender] == questions.length) {
completed[msg.sender] = true;
// 全問正解で報酬を渡す処理(例: NFTミント)
}
}
ユーザー毎のクイズの完了状態とポイント数を取得できます。
addPoints関数は、ユーザーに指定したポイントを付与できます。
// ユーザーごとのポイント
mapping(address => uint256) public points;
// ユーザーごとのクイズ完了フラグ
mapping(address => bool) public completed;
// クイズの完了状態を取得
function isCompleted(address user) public view returns (bool) {
return completed[user];
}
// ポイントを取得
function getPoints(address user) public view returns (uint256) {
return points[user];
}
// ポイントを追加
function addPoints(address user, uint256 amount) public {
points[user] += amount;
}
次にtestフォルダーにQuizApp.test.jsを作成します。
touch test/QuizApp.test.js
以下のコードを貼り付けます。
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("QuizApp", function () {
let quizApp;
beforeEach(async function () {
quizApp = await ethers.deployContract("QuizApp");
[owner, addr1, addr2, _] = await ethers.getSigners();
});
describe("Quiz", function () {
// クイズを追加して正解が取得できるか確認
it("add quiz and check for correct answers", async function () {
const question = "What is the capital of France?";
const options = ["Paris", "London", "Berlin"];
const correctAnswer = 0;
await quizApp.addQuestion(question, options, correctAnswer);
const questions = await quizApp.getQuestions();
expect(questions[0].question).to.equal(question);
expect(questions[0].options[0]).to.equal("Paris");
});
// クイズを追加して不正解が取得できるか確認
it("add quiz and check for incorrect answers", async function () {
const question = "What is the capital of France?";
const options = ["Paris", "London", "Berlin"];
const correctAnswer = 0;
await quizApp.addQuestion(question, options, correctAnswer);
const questions = await quizApp.getQuestions();
expect(questions[0].question).to.equal(question);
expect(questions[0].options[0]).to.not.equal("London");
});
// クイズを追加して回答者が正解したらポイントが加算されるか確認
it("add a quiz and see if points are awarded if respondents answer correctly.", async function () {
const question = "What is 2 + 2?";
const options = ["3", "4", "5"];
const correctAnswer = 1;
await quizApp.addQuestion(question, options, correctAnswer);
await quizApp.connect(addr1).answerQuestion(0, 1);
const points = await quizApp.getPoints(addr1.address);
expect(points).to.equal(1);
});
// クイズを追加して回答者が不正解ならポイントが追加されないか確認
it("add a quiz and see if points are added if respondents answer incorrectly.", async function () {
const question = "What is 2 + 2?";
const options = ["3", "4", "5"];
const correctAnswer = 1;
await quizApp.addQuestion(question, options, correctAnswer);
await quizApp.connect(addr1).answerQuestion(0, 2);
const points = await quizApp.getPoints(addr1.address);
expect(points).to.not.equal(1);
});
// クイズを追加して回答者が全問正解したらクイズが完了するか確認
it("should track completion of the quiz", async function () {
const question1 = "What is 2 + 2?";
const question2 = "What is 1 + 1?";
const options1 = ["3", "4", "5"];
const options2 = ["2", "3", "4"];
const correctAnswer1 = 1;
const correctAnswer2 = 0;
await quizApp.addQuestion(question1, options1, correctAnswer1);
await quizApp.addQuestion(question2, options2, correctAnswer2);
await quizApp.connect(addr1).answerQuestion(0, 1);
await quizApp.connect(addr1).answerQuestion(1, 0);
const completed = await quizApp.isCompleted(addr1.address);
expect(completed).to.equal(true);
});
// クイズを追加して回答者が全問正解したらクイズが完了するか確認
it("should track completion of the quiz", async function () {
const question1 = "What is 2 + 2?";
const question2 = "What is 1 + 1?";
const options1 = ["3", "4", "5"];
const options2 = ["2", "3", "4"];
const correctAnswer1 = 1;
const correctAnswer2 = 0;
await quizApp.addQuestion(question1, options1, correctAnswer1);
await quizApp.addQuestion(question2, options2, correctAnswer2);
await quizApp.connect(addr1).answerQuestion(0, 1);
await quizApp.connect(addr1).answerQuestion(1, 0);
const completed = await quizApp.isCompleted(addr1.address);
expect(completed).to.equal(true);
});
});
describe("Points", function () {
// ポイントを追加して取得できるか確認
it("should add points to a user", async function () {
// addr1に100ポイントを追加
await quizApp.addPoints(addr1.address, 100);
// addr1のポイントを確認
const points = await quizApp.getPoints(addr1.address);
expect(points).to.equal(100);
});
// ポイントを複数回追加して取得できるか確認
it("should accumulate points for a user", async function () {
// addr1に50ポイントを2回追加
await quizApp.addPoints(addr1.address, 50);
await quizApp.addPoints(addr1.address, 50);
// addr1のポイントを確認
const points = await quizApp.getPoints(addr1.address);
expect(points).to.equal(100);
});
// ポイントを追加していないユーザーのポイントを取得できるか確認
it("should return 0 for a user with no points", async function () {
// addr2はまだポイントがないので、0を返す
const points = await quizApp.getPoints(addr2.address);
expect(points).to.equal(0);
});
});
});
テストを実行前の前処理として、QuizAppコントラクトを作成します。
またHardhatの開発環境で用意されているウォレットアドレスをowner、addr1とaddr2に割り当てます。
beforeEach(async function () {
quizApp = await ethers.deployContract("QuizApp");
[owner, addr1, addr2, _] = await ethers.getSigners();
});
クイズが正しく追加できるかをテストします。
問題文と選択肢、正解の配列番号を変数に入れてaddQuestion関数に渡します。
expectで設定した正解と選択した回答が一致するかチェックします。
// クイズを追加して正解が取得できるか確認
it("add quiz and check for correct answers", async function () {
const question = "What is the capital of France?";
const options = ["Paris", "London", "Berlin"];
const correctAnswer = 0;
await quizApp.addQuestion(question, options, correctAnswer);
const questions = await quizApp.getQuestions();
expect(questions[0].question).to.equal(question);
expect(questions[0].options[0]).to.equal("Paris");
});
クイズの追加までは前と同じで、正解したユーザのポイントをgetPoints関数で取得して、付与したポイントが正しいかをチェックします。
// クイズを追加して回答者が正解したらポイントが加算されるか確認
it("add a quiz and see if points are awarded if respondents answer correctly.", async function () {
const question = "What is 2 + 2?";
const options = ["3", "4", "5"];
const correctAnswer = 1;
await quizApp.addQuestion(question, options, correctAnswer);
await quizApp.connect(addr1).answerQuestion(0, 1);
const points = await quizApp.getPoints(addr1.address);
expect(points).to.equal(1);
});
クイズを2問追加して、全問正解した場合の挙動をチェックします。
// クイズを追加して回答者が全問正解したらクイズが完了するか確認
it("should track completion of the quiz", async function () {
const question1 = "What is 2 + 2?";
const question2 = "What is 1 + 1?";
const options1 = ["3", "4", "5"];
const options2 = ["2", "3", "4"];
const correctAnswer1 = 1;
const correctAnswer2 = 0;
await quizApp.addQuestion(question1, options1, correctAnswer1);
await quizApp.addQuestion(question2, options2, correctAnswer2);
await quizApp.connect(addr1).answerQuestion(0, 1);
await quizApp.connect(addr1).answerQuestion(1, 0);
const completed = await quizApp.isCompleted(addr1.address);
expect(completed).to.equal(true);
});
ポイントの追加をテストします。
// ポイントを追加して取得できるか確認
it("should add points to a user", async function () {
// addr1に100ポイントを追加
await quizApp.addPoints(addr1.address, 100);
// addr1のポイントを確認
const points = await quizApp.getPoints(addr1.address);
expect(points).to.equal(100);
});
// ポイントを複数回追加して取得できるか確認
it("should accumulate points for a user", async function () {
// addr1に50ポイントを2回追加
await quizApp.addPoints(addr1.address, 50);
await quizApp.addPoints(addr1.address, 50);
// addr1のポイントを確認
const points = await quizApp.getPoints(addr1.address);
expect(points).to.equal(100);
});
ユーザの取得ポイントを確認します。
// ポイントを追加していないユーザーのポイントを取得できるか確認
it("should return 0 for a user with no points", async function () {
// addr2はまだポイントがないので、0を返す
const points = await quizApp.getPoints(addr2.address);
expect(points).to.equal(0);
});
最後にテストを実行するためのコマンドを設定します。
package.jsonにテストスクリプトを変更します。
"scripts": {
- "test": "echo \"Error: no test specified\" && exit 1"
+ "test": "npx hardhat test"
},
これで完了です。
以下のコマンドを実行します。
npm test
> contract@1.0.0 test
> npx hardhat test
QuizApp
Quiz
✔ add quiz and check for correct answers
✔ add quiz and check for incorrect answers
✔ add a quiz and see if points are awarded if respondents answer correctly.
✔ add a quiz and see if points are added if respondents answer incorrectly.
✔ should track completion of the quiz
Points
✔ should add points to a user
✔ should accumulate points for a user
✔ should return 0 for a user with no points
8 passing (1s)
テストが正常に実行されたことが確認できました!