day24. hardhat – テスト –

このチュートリアルでは、テストコードの作成を行います。テストコードは、開発した対象が機能要件を満たすかを確認するためのコードです。事前に開発対象が満たすべきテストケースを整理し、それをコードに落とします。

このチュートリアルでは、テストコードの作成を行います。テストコードは、開発した対象が機能要件を満たすかを確認するためのコードです。事前に開発対象が満たすべきテストケースを整理し、それをコードに落とします。

TOC

テストコード

テストコードは、ソフトウェア開発において、プログラムの正確な動作や品質を確認するために書かれるコードのことを指します。主な目的は、アプリケーションやコンポーネントが意図した通りに動作し、バグがないことを検証することです。

テストコードは、以下のような様々なレベルで書かれます:

  1. ユニットテスト(Unit Tests): 個々の関数やメソッド、クラスなどの最小単位をテストするものです。通常、テスト対象のコードが他の依存するコードから分離されていることが前提です。
  2. 統合テスト(Integration Tests): 複数のモジュールやコンポーネントが連携して動作するかどうかを確認します。ユニットテストよりも大規模な範囲を対象にします。
  3. 機能テスト(Functional Tests): アプリケーションの個々の機能がユーザーの要求に従って動作するかどうかをテストします。
  4. 受け入れテスト(Acceptance Tests): ソフトウェアが全体としてユーザーの要求を満たすかどうかを確認します。アプリケーション全体を対象にしたテストであり、ユーザーの視点でのテストとも言えます。

テストコードは通常、実際のコードと同じプログラミング言語で書かれますが、Hardhatの場合は、JavascriptまたはTypescriptでテストコードを記述します。テストコードを書くことによって、プログラムを修正時に即座に確認できる、繰り返し利用できる、といったメリットがあります。これによって、バグを早期に発見したり、リファクタリング(コードを改善する行為)の補助となり、ソフトウェアの品質を高めることができます。

テスト駆動開発(TDD: Test-Driven Development)
テスト駆動開発とは、実際のコーディングを開始する前にテストケースの策定とテストコードの作成から始める開発手法のことを言います。
この手法では、まずRed(テストコードは実装されているが、機能が実装されていない状態)=> Green(テストを通過するレベルに機能が実装されている状態) => Refactor(さらにコード改善をし、品質、保守性が高められた状態)という順をたどります。このサイクルを繰り返すことで、品質の向上、バグの低減、新機能の追加の容易性を高めることができます。

このガイドでは、Unitテスト(ローカルネットでのテスト)とStagingテスト(テストネットにデプロイし、本番に近い環境で行うテスト)の2つを行います。後者は厳密な定義がしがたいですが、2の結合テストに近いものと捉えることができると思います(テストの種類で何を行うかは組織、プロジェクトによっても異なるので、厳密な定義がしにくいところではあります)。

実際にテストコードを作成する前に、テスト対象のコードを再掲しておきます(解説は、Day17を参照して下さい)。

Solidity
// 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テストのコードになります。

TypeScript
// 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部分のコードはテストの前提条件を整えるために、ローカルネット上でテストを実行し、テストデプロイメントのセットアップを行い、必要なコントラクトを取得する手続きを示しています。

TypeScript
// 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")
    })
  1. if (!developmentChains.includes(network.name)) { ... }: この行は、テストを実行する前に、テストが開発用のチェーン(ローカルネット)上で実行されていることを確認しています。developmentChainsは開発用のチェーン名のリストを格納している変数です(helper-hardhat.config.tsで定義しています)。もしテストが開発用のチェーンでない場合、エラーメッセージがスローされてテストが中断されます。
  2. const accounts = await ethers.getSigners(): この行は、Ethers.jsライブラリのgetSigners()関数を使用して、アカウント(サインする能力を持つオブジェクト)のリストを取得しています。このリストは、テスト内でアカウントの操作や署名を行うために使用されます。
  3. deployer = accounts[0]: deployer変数に、アカウントリストの中から最初のアカウントを設定しています。このアカウントはテスト内でのデプロイ操作などに使用される可能性があります。
  4. await deployments.fixture(["all"]): deploymentsライブラリのfixture()関数を使用して、テストデプロイメントのセットアップを行っています。["all"]はデプロイメントのコンフィグレーションを指定しています。テストの実行前に必要なコントラクトのデプロイや初期化を行うための手続きです。
  5. funding = await ethers.getContract("Funding"): デプロイ済みのコントラクトを取得するために、Ethers.jsライブラリのgetContract()関数を使用しています。"Funding"は取得するコントラクトの名前です。これにより、テスト内でコントラクトの操作やアサーションを行うための準備が整います。

describeセクション

このコードは、Mochaテストフレームワークのdescribeitブロックを使用して、Fundingコントラクトの2つのテストケースを定義しています(1つのitが1つのテストケースに相当します)。各テストケースは、describeブロックで指定されたコントラクトの特定の機能に焦点を当ててテストします。以下で各行の処理を解説します。

