Now and Nawoo

NFTの制作記録、技術メモ → C#, Solidity, Blockchain, Bitcoin, Ethereum, NFT

Hardhatの使い方メモ(4) テスト

長いことプログラマーやってるのですが、恥ずかしながら、今までほとんどテストを書いたことがありません。。。 これではイカンということで、Hardhatでのテストの書き方について調べてみました。

Testing contracts - Hardhat

Mocha,Chai,Waffle って何?

Hardhatのドキュメントを見ると Mocha,Chai,Waffle だのいろいろ単語が出てきます。 Mochaとはテストフレームワークのことで、これだけではテストできないので、Chaiというアサーションライブラリを使います。 Chaiのアサート関数をmatcherと呼ぶのですが、Waffleによってスマートコントラクト用のmatcherを使えるようになります。

さらに、コントラクトのデプロイや関数呼び出しの処理はethers.jsを使って書いていくので、テストを書くには ethers.js の知識も必要になります。

ethers.js

Hardhatでのテストの流れ

testディレクトリの中にテストスクリプト(jsファイル)を作成します。(例えばtest/myTest.js)

テストを実行するには、ターミナルから以下のコマンドを実行します。

> npx hardhat test

testディレクトリ内のすべてのテストスクリプトが実行されます。特定のテストスクリプトだけ実行するにはファイル名を指定します。

> npx hardhat test .\test\myTest.js

テストスクリプトの構造

基本的なテストスクリプトは以下のようになります。

const { expect } = require("chai");
const { ethers, network } = require("hardhat");

describe("テストグループの名前", function () {
  beforeEach(async function() {
    // 各テストの前に実行する共通処理
  });
  it("テストの名前", async function () {
    // 1つ目のテストの内容
  });
  it("テストの名前", async function () {
    // 2つ目のテストの内容
  });
});

describeがグループで、itが一つ一つのテストです。 describeはネストもできるので、大グループ、中グループ、、のように分類して書くこともできます。 各テストは、できるだけシンプルに単一の機能をテストするだけにしておきます。

テストグループの名前とテストの名前は、つなげたときに英文として意味が通じるような形で書くのがお作法らしいですが、 普通に日本語で「〇〇のテスト」と書いた方がわかりやすいと思います。(個人的な見解)

beforeEachの例

beforeEachには、各テストの前に実行する共通処理を書きます。 例えば、以下のように書いておくと、各テストでデプロイ済みの contract が利用できます。

describe("MyNft", function () {
    let contract;
    beforeEach(async function () {
        const factory = await ethers.getContractFactory("MyNft");
        contract = await factory.deploy();
        await contract.deployed();
    });
    it("〇〇のテスト", async function () {
        // デプロイ済みのcontractを利用できる
    });
...

テストの記述

ドキュメントを見ると、 expect(...).to.be.equal(...) のような構文が書いてあって複雑そうに見えますが、実はこのtoとかbeは Language Chainsといって、英文っぽく見せる装飾のためのものです。 expect(...).to.be.equal(...)expect(...).equal(...) は全く同じです。

Language Chainsには、to, be の他にも been, is, that, which, and, has, have, with, at, of, same, but, does, still, also などがあります。 邪魔なだけなので、全部消してしまいましょう。(個人的見解ですw)

Language Chains - Chai Assertion Library

equal

一番良く使うのが expect(値を取得).equal(期待する値) です。

例えば、コントラクトをデプロイして、NFTを1枚発行した後、totalSupplyの返す値が1になっているかどうかテストする場合はこうなります。

await contract.mint();
expect(await contract.totalSupply()).equal(1);

revertのチェック

あえて失敗するような処理を書いて、ちゃんとrevertされるかをチェックします。 例えば、onlyOwnerの関数をOwner以外で呼び出す、範囲外のtokenIDを指定してtokenURIを呼び出す、などです。

await expect(contract.transferFrom(...)).reverted; // awaitの位置に注意

revert時のエラーメッセージも含めてチェックするには、revertedWithを使います。

await expect(contract.tokenURI(99)).revertedWith("ERC721Metadata: URI query for nonexistent token");

イベントのチェック

コントラクトの関数を呼び出したときにイベントが正しく発行されているかをチェックします。

await expect(contract.transferFrom(...)).emit(contract, "Transfer");

イベントのパラメータもチェックするには、withArgsを使います。

await expect(contract.transferFrom(...)).emit(contract, "Transfer").withArgs(...);

時間経過のテスト

block.timestampを使った処理をテストする場合には、手動で時間を進めることができます。

await network.provider.send("evm_increaseTime", 100); // 100秒すすめる
await network.provider.send("evm_mine"); // マイニングする

これでblock.timestampは100秒進みます。 (ぴったり100秒というわけではなく、マイニングのタイミングで少しずれることもあるようです。)

別のアカウントを使う場合

別のアカウントを使って関数呼び出しを行う場合は、contract.connect(~)を使います。

const [owner, addr1, addr2] = await ethers.getSigners();
await contract.connect(addr1).transferFrom(addr1.address, addr2.address, 2);

ethers.jsのメモ

その他、ethers.jsに関して、テストを書くときに迷った部分を書いておきます。

mint後にtokenIDを取得するには?

Transferイベントのパラメータの4つ目がtokenIDになるので、logs(またはevents)から取得できます。 ただし、これで取得した値は文字列なので、数字として扱うならBigNumberに変換する必要があります。

// mintではERC721の_safeMintを呼び出しているとします
// _safeMintではTransferイベントが発行され、そのパラメータの4つ目がtokenIDです。
const tx = await contract.mint(); 
const receipt = await tx.wait();
const tokenId = ethers.BigNumber.from(receipt.logs[0].topics[3]);
 // receipt.events[0].topics[3] でもOK

(あまりスマートじゃないですね。もっと良い方法をご存じの方はぜひ教えて下さい。)

BigNumber - ethers.js

AddressZeroの定義

0x0アドレス は ethers.constants.AddressZeroで定義されています。 "0x0000000000000000000000000000000000000000" と書くかわりに、AddressZeroを使うことができます。

await expect(contract.claim()) // tokenID=1
    .emit(contract, "Transfer")
    .withArgs(ethers.constants.AddressZero, address, 1);

safeTransferFromが呼び出せない!?

ERC721のsafeTransferFromを呼び出すと、contract.safeTransferFrom is not a function. というエラーが出てしまいます。

contract.safeTransferFrom(addr1.address, addr2.address, 1);  // ←これはエラー

safeTransferFromはオーバーロードされているので、下記のように書く必要があります。

contract["safeTransferFrom(address,address,uint256)"](addr1.address, addr2.address, 1);

Overloaded Functions - ethers.js

さいごに

NFT開発時のテストに必要になりそうなポイントをざっとまとめてみました。

ただ、具体的にどんな内容のテストを書いていけばいいのか、というのが大きな問題です。 これについては、私自身まだよくわかっていません。 ソフトウェアテストについての本を1冊読みはじめたところですが、良いテストを書けるようになるまでの道は遠そうです。