はじめに(Introduction)
EVM互換のブロックチェーンで動作するスマートコントラクトをHardhatを使って作成します。
ここではスマートコントラクトのテストを行います。
テスト作成
test
フォルダにSampleToken.js
ファイルを作成します。
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");
});
});
});
loadFixture
固定の環境を設定する関数 deployFixture
を作成します。
ここでは、SampleToken
をデプロイし3つの署名者を返します。
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で行います。
以下のテストでは、デプロイ後の name
と symbol
が正常に設定されているかを判定するテストです。
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 によって、Chaiが拡張されています。
拡張された機能を使用していきます。
Numbers(数値)
EVM互換のスマートコントラクトでは大きな数値(最大 $2^{256}-1$ )数値を使います。
そのため、通常の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
- bn.js インスタンス
- bignumber.js インスタンス
Reverted transactions(差戻トランザクション)
トランザクションの reverted
(差戻)に関する拡張です。
意図的に reverted
(差戻)を発生する為スマートコントラクトに revertedWith
を追記します。
// 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);
}
}
await
の位置が通常とは異なるので注意してください。
reverted
以下のテストでは mint
されていないのに、user1
に 1000
トークンを送信しようとした為、トランザクションの差戻が発生する発生するケースです。
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."
文字列を付与して、トランザクションの差戻が発生する発生するケースです。
文字列と完全一致させるパターン、正規表現で一致させるパターンの2ケースを例として挙げています。
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
が発生します。
/**
* @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
)となります。
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に記載されています。
テストのパターンはパニックの検知、パニックコードの指定(数値、固定値PANIC_CODES
)による検出です。
ここでは、overflow を検出しています。
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
を使用します。
何の文字列も返さないパターンです。
it("revertedWithoutReason", async function () {
const { sampleToken, deployer, user1, user2 } = await loadFixture(deployFixture);
await expect(sampleToken.revertedWith(0)).to.be.revertedWithoutReason();
});
Events
スマートコントラクトを実行した際にイベント(event)を発生する場合があります。
そのイベントについてテストする為の拡張です。
await
の位置が通常とは異なるので注意してください。
emit
ERC20に置いてトークンを移動した場合以下のイベントが発生します。
/**
* @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
)となります。
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
となっています。
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
)を指定します。
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
となっています。
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
)を指定します。
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
指定された文字列が適切なアドレスであることを確認します。
it("properAddress", async function () {
expect("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266").to.be.properAddress;
});
properPrivateKey
指定された文字列が適切な秘密鍵であることを確認します。
it("properPrivateKey", async function () {
expect("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80").to.be.properPrivateKey;
});
properHex
指定された文字列が特定の長さの適切な 16 進文字列であることを確認します。
it("properHex", async function () {
expect("0x1234").to.be.properHex(4);
});
hexEqual
指定された文字列の 16 進文字列が同じ数値に対応することを確認します。
it("hexEqual", async function () {
expect("0x00012AB").to.hexEqual("0x12ab");
});
まとめ
Chaiの形式でテストが記述できるとともに、EVM互換のテスト用に拡張されています。
これらを使用してテストコードを記述していきます。