TypeScript
// 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(),
            )
        })
    })
  1. describe("fund", function () { ... }): この行は、fund関数に関連するテストケースをグループ化するためのdescribeブロックを定義しています。このブロック内に複数のitブロック(テストケース)が含まれています。
  2. it("ETHの金額が少ない場合、失敗する", async () => { ... }): この行は、fund関数に対してETHの金額が少ない場合に失敗するかどうかをテストするためのitブロックを定義しています。テスト内部では、expectアサーションを使用して、関数呼び出し時に指定のエラー(Funding__NotEnoughETH)がスローされることを期待しています。
  3. 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された金額から引き出し用の手数料(ガス代)を引いた分の数字が加算されています。その計算を行っているのは、以下の部分です。

TypeScript
// 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
  1. withdrawTxn: withdrawTxnは、Ethereumネットワーク上で行われるトランザクション(取引)を表すオブジェクトです。この変数は、トランザクションを送信するための関数(funding.withdraw())の戻り値として得られます。withdrawTxnオブジェクトには、トランザクションのハッシュ(hash)やガス価格(gasPrice)などの情報が含まれています。
  2. txnReceipt: txnReceiptは、トランザクションの処理が完了した後のトランザクションレシート(Transaction Receipt)を表すオブジェクトです。トランザクションレシートには、トランザクションの結果やガス使用量(gasUsed)などが含まれています。トランザクションがブロックに含まれ、確定した後に得られる情報です。txnReceiptオブジェクトは、トランザクションの送信後に得られるwaitメソッドの戻り値として得られます。
  3. totalGasCost:ガス代は、使用されたガスの量にガスの価格を掛け合わせることで得られます。

Unitテストの実行とその結果

Unitテストの実行は次のコマンド(2行目)で実行し、その結果は、6行目以降に表示されます。describe, itのそれぞれの最初で指定した引数が区切り(Funding, fund, withdraw…)として表示され、その間の✓が実行結果が正常であることを示しています。
結果、合計7つのテストケースを実行し、正常であった(passing)ことになります。エラーがある場合には、”1 failing”といった表示がなされます。

Zsh
# 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テストのコードです。

TypeScript
// 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")
          })
      })

  1. developmentChains.includes(network.name) ? describe.skip : describe("Funding Staging Tests", async function () { ... })
    : この行は、テストを実行するかスキップするかを判断しています。もし network.namedevelopmentChains 配列に含まれている場合、describe.skip が呼び出されてこのセクションのテストがスキップされます。そうでなければ、describe("Funding Staging Tests", async function () { ... }) ブロック内のテストが実行されます。
  2. テストケースの beforeEach : テストケースごとに事前のセットアップを行うための セクションです。このセクション内でアカウントの取得、デプロイ、コントラクトのインスタンス化が行われます。
  3. it("fundとwithdrawが実行できる", async function () { ... }): これは実際のテストケースです。funding.fundfunding.withdraw 関数が実行され、その後コントラクトの残高を検証しています。テストは assert.equal を使用してコントラクトの最終残高がゼロであることを確認しています。

Stagingテストの実行

Stagingテストはテストネットに対して行われます。(デプロイを実施する)deployerアカウントの残高が対象のテストネットにおいてある程度存在する必要があります。残高がない場合には、Day5でも紹介したFaucetサービスを使って、deployerアカウントにテストネットのETHを追加する(残高を増やす)必要があります。
次の画像は、デプロイ、テスト前にアカウントに残高があることをMetaMask上で確認したものです。

Metamaskで利用するアカウント(deployer)の残高が十分にあることを確認の上、以降の操作は行います。

deploy

deployとその結果は次のようになります。実際にデプロイされたコントラクトは、Etherscan(ブロックエクスプローラー)で確認することができます。デプロイするトランザクションやVerify(検証)されたコード、ABI(プログラムのインターフェース定義)などを見ることができます。

Zsh
# 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テストも併せて実行されてしまいます。このオプションは該当する文字列を含むセクションだけを実行するというものです)。

以下の例ではうまく動作しましたが、テストネットの状況(選択したテストネットやその混雑状況等が理由と推察できる)によっては、タイムアウトになったり、何がしかの理由でエラーとなる場合(リンク先は英文)もあるようです。その場合には、タイムアウトの時間を増やすなどのことによって、解消した例もあるようです。(もしくは、ローカルネットでのテストを綿密に行うことで品質を担保するという考え方を採用することもできます)。

Zsh
# 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の箇所にカバーされていない行の数字が表示されます。行数が記載されている場合には、該当する箇所についてのテストコードを書く必要があるということを知ることができます。

Zsh
% 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チュートリアルシリーズについてのご意見を是非お聞かせください。

この記事、または、web3チュートリアル全体について、是非、あなたのご意見をお聞かせください。
アンケートはこちらからご回答いただけます。

無料相談承ります

ITの専門家が無料相談を受け付けます。web3以外のテーマでもOKです。

オンラインでの無料相談を承っています。ご希望の方は、お問い合わせフォームよりご連絡ください。
ITの専門家があなたのご質問にお答えいたします。

Let's share this post !

Author of this article

After joining IBM in 2004, the author gained extensive experience in developing and maintaining distributed systems, primarily web-based, as an engineer and PM. Later, he founded his own company, designing and developing mobile applications and backend services. He is currently leading a Tech team at a venture company specializing in robo-advisory.

Comments

To comment

CAPTCHA


TOC