スマートコントラクト開発（テスト）

# はじめに（Introduction）

EVM互換のブロックチェーンで動作するスマートコントラクトをHardhatを使って作成します。
ここではスマートコントラクトのテストを行います。

[前々回](https://qiita.com/tis-nakagawa/private/ad5d668be821ed6cbd76)と[前回](https://qiita.com/tis-nakagawa/private/3e55e08cfc97fd446181)とが終わっているものとします。

# テスト作成

`test`フォルダに`SampleToken.js`ファイルを作成します。

<details><summary>SampleToken.js 全文</summary>

```javascript:SampleToken.js
const { loadFixture } = require("@nomicfoundation/hardhat-toolbox/network-helpers");
const { expect } = require("chai");
const { PANIC_CODES } = require("@nomicfoundation/hardhat-chai-matchers/panic");

describe("SampleToken", function () {
    const NAME = "Sample Token";
    const SYMBOL = "STC";
    async function deployFixture() {
        const [deployer, user1, user2] = await ethers.getSigners();
        const factory = await ethers.getContractFactory("SampleToken");
        const sampleToken = await factory.deploy(NAME, SYMBOL);
        await sampleToken.waitForDeployment();
        return { sampleToken, deployer, user1, user2 };
    };
    describe("Deployment", function () {
        it("Check name and symbol", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            expect(await sampleToken.name()).to.equal(NAME);
            expect(await sampleToken.symbol()).to.equal(SYMBOL);
        });
    });
    describe("Numbers", function () {
        it("types", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await sampleToken.mint(user1.address, 1000);
            expect(await sampleToken.totalSupply()).to.equal(1000);
            expect(await sampleToken.totalSupply()).to.equal(1000n);
            expect(await sampleToken.totalSupply()).to.equal(1_000);
        });
    });
    describe("Reverted transactions", function () {
        it("reverted", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await expect(sampleToken.transfer(user1.address, 1000)).to.be.reverted;
        });
        it("revertedWith", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await expect(sampleToken.revertedWith(1)).to.be.revertedWith(
                "value is not zero."
            );
            await expect(sampleToken.revertedWith(1)).to.be.revertedWith(
                /value is not .*/
            );
        });
        it("revertedWithCustomError", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await expect(sampleToken.transfer(user1.address, 1000)).to.be.revertedWithCustomError(
                sampleToken,
                "ERC20InsufficientBalance"
            );
            await expect(sampleToken.transfer(user1.address, 1000)).to.be.revertedWithCustomError(
                sampleToken,
                "ERC20InsufficientBalance"
            ).withArgs(
                deployer.address,
                0,
                1000
            );
        });
        it("revertedWithPanic", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await sampleToken.mint(user1.address, 2n ** 256n - 1n);
            await expect(sampleToken.mint(user1.address, 1)).to.be.revertedWithPanic();
            await expect(sampleToken.mint(user1.address, 1)).to.be.revertedWithPanic(0x11);
            await expect(sampleToken.mint(user1.address, 1)).to.be.revertedWithPanic(
                PANIC_CODES.ARITHMETIC_OVERFLOW);
        });
        it("revertedWithoutReason", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await expect(sampleToken.revertedWith(0)).to.be.revertedWithoutReason();
        });
    });
    describe("Events", function () {
        it("emit", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await sampleToken.mint(user1.address, 1000);
            await expect(sampleToken.connect(user1).transfer(user2.address, 500)).to.emit(
                sampleToken,
                "Transfer"
            );
            await expect(sampleToken.connect(user1).transfer(user2.address, 500)).to.emit(
                sampleToken,
                "Transfer"
            ).withArgs(
                user1.address,
                user2.address,
                500
            );
        });
    });
    describe("Balance change", function () {
        it("changeEtherBalance", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await expect(user1.sendTransaction({ to: user2.address, value: 1000 })).to.changeEtherBalance(
                user1.address,
                -1000
            );
        });
        it("changeTokenBalance", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await sampleToken.mint(user1.address, 1000);
            await expect(sampleToken.connect(user1).transfer(user2.address, 500)).to.changeTokenBalance(
                sampleToken,
                user1.address,
                -500
            );
        });
        it("changeEtherBalances", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await expect(user1.sendTransaction({ to: user2.address, value: 1000 })).to.changeEtherBalances(
                [user1.address, user2.address],
                [-1000, 1000]
            );
        });
        it("changeTokenBalances", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await sampleToken.mint(user1.address, 1000);
            await expect(sampleToken.connect(user1).transfer(user2.address, 500)).to.changeTokenBalances(
                sampleToken,
                [user1.address, user2.address],
                [-500, 500]
            );
        });
    });
    describe("Other matchers", function () {
        it("properAddress", async function () {
            expect("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266").to.be.properAddress;
        });
        it("properPrivateKey", async function () {
            expect("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80").to.be.properPrivateKey;
        });
        it("properHex", async function () {
            expect("0x1234").to.be.properHex(4);
        });
        it("hexEqual", async function () {
            expect("0x00012AB").to.hexEqual("0x12ab");
        });
    });
});
```

</details>

# loadFixture

固定の環境を設定する関数 `deployFixture` を作成します。
ここでは、`SampleToken` をデプロイし３つの署名者を返します。

```javascript
    const NAME = "Sample Token";
    const SYMBOL = "STC";
    async function deployFixture() {
        const [deployer, user1, user2] = await ethers.getSigners();
        const factory = await ethers.getContractFactory("SampleToken");
        const sampleToken = await factory.deploy(NAME, SYMBOL);
        await sampleToken.waitForDeployment();
        return { sampleToken, deployer, user1, user2 };
    };
```

# Chai

テストは[Chai](https://www.chaijs.com/)で行います。
以下のテストでは、デプロイ後の `name` と `symbol` が正常に設定されているかを判定するテストです。

```javascript
    describe("Deployment", function () {
        it("Check name and symbol", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            expect(await sampleToken.name()).to.equal(NAME);
            expect(await sampleToken.symbol()).to.equal(SYMBOL);
        });
    });
```

# Hardhat Chai Matchers

Hardhatは [@nomicfoundation/hardhat-chai-matchers](https://www.npmjs.com/package/@nomicfoundation/hardhat-chai-matchers) によって、Chaiが拡張されています。
拡張された機能を使用していきます。

https://hardhat.org/hardhat-chai-matchers/docs/reference

## Numbers（数値）

EVM互換のスマートコントラクトでは大きな数値（最大 $2^{256}-1$ ）数値を使います。
そのため、通常のJavaScriptで扱う数値とは異なる数値形式も有効となります。

```javascript
    describe("Numbers", function () {
        it("types", async function () {
            const { sampleToken, deployer, user1, user2, user3 } = await loadFixture(deployFixture);
            await sampleToken.mint(user1.address, 1000);
            expect(await sampleToken.totalSupply()).to.equal(1000);
            expect(await sampleToken.totalSupply()).to.equal(1000n);
            expect(await sampleToken.totalSupply()).to.equal(1_000);
        });
    });
```

以下の数値形式に対応しています。
- 純正 javascript 数値
- [BigInts](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt)
- [bn.js](https://github.com/indutny/bn.js/) インスタンス
- [bignumber.js](https://github.com/MikeMcl/bignumber.js/) インスタンス

### Reverted transactions（差戻トランザクション）

トランザクションの `reverted`（差戻）に関する拡張です。
意図的に `reverted`（差戻）を発生する為スマートコントラクトに `revertedWith` を追記します。

```javascript:SampleToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract SampleToken is ERC20, Ownable {
    constructor(
        string memory name_,
        string memory symbol_
    ) ERC20(name_, symbol_) Ownable(msg.sender) {}

    function mint(address account, uint256 value) external onlyOwner {
        _mint(account, value);
    }

    function revertedWith(uint256 value) external pure {
        require(value == 0, "value is not zero.");
        require(value == 1);
    }
}
```

:::note alert
`await`の位置が通常とは異なるので注意してください。
:::

### `reverted`

以下のテストでは `mint` されていないのに、`user1` に `1000` トークンを送信しようとした為、トランザクションの差戻が発生する発生するケースです。

```javascript
        it("reverted", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await expect(sampleToken.transfer(user1.address, 1000)).to.be.reverted;
        });
```

### `revertedWith`

次のテストでは、追記した `revertedWith` を使用します。
追記したとおり、`"value is not zero."` 文字列を付与して、トランザクションの差戻が発生する発生するケースです。
文字列と完全一致させるパターン、正規表現で一致させるパターンの２ケースを例として挙げています。


```javascript
        it("revertedWith", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await expect(sampleToken.revertedWith(1)).to.be.revertedWith(
                "value is not zero."
            );
            await expect(sampleToken.revertedWith(1)).to.be.revertedWith(
                /value is not .*/
            );
        });
```

### `revertedWithCustomError`

次のテストでは、残高が足りない状態で `user1` へトークンを送信しようとした場合のケースです。
スマートコントラクト内で定義されている、以下の `ERC20InsufficientBalance` が発生します。

```javascript
    /**
     * @dev Indicates an error related to the current `balance` of a `sender`. Used in transfers.
     * @param sender Address whose tokens are being transferred.
     * @param balance Current balance for the interacting account.
     * @param needed Minimum amount required to perform a transfer.
     */
    error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed);
```

インターフェースが定義されている `sampleToken` と定義されている名称 `ERC20InsufficientBalance` を指定します。
パラメータのチェックを行う為には `withArgs` を使用します。
ここでは、送信元（`sender`）、送信元の保持しているトークン数（`balance`）、送信しようとしたトークン数（`needed`）となります。

```javascript
        it("revertedWithCustomError", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await expect(sampleToken.transfer(user1.address, 1000)).to.be.revertedWithCustomError(
                sampleToken,
                "ERC20InsufficientBalance"
            );
            await expect(sampleToken.transfer(user1.address, 1000)).to.be.revertedWithCustomError(
                sampleToken,
                "ERC20InsufficientBalance"
            ).withArgs(
                deployer.address,
                0,
                1000
            );
        });
```

### `revertedWithPanic`

次のテストはパニックを発生させるテストです。
トークンは `uint256` で定義されている為、それ以上を設定した場合にパニックが発生します。
パニックコードは [panic code](https://docs.soliditylang.org/en/v0.8.26/control-structures.html#panic-via-assert-and-error-via-require)に記載されています。

テストのパターンはパニックの検知、パニックコードの指定（数値、固定値`PANIC_CODES`）による検出です。
ここでは、overflow を検出しています。

```javascript
        it("revertedWithPanic", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await sampleToken.mint(user1.address, 2n ** 256n - 1n);
            await expect(sampleToken.mint(user1.address, 1)).to.be.revertedWithPanic();
            await expect(sampleToken.mint(user1.address, 1)).to.be.revertedWithPanic(0x11);
            await expect(sampleToken.mint(user1.address, 1)).to.be.revertedWithPanic(
                PANIC_CODES.ARITHMETIC_OVERFLOW);
        });
```

### `revertedWithoutReason`

次のテストでは、追記した `revertedWith` を使用します。
何の文字列も返さないパターンです。

```javascript
        it("revertedWithoutReason", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await expect(sampleToken.revertedWith(0)).to.be.revertedWithoutReason();
        });
```

## Events

スマートコントラクトを実行した際にイベント(event）を発生する場合があります。
そのイベントについてテストする為の拡張です。

:::note alert
`await`の位置が通常とは異なるので注意してください。
:::

### `emit`

ERC20に置いてトークンを移動した場合以下のイベントが発生します。

```javascript
    /**
     * @dev Emitted when `value` tokens are moved from one account (`from`) to
     * another (`to`).
     *
     * Note that `value` may be zero.
     */
    event Transfer(address indexed from, address indexed to, uint256 value);
```

インターフェースが定義されている `sampleToken` と定義されている名称 `Transfer` を指定します。
パラメータのチェックを行う為には `withArgs` を使用します。
ここでは、送信元（`from`）、送信先（`to`）、送信トークン数（`value`）となります。

```javascript
        it("emit", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await sampleToken.mint(user1.address, 1000);
            await expect(sampleToken.connect(user1).transfer(user2.address, 500)).to.emit(
                sampleToken,
                "Transfer"
            );
            await expect(sampleToken.connect(user1).transfer(user2.address, 500)).to.emit(
                sampleToken,
                "Transfer"
            ).withArgs(
                user1.address,
                user2.address,
                500
            );
        });
```

## Balance change

メインコインやトークン（ERC20）の移転に伴う残高の推移についてのテストを行う為の拡張です。

### `changeEtherBalance`

以下のテストは、メインコインの推移についてのテストです。
`user1`から`user2`へ`1000`コイン送信したケースです。
`user1`の残金が`-1000`となっています。

```javascript
        it("changeEtherBalance", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await expect(user1.sendTransaction({ to: user2.address, value: 1000 })).to.changeEtherBalance(
                user1.address,
                -1000
            );
        });
```

### `changeTokenBalance`

以下のテストは、トークン（ERC20）の推移についてのテストです。
`user1`から`user2`へ`500`トークン送信したケースです。
`user1`の残金が`-500`となっています。
メインコインと異なり対象のトークンオブジェクト（`sampleToken`）を指定します。

```javascript
        it("changeTokenBalance", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await sampleToken.mint(user1.address, 1000);
            await expect(sampleToken.connect(user1).transfer(user2.address, 500)).to.changeTokenBalance(
                sampleToken,
                user1.address,
                -500
            );
        });
```

### `changeEtherBalances`

以下のテストは、メインコインの推移についてのテストです。
`user1`から`user2`へ`1000`コイン送信したケースで、複数のアドレスを指定できます。
`user1`の残金が`-1000`、`user2`の残金が`1000`となっています。

```javascript
        it("changeEtherBalances", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await expect(user1.sendTransaction({ to: user2.address, value: 1000 })).to.changeEtherBalances(
                [user1.address, user2.address],
                [-1000, 1000]
            );
        });
```

### `changeTokenBalances`

以下のテストは、トークン（ERC20）の推移についてのテストです。
`user1`から`user2`へ`500`トークン送信したケースで、複数のアドレスを指定できます。
`user1`の残金が`-500`、`user2`の残金が`500`となっています。
単発と同じく対象のトークンオブジェクト（`sampleToken`）を指定します。

```javascript
        it("changeTokenBalances", async function () {
            const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
            await sampleToken.mint(user1.address, 1000);
            await expect(sampleToken.connect(user1).transfer(user2.address, 500)).to.changeTokenBalances(
                sampleToken,
                [user1.address, user2.address],
                [-500, 500]
            );
        });
```

## Other matchers

その他の拡張です。

### `properAddress`

指定された文字列が適切なアドレスであることを確認します。

```javascript
        it("properAddress", async function () {
            expect("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266").to.be.properAddress;
        });
```

### `properPrivateKey`

指定された文字列が適切な秘密鍵であることを確認します。

```javascript
        it("properPrivateKey", async function () {
            expect("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80").to.be.properPrivateKey;
        });
```

### `properHex`

指定された文字列が特定の長さの適切な 16 進文字列であることを確認します。

```javascript
        it("properHex", async function () {
            expect("0x1234").to.be.properHex(4);
        });
```

### `hexEqual`

指定された文字列の 16 進文字列が同じ数値に対応することを確認します。

```javascript
        it("hexEqual", async function () {
            expect("0x00012AB").to.hexEqual("0x12ab");
        });
```

# まとめ

Chaiの形式でテストが記述できるとともに、EVM互換のテスト用に拡張されています。
これらを使用してテストコードを記述していきます。