このチュートリアルでは、テストコードの作成を行います。テストコードは、開発した対象が機能要件を満たすかを確認するためのコードです。事前に開発対象が満たすべきテストケースを整理し、それをコードに落とします。
テストコード
テストコードは、ソフトウェア開発において、プログラムの正確な動作や品質を確認するために書かれるコードのことを指します。主な目的は、アプリケーションやコンポーネントが意図した通りに動作し、バグがないことを検証することです。
テストコードは、以下のような様々なレベルで書かれます:
- ユニットテスト(Unit Tests): 個々の関数やメソッド、クラスなどの最小単位をテストするものです。通常、テスト対象のコードが他の依存するコードから分離されていることが前提です。
- 統合テスト(Integration Tests): 複数のモジュールやコンポーネントが連携して動作するかどうかを確認します。ユニットテストよりも大規模な範囲を対象にします。
- 機能テスト(Functional Tests): アプリケーションの個々の機能がユーザーの要求に従って動作するかどうかをテストします。
- 受け入れテスト(Acceptance Tests): ソフトウェアが全体としてユーザーの要求を満たすかどうかを確認します。アプリケーション全体を対象にしたテストであり、ユーザーの視点でのテストとも言えます。
テストコードは通常、実際のコードと同じプログラミング言語で書かれますが、Hardhatの場合は、JavascriptまたはTypescriptでテストコードを記述します。テストコードを書くことによって、プログラムを修正時に即座に確認できる、繰り返し利用できる、といったメリットがあります。これによって、バグを早期に発見したり、リファクタリング(コードを改善する行為)の補助となり、ソフトウェアの品質を高めることができます。
このガイドでは、Unitテスト(ローカルネットでのテスト)とStagingテスト(テストネットにデプロイし、本番に近い環境で行うテスト)の2つを行います。後者は厳密な定義がしがたいですが、2の結合テストに近いものと捉えることができると思います(テストの種類で何を行うかは組織、プロジェクトによっても異なるので、厳密な定義がしにくいところではあります)。
実際にテストコードを作成する前に、テスト対象のコードを再掲しておきます(解説は、Day17を参照して下さい)。
// contracts/Funding.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
error Funding__NotOwner();
error Funding__NotEnoughETH();
/**
* @title A contract for crowdfunding
* @author simochan
* @notice This contract is to demo a sample funding contract
*/
contract Funding {
// State variables
address[] private s_funders;
mapping(address => uint256) private s_addressToAmountFunded;
address private immutable i_owner;
// Event
event Funded(address indexed funder, uint256 indexed fundedAmount);
// Modifiers
modifier onlyOwner() {
if (msg.sender != i_owner) {
revert Funding__NotOwner();
}
_;
}
constructor() {
i_owner = msg.sender;
}
function fund() public payable {
if(msg.value <= 100000000000000000) {
revert Funding__NotEnoughETH();
}
s_funders.push(msg.sender);
s_addressToAmountFunded[msg.sender] += msg.value;
emit Funded(msg.sender, msg.value);
}
function withdraw() public onlyOwner {
for (uint256 i = 0; i < s_funders.length; i++) {
address funder = s_funders[i];
s_addressToAmountFunded[funder] = 0;
}
s_funders = new address[](0);
(bool success, ) = i_owner.call{value: address(this).balance}("");
require(success);
}
function getOwner() public view returns (address) {
return i_owner;
}
function getFunder(uint256 index) public view returns (address) {
return s_funders[index];
}
function getAddressToAmountFunded(address funder) public view returns(uint256) {
return s_addressToAmountFunded[funder];
}
}
Unitテスト
次に示すものがUnitテストのコードになります。
// test/unit/Funding.test.ts
import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"
import { assert, expect } from "chai"
import { network, deployments, ethers } from "hardhat"
import { developmentChains } from "../../helper-hardhat-config"
import { Funding } from "../../typechain-types"
describe("Funding Unit Tests", function () {
let funding: Funding // Fundingコントラクトを格納するための変数
let deployer: SignerWithAddress // Fundingコントラクトをデプロイしたアカウント、deployerを格納するための変数
beforeEach(async () => {
if (!developmentChains.includes(network.name)) {
throw "このテストは開発用のチェーンで動かす必要があります"
}
const accounts = await ethers.getSigners()
deployer = accounts[0]
await deployments.fixture(["all"])
funding = await ethers.getContract("Funding")
})
describe("fund", function () {
it("ETHの金額が少ない場合、失敗する", async () => {
await expect(funding.fund()).to.be.revertedWith(
"Funding__NotEnoughETH",
)
})
it("出資金額が正しく保存される", async () => {
await funding.fund({ value: ethers.parseEther("1.0") })
const response = await funding.getAddressToAmountFunded(
deployer.address,
)
assert.equal(
response.toString(),
ethers.parseEther("1.0").toString(),
)
})
})
describe("withdraw", function () {
it("普通のユーザーはfundされた金額をwithdrawできない", async () => {
const accounts = await ethers.getSigners()
await funding
.connect(accounts[1])
.fund({ value: ethers.parseEther("1") })
await expect(
funding.connect(accounts[1]).withdraw(),
).to.be.revertedWith("Funding__NotOwner")
})
it("Contractの所有者はfundされた金額をwithdrawできる", async () => {
const accounts = await ethers.getSigners()
await funding.fund({ value: ethers.parseEther("1.0") })
await funding
.connect(accounts[1])
.fund({ value: ethers.parseEther("1.0") })
const initialContractBalance = await ethers.provider.getBalance(
funding.getAddress(),
)
const initialDeployerBalance = await ethers.provider.getBalance(
deployer.address,
)
const withdrawTxn = await funding.withdraw()
const txnReceipt = await withdrawTxn.wait()
const gasUsed = txnReceipt?.gasUsed
const gasPrice = withdrawTxn.gasPrice
const totalGasCost = gasUsed * gasPrice
const finalContractBalance = await ethers.provider.getBalance(
funding.getAddress(),
)
const finalDeployerBalance = await ethers.provider.getBalance(
deployer.address,
)
assert.equal(finalContractBalance.toString(), "0")
const expectedDeployerBalance =
initialDeployerBalance + initialContractBalance - totalGasCost
assert.equal(finalDeployerBalance, expectedDeployerBalance)
})
})
describe("getOwner", function () {
it("deployerのアドレスを正しく返す", async () => {
const response = await funding.getOwner()
assert.equal(response.toString(), deployer.address)
})
})
describe("getFunder", function () {
it("出資者のアドレスを正しく返す", async () => {
await funding.fund({ value: ethers.parseEther("1.0") })
const response = await funding.getFunder(0)
assert.equal(response.toString(), deployer.address)
})
})
describe("getAddressToAmountFunded", function () {
it("出資された金額を正しく返す", async () => {
await funding.fund({ value: ethers.parseEther("2.0") })
const response = await funding.getAddressToAmountFunded(
deployer.address,
)
assert.equal(
response.toString(),
ethers.parseEther("2.0").toString(),
)
})
})
})
構成
- 〜6行目:テストに必要なライブラリーをimportし、テスト用の機能を使えるようにします
- 8行目:Fundingコントラクト用のテストを以下に書きますよ、という宣言をしています
- 9行目:Fundingコントラクトを格納するための変数
- 10行目:Fundingコントラクトをデプロイしたアカウント、
deployer
を格納するための変数 - 11〜19行目:21行目以降のそれぞれが実行される前にこの部分が実行されます
- 21行目〜:Fundingコントラクトで定義したそれぞれの関数用のテストを行っています
beforeEachセクション
beforeEach部分のコードはテストの前提条件を整えるために、ローカルネット上でテストを実行し、テストデプロイメントのセットアップを行い、必要なコントラクトを取得する手続きを示しています。
// test/unit/Funding.test.ts より抜粋
beforeEach(async () => {
if (!developmentChains.includes(network.name)) {
throw "このテストは開発用のチェーンで動かす必要があります"
}
const accounts = await ethers.getSigners()
deployer = accounts[0]
await deployments.fixture(["all"])
funding = await ethers.getContract("Funding")
})
if (!developmentChains.includes(network.name)) { ... }
: この行は、テストを実行する前に、テストが開発用のチェーン(ローカルネット)上で実行されていることを確認しています。developmentChains
は開発用のチェーン名のリストを格納している変数です(helper-hardhat.config.tsで定義しています)。もしテストが開発用のチェーンでない場合、エラーメッセージがスローされてテストが中断されます。const accounts = await ethers.getSigners()
: この行は、Ethers.jsライブラリのgetSigners()
関数を使用して、アカウント(サインする能力を持つオブジェクト)のリストを取得しています。このリストは、テスト内でアカウントの操作や署名を行うために使用されます。deployer = accounts[0]
:deployer
変数に、アカウントリストの中から最初のアカウントを設定しています。このアカウントはテスト内でのデプロイ操作などに使用される可能性があります。await deployments.fixture(["all"])
:deployments
ライブラリのfixture()
関数を使用して、テストデプロイメントのセットアップを行っています。["all"]
はデプロイメントのコンフィグレーションを指定しています。テストの実行前に必要なコントラクトのデプロイや初期化を行うための手続きです。funding = await ethers.getContract("Funding")
: デプロイ済みのコントラクトを取得するために、Ethers.jsライブラリのgetContract()
関数を使用しています。"Funding"
は取得するコントラクトの名前です。これにより、テスト内でコントラクトの操作やアサーションを行うための準備が整います。
describeセクション
このコードは、Mochaテストフレームワークのdescribe
とit
ブロックを使用して、Funding
コントラクトの2つのテストケースを定義しています(1つのitが1つのテストケースに相当します)。各テストケースは、describe
ブロックで指定されたコントラクトの特定の機能に焦点を当ててテストします。以下で各行の処理を解説します。
// test/unit/Funding.test.ts より抜粋
describe("fund", function () {
it("ETHの金額が少ない場合、失敗する", async () => {
await expect(funding.fund()).to.be.revertedWith(
"Funding__NotEnoughETH",
)
})
it("出資金額が正しく保存される", async () => {
await funding.fund({ value: ethers.parseEther("1.0") })
const response = await funding.getAddressToAmountFunded(
deployer.address,
)
assert.equal(
response.toString(),
ethers.parseEther("1.0").toString(),
)
})
})
describe("fund", function () { ... })
: この行は、fund
関数に関連するテストケースをグループ化するためのdescribe
ブロックを定義しています。このブロック内に複数のit
ブロック(テストケース)が含まれています。it("ETHの金額が少ない場合、失敗する", async () => { ... })
: この行は、fund
関数に対してETHの金額が少ない場合に失敗するかどうかをテストするためのit
ブロックを定義しています。テスト内部では、expect
アサーションを使用して、関数呼び出し時に指定のエラー(Funding__NotEnoughETH
)がスローされることを期待しています。it("出資金額が正しく保存される", async () => { ... })
: この行は、fund
関数が正しく動作して出資金額がコントラクト内に保存されるかどうかをテストするためのit
ブロックを定義しています。テスト内部では、fund
関数を呼び出し、適切なETH量(1 ETH)を送信しています。その後、出資金額が正しく保存されているかを確認するために、getAddressToAmountFunded
関数を呼び出してアサーションを行っています。
上記のように、expect, assert.equalなどを使って、期待する結果となるかどうかを確認しています。
expect
:引数の値が期待する結果になるかを確認します。例えば、expect(引数).to.be.true, expect(引数).to.be.revertedWith(“エラー文字列”)といった形です。前者は引数がtrueである、後者は引数の内容を実行した場合、エラーがスローされる、という意味です。assert.equal(引数1, 引数2)
:引数1と引数2が同じ場合にはテスト成功(true)、異なる場合にはテスト失敗(false)、となります。
以降のコードは基本的には上記のものと同じになります。
最も長い50〜79行目は、FundingコントラクトとDeployerのそれぞれの引き出し前の残高と引き出し後の残高を確認し、引き出し後のそれぞれの残高が期待する値となっているかを確認しています。1点補足すべき点は、ガス代の計算を行っている部分です。引き出し後のDeployerの残高は、Fundされた金額から引き出し用の手数料(ガス代)を引いた分の数字が加算されています。その計算を行っているのは、以下の部分です。
// test/unit/Funding.test.ts より抜粋
const withdrawTxn = await funding.withdraw()
const txnReceipt = await withdrawTxn.wait()
const gasUsed = txnReceipt?.gasUsed
const gasPrice = withdrawTxn.gasPrice
const totalGasCost = gasUsed * gasPrice
withdrawTxn
:withdrawTxn
は、Ethereumネットワーク上で行われるトランザクション(取引)を表すオブジェクトです。この変数は、トランザクションを送信するための関数(funding.withdraw()
)の戻り値として得られます。withdrawTxn
オブジェクトには、トランザクションのハッシュ(hash
)やガス価格(gasPrice
)などの情報が含まれています。txnReceipt
:txnReceipt
は、トランザクションの処理が完了した後のトランザクションレシート(Transaction Receipt)を表すオブジェクトです。トランザクションレシートには、トランザクションの結果やガス使用量(gasUsed
)などが含まれています。トランザクションがブロックに含まれ、確定した後に得られる情報です。txnReceipt
オブジェクトは、トランザクションの送信後に得られるwait
メソッドの戻り値として得られます。totalGasCost
:ガス代は、使用されたガスの量にガスの価格を掛け合わせることで得られます。
Unitテストの実行とその結果
Unitテストの実行は次のコマンド(2行目)で実行し、その結果は、6行目以降に表示されます。describe, itのそれぞれの最初で指定した引数が区切り(Funding, fund, withdraw…)として表示され、その間の✓が実行結果が正常であることを示しています。
結果、合計7つのテストケースを実行し、正常であった(passing)ことになります。エラーがある場合には、”1 failing”といった表示がなされます。
# console
% yarn hardhat test # Unitテストを実行する
yarn run v1.22.19
$ /Users/username/code/token-village/funding/node_modules/.bin/hardhat test
Funding Unit Tests
fund
✓ ETHの金額が少ない場合、失敗する
✓ 出資金額が正しく保存される
withdraw
✓ 普通のユーザーはfundされた金額をwithdrawできない
✓ Contractの所有者はfundされた金額をwithdrawできる
getOwner
✓ deployerのアドレスを正しく返す
getFunder
✓ 出資者のアドレスを正しく返す
getAddressToAmountFunded
✓ 出資された金額を正しく返す
7 passing (1s)
✨ Done in 4.56s.
ここで売っているコマンドyarn hardhat test
はショートカットを登録することもできます。package.jsonのscriptsの項目に登録することでより短いコマンドを定義できます。詳細は、Day20に記載していますが、この場合は、yarn test
と実行するだけで同じコマンドが実行できます。
Stagingテスト
次に示すものが、Stagingテストのコードです。
// test/staging/Funding.staging.test.ts
import { assert } from "chai"
import { ethers, network } from "hardhat"
import { developmentChains } from "../../helper-hardhat-config"
import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"
import { Funding } from "../../typechain-types"
developmentChains.includes(network.name)
? describe.skip
: describe("Funding Staging Tests", async function () {
let funding: Funding
let deployer: SignerWithAddress
const fundingAmount = ethers.parseEther("0.11")
beforeEach(async function () {
const accounts = await ethers.getSigners()
deployer = accounts[0]
funding = await ethers.getContract("Funding", deployer.address)
})
it("fundとwithdrawが実行できる", async function () {
await funding.fund({ value: fundingAmount })
await funding.withdraw({ gasLimit: 100000 })
const finalContractBalance = await ethers.provider.getBalance(
funding.getAddress(),
)
console.log(finalContractBalance.toString() + "(=完了後のコントラクトの残高)は0と等しくなります")
assert.equal(finalContractBalance.toString(), "0")
})
})
: この行は、テストを実行するかスキップするかを判断しています。もし
developmentChains.includes(network.name) ? describe.skip : describe("Funding Staging Tests", async function () { ... })network.name
がdevelopmentChains
配列に含まれている場合、describe.skip
が呼び出されてこのセクションのテストがスキップされます。そうでなければ、describe("Funding Staging Tests", async function () { ... })
ブロック内のテストが実行されます。- テストケースの
beforeEach
: テストケースごとに事前のセットアップを行うための セクションです。このセクション内でアカウントの取得、デプロイ、コントラクトのインスタンス化が行われます。 it("fundとwithdrawが実行できる", async function () { ... })
: これは実際のテストケースです。funding.fund
とfunding.withdraw
関数が実行され、その後コントラクトの残高を検証しています。テストはassert.equal
を使用してコントラクトの最終残高がゼロであることを確認しています。
Stagingテストの実行
Stagingテストはテストネットに対して行われます。(デプロイを実施する)deployerアカウントの残高が対象のテストネットにおいてある程度存在する必要があります。残高がない場合には、Day5でも紹介したFaucetサービスを使って、deployerアカウントにテストネットのETHを追加する(残高を増やす)必要があります。
次の画像は、デプロイ、テスト前にアカウントに残高があることをMetaMask上で確認したものです。
deploy
deployとその結果は次のようになります。実際にデプロイされたコントラクトは、Etherscan(ブロックエクスプローラー)で確認することができます。デプロイするトランザクションやVerify(検証)されたコード、ABI(プログラムのインターフェース定義)などを見ることができます。
# console
% yarn hardhat deploy --network sepolia
yarn run v1.22.19
$ /Users/username/code/token-village/funding/node_modules/.bin/hardhat deploy --network sepolia
Nothing to compile
No need to generate any newer typings.
-----------------------------------------------------
Deploying Funding and waiting for confirmations...
deploying "Funding" (tx: 0xf2e83912e0e70c4df85a91d486c115054fe0c166d41f7f7124091c591cf2a793)...: deployed at 0xfb2Cfd85618Ba0Ba983aF6A45134A120905da0e5 with 538126 gas
Funding deployed at 0xfb2Cfd85618Ba0Ba983aF6A45134A120905da0e5
Verifying contract...
Nothing to compile
No need to generate any newer typings.
Successfully submitted source code for contract
contracts/Funding.sol:Funding at 0xfb2Cfd85618Ba0Ba983aF6A45134A120905da0e5
for verification on the block explorer. Waiting for verification result...
Successfully verified contract Funding on Etherscan.
https://sepolia.etherscan.io/address/0xfb2Cfd85618Ba0Ba983aF6A45134A120905da0e5#code
✨ Done in 87.84s.
Stagingテスト
Stagingテストの実行とのその結果は次のようになります(--grep "Staging"
を指定しない場合、Unitテストも併せて実行されてしまいます。このオプションは該当する文字列を含むセクションだけを実行するというものです)。
以下の例ではうまく動作しましたが、テストネットの状況(選択したテストネットやその混雑状況等が理由と推察できる)によっては、タイムアウトになったり、何がしかの理由でエラーとなる場合(リンク先は英文)もあるようです。その場合には、タイムアウトの時間を増やすなどのことによって、解消した例もあるようです。(もしくは、ローカルネットでのテストを綿密に行うことで品質を担保するという考え方を採用することもできます)。
# console
% yarn hardhat test --network sepolia --grep "Staging"
yarn run v1.22.19
$ /Users/username/code/token-village/funding/node_modules/.bin/hardhat test --network sepolia --grep Staging
Compiled 1 Solidity file successfully
Funding Staging Tests
0(=完了後のコントラクトの残高)は0と等しくなります
✓ fundとwithdrawが実行できる
1 passing (4m)
✨ Done in 244.94s.
テストカバレッジ
本来実行されるべきテストケースをテストコードがどのくらい網羅しているかを測定する行為をテストカバレッジを測ると言います。テストカバレッジを測るためには、package.jsonで定義、追加した@nomiclabs/hardhat-etherscan
パッケージの機能を利用します。と言っても、行うのは、次に示すコマンドを実行するのみです。45行目以降のテーブル表記の部分でカバーしていない箇所があれば、100を切った数字が表示され、Uncovered Lines
の箇所にカバーされていない行の数字が表示されます。行数が記載されている場合には、該当する箇所についてのテストコードを書く必要があるということを知ることができます。
% yarn hardhat coverage
yarn run v1.22.19
$ /Users/username/code/token-village/funding/node_modules/.bin/hardhat coverage
Version
=======
> solidity-coverage: v0.8.4
Instrumenting for coverage...
=============================
> Funding.sol
Compilation:
============
Generating typings for: 1 artifacts in dir: typechain-types for target: ethers-v6
Successfully generated 6 typings!
Compiled 1 Solidity file successfully
Network Info
============
> HardhatEVM: v2.17.1
> network: hardhat
Funding
fund
✔ ETHの金額が少ない場合、失敗する (62ms)
✔ 出資金額が正しく保存される (39ms)
withdraw
✔ 普通のユーザーはfundされた金額をwithdrawできない (42ms)
✔ Contractの所有者はfundされた金額をwithdrawできる (55ms)
getOwner
✔ deployerのアドレスを正しく返す
getFunder
✔ 出資者のアドレスを正しく返す
getAddressToAmountFunded
✔ 出資された金額を正しく返す
7 passing (683ms)
--------------|----------|----------|----------|----------|----------------|
File | % Stmts | % Branch | % Funcs | % Lines |Uncovered Lines |
--------------|----------|----------|----------|----------|----------------|
contracts/ | 100 | 87.5 | 100 | 100 | |
Funding.sol | 100 | 87.5 | 100 | 100 | |
--------------|----------|----------|----------|----------|----------------|
All files | 100 | 87.5 | 100 | 100 | |
--------------|----------|----------|----------|----------|----------------|
> Istanbul reports written to ./coverage/ and ./coverage.json
✨ Done in 7.96s.
ご意見をお聞かせください!
この記事、または、web3チュートリアル全体について、是非、あなたのご意見をお聞かせください。
アンケートはこちらからご回答いただけます。
無料相談承ります
オンラインでの無料相談を承っています。ご希望の方は、お問い合わせフォームよりご連絡ください。
ITの専門家があなたのご質問にお答えいたします。
Comments