GitHub Actionsとganache-cliでUT自動化

## はじめに

#### きっかけ
できればスタブ等を利用せずにプライベートのEthereumネットワークと接続してUTを行いたい。一方で、構成が複雑化するとCICDにのせにくい。。
そんなことを考えながら今回の構成を試してみました。

#### サンプル
実際にmasterへのpush, PRをトリガーにethereumとの接続を行うソースのUTが実行されるレポジトリを作成してみました。
リンク：[nftTransactionHelper](https://github.com/xiangma9712/nftTransactionHelper)

今回作成したツール自体は特段付加価値のあるものではないですが、Dapp開発の中でUTをCICDの仕組みの中で行いたいというニーズはあると思うので参考になれば幸いです。

## テスト環境を考える

### スタブやモックを利用する
ブロックチェーンが読み取り元になるパターンのテストではさほど気にならないですが、送信先となるパターンではテストしにくいところがあります。現実的な解なのかもしれませんが、データ作るのがめんどくさいかつ間違えるので今回はパスしました。

### テストネットを利用する
Ropsten等のテストネットに接続するという方法も考えられます。これの問題点は、常に同じ状況を再現できないことです。
Etherの残高であれば大きな問題にならないかもしれませんが、nonceのパターンであったり、TXが取り込まれない状況であったり再現しにくい部分が多々あります。
ITにはいいですが、UTには向かないでしょう。

### ganache-cli (ソース組み込み)
truffleの[ganache-cli](https://github.com/trufflesuite/ganache-cli)を利用する手もあります。以下のようにproviderに設定することで利用可能です。

```js
const ganache = require("ganache-core");
const web3 = new Web3(ganache.provider());
```
問題はこれをUTで利用しようと思うとソースコードにテスト用の分岐を用意する必要が生まれることです。
基本的には`http-provider`を利用して、実行モードがdevやtestだったら`ganache`を利用するといった形で切り替えることになると思います。個人的には避けたい事象です。

### ganache-cli (Docker)

今回使ったのはこれです。指定のアドレスに残高を用意するなどの起動オプションを指定することでいつ起動しても常に同じ状態を再現することができます。
また、テストを行うnodeとは別に起動することで、`http-provider`を利用し続けたまま、向き先をlocalhostにすることでテストが可能となります。
これくらいであれば環境変数や設定ファイルから読み込んで切り替えるのも自然ではないでしょうか。

## テストを作る

### 環境のセットアップ
まずdockerでganache-cliを起動します。

```shell
docker run --detach --rm --name localethereum -p 8545:8545 trufflesuite/ganache-cli \
--accounts 1 \
--mnemonic " ?? " \
--defaultBalanceEther 100 \
```
ここでは、mnemonicによって指定された特定のアドレスに100Etherを持たせて起動しています。設定可能なオプションは[docker-hub](https://hub.docker.com/r/trufflesuite/ganache-cli/)から確認できます。

`--detach`をつけないとログがうるさいです。`-p 8545:8545`は言わずもがなアクセスのために必須です。実行のたびに一貫した状態となることが今回のテーマなので`--rm`も大事。

動作確認環境 
- Github Actions
- MacOS 11.3.1 (m1) / DockerDesktop 3.3.1

### 起動を確認するスクリプトの用意

最終的にCICDで回るようにするために必要なのが、この起動を確認するスクリプトです。
コンテナが起動してから実際にブロックの読み取りやTXの送信が可能となるまでにタイムラグがあります。
DBのコンテナを立てるときにもよくある話ですが、テストを走らせる前に少しまちを入れる必要があるわけです。
起動からおおよそ15秒〜30秒でOKになります。

```typescript
import Web3 from 'web3';
const client = new Web3('http://localhost:8545');
const sleep = (msec: number) => new Promise(resolve => setTimeout(resolve, msec));

async function waitUntilContainerReady(seconds: number): Promise<void> {
    const promises: Promise<unknown>[] = [];
    promises.push(sleep(seconds * 1000));
    promises.push((async () => {
        while(true) {
            try {
                const blockNumber = await client.eth.getBlockNumber();
                return;
            } catch (error) {
                console.log('Not prepared yet...');
                await sleep(1000 * 5);
            }
        }
    })());
    await Promise.race(promises);
}

waitUntilContainerReady(60);
```

### 状態を管理する

ganacheの設定だけでは限界があるので、コントラクトを発行したり他のアドレスに残高を移したりテスト前に実行されるスクリプトを用意することも必要になります。 
コントラクトのソースは予めコンパイル結果を置いておくと良いかと思います。

```typescript
import Web3 from 'web3';
import HDWalletProvider from '@truffle/hdwallet-provider';
import * as fs from 'fs';
import {mainAccount, subAccount} from '../testAccount';
const compileResult = JSON.parse(fs.readFileSync('./test/env/MyNftLibrary.json', 'UTF-8'));

export const setup = async () => {
    const provider = new HDWalletProvider(mainAccount.mnemonic, 'http://localhost:8545');
    const client = new Web3(provider);
    const contractBeforeDeploy = new client.eth.Contract(compileResult.abi);
    const contract = await contractBeforeDeploy
        .deploy({data: compileResult.bytecode})
        .send({from: provider.getAddress()});
        
    await contract.methods.mint(1).send({from: provider.getAddress()});
    provider.engine.stop();
    return contract.options.address;
};
```

テストinputがベタガキでなくてスクリプトによって用意されることになるので微妙と感じる人もいるかもしれません。そういう場合はインプットのassertもしてください。

### ローカルでのテスト

上記三つの工程をテスト実行前に行う必要があります。今回は以下のようにそれぞれ対応しました。

- コンテナ起動
  シェルを配置して、`npm run up-local-ethereum`で実行できるようにnpm scriptに登録。

- コンテナの待機
  `postup-local-ethereum`で用意した待機スクリプトが実行されるようにすればOK

- 状態の管理
  mochaの`beforeTest()`で呼びだし。

## Github Actions

下準備が整ったら、github actionsを呼び出すためのyamlを配置すれば完了です。


```yaml
name: Node.js CI

on:
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [14.x]

    steps:
    - uses: actions/checkout@v2
    - name: Use Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v2
      with:
        node-version: ${{ matrix.node-version }}
    - run: npm ci
    - run: npm run up-local-ethereum
    - run: npm test
```

## おわりに
本当はテストケース間での依存をなくすために、こまめにデータ初期化を行うべきという話はあります。それを踏まえるとTypeScriptでコントロールできないdockerの利用はデメリットもありそうです。

また、ちょっと完成度の高いmockにすぎないのではないかという話もあるかと思います。
gethをプライベートノードとして起動するみたいなこともあり得ますが、流石に費用対効果が微妙そうです。

などなど懐疑的な見方もあると思いますが、UTを作り込める可能性は十分感じていただけたと思います。
また今回はしていませんが、npm publishまで自動化するなども容易そうです。