Github Actionsでドキュメント内のコードのテスト自動化CIを構成する

ドキュメント内の ~~Python~~ コードの自動テストを行うCIを構築するためのGitHub Actionsを作りました。Pythonの例で書きますが、どの言語でも使えます。

## ワークフロー

以下の流れでドキュメント内のコードのテストを自動化します。

1. ローカル
    1. README内のコードブロックにアノテーション
    1. 関数定義
    1. テストコード実装
1. CI (GitHub Action)
    1. 自動テスト
    1. [Markdown Embed Code From File](https://github.com/marketplace/actions/markdown-embed-code-from-file) でREADMEにアノテーションされたコードブロックへ埋め込み
    1. 差分をコミット

[こちら](https://github.com/tokusumi/readme-code-testing)で試しています。

## 1. ローカル 

以下のディレクトリ構成をとることにします。

```shell
├── README.md
├── src
│   ├── __init__.py
│   └── mul.py
└── test_src
    ├── __init__.py
    └── test_mul.py
```

### 1.1. README内のコードブロックにアノテーション

次のように手順1.1.を紹介した `README.md` をディレクトリに配置します。

>\#\# README.md

>README内のコードブロックに"\`\`\`lang:external/file/path.py\`\`\`"のようにアノテーションしておきます。


>つまり、以下のコードブロックはCIで \`src/mul.py` と同期されます:


>\`\`\`python:src/mul.py
```

> 
>\* CIの度に、上記コードブロックの中身がsrc/mul.pyの中身に置き換わります
\* READMEのレンダリング時にPython用のハイライトがかかります。
\* ファイルが存在しなければCIは失敗となります。

### 1.2. 関数定義

`src/` ディレクトリに埋め込みたいソースコードを通常通り定義します。

ここでは適当な関数を定義します。

```python:src/mul.py
from math import pow

def multiple(x):
    return pow(x, 2)
```

### 1.3. テストコード実装

`test_src/` ディレクトリに先程定義した関数/クラスのユニットテストを書きます。

```python:test_src/test_mul.py
from src.mul import multiple

def test_mul():
    assert multiple(3) == 9.
```

ここで、一度ローカルで自動テストを実行しておきます。以下の様に、`pytest` でユニットテストを行います。

```bash
$ pytest test_src
```

ただし、今回紹介する方法は、好きな手法でテストを行なえます。

## 2. CI (GitHub Action)

次のCIをGitHub Actionsで構築します。

1. Pythonのコードを自動テスト
2. コードブロックの同期
3. 差分を自動コミット/プッシュ

先にすべて書くと、先程のディレクトリ構成に以下のGitHub Actions の設定ファイル (`.github/workflows/readme-test.yml`) を加えます。

`jobs` の部分で何をやっているのか後で順に説明していきます。

```yaml:.github/workflows/readme-test.yml
name: Test code and embed into README

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main
jobs:
  readme-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
        with:
          persist-credentials: false
          fetch-depth: 0
      - name: Set up Python
        uses: actions/setup-python@v2
        with:
          python-version: 3.7
      - name: Install dependencies
        run: pip install pytest
      - name: Test with pytest
        run: pytest test_src

      - uses: tokusumi/markdown-embed-code@main
        with:
          markdown: "README.md"

      - name: Check modified (success if not changed, failure if the other)
        id: modified
        run: if [[ $(git status --porcelain | wc -l) != "0" ]]; then exit 1; fi
        continue-on-error: true

      - name: Commit files
        if: steps.modified.outcome == 'failure'
        run: |
          git config --local user.email "action@github.com"
          git config --local user.name "GitHub Action"
          git commit -m "Embedding code into Markdown" -a
      - name: Push changes
        if: steps.modified.outcome == 'failure'
        uses: ad-m/github-push-action@master
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          branch: ${{ github.head_ref }}
```
### 2.1. 自動テスト

まず、Pythonの自動テストの実行部分を説明します。

自動テストに該当する部分は以下です。

```yaml
jobs:
  readme-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
        with:
          persist-credentials: false  # 後述
          fetch-depth: 0              # 後述
      - name: Set up Python
        uses: actions/setup-python@v2
        with:
          python-version: 3.7
      - name: Install dependencies
        run: pip install pytest
      - name: Test with pytest
        run: pytest test_src
```

非常に簡単な構成になっています。例として上記の設定を紹介しましたが、それぞれの開発環境にあわせて自由に自動テストが行えます。

参考までに、GitHub ActionsによるPythonの自動テストの構成をいくつかのせておきます。

* [Building and testing Python
](https://docs.github.com/en/free-pro-team@latest/actions/guides/building-and-testing-python)
* [[Poetry] Python Tips： Python のプロジェクトで GitHub Actions を使いたい](https://www.lifewithpython.com/2020/04/python-github-actions.html)
* [[Pipenv] Github ActionsでPipenv + Pytestの自動テストを行った](https://qiita.com/devara/items/e74c6491d340c9729bc3)

### 2.2. Markdown Embed Code From File でREADMEにアノテーションされたコードブロックへ埋め込み

次に、テストしたコードをREADMEに埋め込みます。

このために、[Markdown Embed Code From File](https://github.com/marketplace/actions/markdown-embed-code-from-file) というGitHub Actionを作りました。

使い方は、以下の様にターゲットとするREADMEのファイル名を指定するだけです。

```yaml
      - uses: tokusumi/markdown-embed-code@main
        with:
          markdown: "README.md"
```


### 2.3. 差分を自動コミット/プッシュ

変更をレポジトリに反映させるためにCIの中で差分をコミットします。

煩雑ですが、以下の処理を行っています。

1. ファイルの変更がないか確認 -> なければ終了
2. コミット (GitHub Actions用のユーザーアカウントを指定)
3. リモートレポジトリへプッシュ (`secrets.GITHUB_TOKEN` はデフォルトで設定されているシークレットです)

```yaml
jobs:
  readme-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
        with:
          persist-credentials: false  # 
          fetch-depth: 0              # 

      # ...

      - name: Check modified (success if not changed, failure if the other)
        id: modified
        run: if [[ $(git status --porcelain | wc -l) != "0" ]]; then exit 1; fi
        continue-on-error: true

      - name: Commit files
        if: steps.modified.outcome == 'failure'
        run: |
          git config --local user.email "action@github.com"
          git config --local user.name "GitHub Action"
          git commit -m "Embedding code into Markdown" -a
      - name: Push changes
        if: steps.modified.outcome == 'failure'
        uses: ad-m/github-push-action@master
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          branch: ${{ github.head_ref }}
```

このCIがはしると、`README.md` は以下の様にコードが同期されます。

>\#\# README.md

>README内のコードブロックに"\`\`\`lang:external/file/path.py\`\`\`"のようにアノテーションしておきます。


>つまり、以下のコードブロックはCIで \`src/mul.py` と同期されます:


>\`\`\`python:src/mul.py
from math import pow

>def multiple(x):
    return pow(x, 2)
```

> 
>\* CIの度に、上記コードブロックの中身がsrc/mul.pyの中身に置き換わります
\* READMEのレンダリング時にPython用のハイライトがかかります。
\* ファイルが存在しなければCIは失敗となります。


## おわり

ドキュメント生成ツールだとMarkdownへの外部ファイルからの埋め込みはよくサポートされていますが、READMEのような生のmarkdownを直接編集してくれるものが見つからなかった (GitHub Actionsには) ので作りました。
よければ使ってみて下さい。

## 参考・類似ツール

- [embedmd](https://github.com/campoy/embedmd): golang製。埋め込むファイルのパスがコードブロック内に入る