Node.js/npm ほぼ初心から、GitHub Actions でユニットテスト (Vitest) 動かすまで

:::note warn
Node.js の話や Vitest の話など、色々詰め合わせになっています。

何かの参考用というより、他人が書いた勉強ノート的なものとして見ていただければと思います。
:::

## 背景

しばらくの間、JavaScript のユニットテストは、テスト用の HTML と JavaScript を書いて、ブラウザで実行してコンソール確認して、という形をとっていました。

ユニットテストフレームワークを避けていたのは、npm とか Node.js 関係の環境構築を食わず嫌いしてきたためです。(フレームワークを使うには [Node.js](https://nodejs.org/en) か [Deno](https://deno.com/) を導入する他ない)

ただ、さすがにブラウザで手動テストは面倒だしダサいと感じてきたので、大人しく Node.js/npm 周りと和解し、Vitest を使ったテストをすることにしたのでした。

自動で回せるようになれば、GitHub Actions でも push の度にテストを回せますから、常にテストし続けてコードの質を保つことにつながります。

ということで、この記事は Node.js 関係の環境構築、および npm の扱いからスタートして、GitHub Actions で Vitest を動かすまでの手順というか、道のりを記しています。

## ローカル上で Vitest を準備して動かすまで

まずはローカル環境上で Vitest を利用できるようにします。

### Node.js の準備

Vitest は [v18.0.0 以上の Node.js が必要](https://vitest.dev/guide/#adding-vitest-to-your-project)なようです。ところが、apt から入れられる Node.js は古く、この要件を満たせません。(記事執筆時点、Linux Mint 21.3 で v12.22.9)

そこで、apt ではない方法で Node を準備します。[Node Version Manager (nvm)](https://nodejs.org/en/download/package-manager) を使う方法が良さげです。インストールは下記コマンド。

```sh
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
```

Bash でなく Fish 使いの方は[こちらが使えます](https://github.com/jorgebucaran/nvm.fish)。インストールは下記コマンド。

```sh
fisher install jorgebucaran/nvm.fish
```

これで `nvm` コマンドが使えるようになり、Node.js のインストール/アンインストール、アクティベートなどができるようになります。

v20 の Node が LTS としてリリースされているようなので、ここでは v20 を使う方向でいきます。(本記事執筆時点で最新の LTS)

```sh
nvm install 20
```

そして、下記コマンドで環境がアクティベートされ、`node` や `npm` などのコマンドが使えるようになります。

```sh
nvm use 20
```

一度端末を閉じるとアクティベートが解除され、`node` や `npm` などのコマンドが使えなく (見つからなく) なります。必要になったら、再び上の `use` コマンドを実行します。

### Vitest の準備

この辺は、[Vitest 公式ページ](https://vitest.dev/guide/#adding-vitest-to-your-project)にある通り進めます。カレントに Vitest パッケージをインストールします。

```sh
npm install -D vitest
```

:::note
オプション `-D` の意味は、調べれば出てくるので省略。

一言で言うと、開発用途にだけ必要なパッケージ (例えばテストフレームワークとか) には `-D` を付けるべきです。
:::

```sh
ls
# node_modules  package-lock.json  package.json
```

この `node_modules` が存在するディレクトリ、およびこれより下位のディレクトリにカレントがある状態で、先ほどインストールしたパッケージが使用できます。試しに Vitest のバージョンを表示させてみます。

```sh
npx vitest -v
# vitest/1.6.0 linux-x64 node-v20.13.0
```

### ちょっと npm の設定

ここで `package.json` を少し編集します。

```diff_json
  {
+   "scripts": {
+     "test": "vitest run"
+   },
+   "type": "module",
    "devDependencies": {
      "vitest": "^1.6.0"
    }
  }
```

:::note warn
バージョン `^1.6.0` は、そのときのリリースバージョンによって変わります。
:::

#### `"scripts"`

これにより、[`npm test`](https://docs.npmjs.com/cli/v7/commands/npm-test) とコマンドを実行すると、Vitest が実行されるようになります。エイリアス登録的な感じです。

これを登録しておくことで、もしテストに使うオプションが変わったとしても、GitHub Actions などのスクリプトを変えることなく、`package.json` だけ変えればよいことになります。以降はテストをする際に `npm test` と実行します。

:::note
`"scripts"` に登録されているコマンドは、[`npm run <name>`](https://docs.npmjs.com/cli/v7/commands/npm-run-script) というコマンドで呼び出せます。

`"test"` は `npm run test` でも実行できますが、よく使われるからちょっと特別？で、`npm test` だけで OK。
:::

#### `"type"`

モジュールの読み込み方式の指定です。試しにこれを未設定の状態でテストをしてみると、以下のような Warning が出てきます。

```
The CJS build of Vite's Node API is deprecated. See https://vitejs.dev/guide/troubleshooting.html#vite-cjs-node-api-deprecated for more details.
```

https://vitejs.dev/guide/troubleshooting.html#vite-cjs-node-api-deprecated

「CJS (CommonJS) を廃止するから、ESM (ECMAScript Modules) にしてね」との案内です。先の `"type": "module"` を指定してやることで ESM になり、これで Warning が消えます。

:::note
JavaScript のモジュールの仕組みは、CommonJS と ECMAScript Modules の 2つの仕様があります。仕様が 2つ存在する経緯とかを記すとこの記事の範囲を超えてしまうので、詳細は別途お調べください･･･。
:::

### 以降 vitest の準備は

ところで、Vitest をインストールしたとき、`package.json` と `package-lock.json` が生成されました。この 2ファイルには、インストールしたパッケージの情報が記されています。

`node_modules` にはパッケージの実体が格納されており、先ほどのパッケージ情報ファイルを基に生成されます。

そのため、`node_modules` は Git などのソース管理で追跡せず、代わりに `package.json` と `package-lock.json` をソース管理することで、同じパッケージ構成を他環境で作れるようにするのが一般的です。

`package-lock.json` を基にパッケージを再度インストールするには下記コマンド。

```sh
npm ci
```

`package-lock.json` には、依存パッケージ含め、インストールしたすべてのパッケージ情報が記されています。つまり、`npm ci` により、パッケージ構成は (バージョンなど含めて) 完全に再現されることになります。

`npm install` と `npm ci` は一見すると同じようなコマンドに見えます。ですが、GitHub Actions などの CI 環境においては、`npm ci` が推奨されています。

詳しくは、こちらの記事の解説をどうぞ。(丸投げ)

https://qiita.com/mstssk/items/8759c71f328cab802670

### Vitest の実行

Vitest の準備ができたところで、テストを記述します。[公式チュートリアルの通り](https://vitest.dev/guide/#writing-tests)ファイルを準備するとこんな感じ。

```sh
ls
# node_modules  package-lock.json  package.json  sum.js  sum.test.js
```

```sum.js
export function sum(a, b) {
  return a + b
}
```

```sum.test.js
import { expect, test } from 'vitest'
import { sum } from './sum'

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3)
})
```

テスト実行

```sh
npm test
```

正しくできていれば、Vitest が passed で結果を表示してくれます。

![vitest-run.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540894/fb26825e-1d77-e205-ec92-d2e27229499d.png)

これで、まずはローカル環境上で Vitest を動かすことができるようになりました。

## GitHub Actions で動かす

これまでの手順も踏まえ、テストを GitHub Actions 上で動かします。

さて、先の手順では nvm をやりくりしていましたが、GitHub Actions ではその必要はありません。Node.js を準備してくれる action を呼ぶだけで OK です。また、バージョン指定も可能です。

```yml
    - name: Set up Node 20
      uses: actions/setup-node@v4
      with:
        node-version: 20
```

あとは、`npm ci` とかで Vitest の準備をし、実行するのみ。

完成品がこちら。

```yml
name: Run all JS unittests by vitest

on:
  push

jobs:
  js-vitest:
    runs-on: ubuntu-latest

    steps:
    - name: Source checkout
      uses: actions/checkout@v4
    - name: Set up Node 20
      uses: actions/setup-node@v4
      with:
        node-version: 20
    - name: Install node dependencies
      run: npm ci
    - name: Run all tests
      run: npm test
```

ちなみに、Vitest のテストファイルがある場所、および Vitest をインストールする場所がリポジトリのルート直下でない場合は、`defailts:` を以下のように追加することでカレントをセットできます。

```yml
(略)
jobs:
  js-vitest:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: path/to/vitest-files/dir
(略)
```

## ちなみに - Vitest の採用理由

- Vitest が比較的最近できたものらしい (記事執筆時点)
- Jest と API の互換性がある
  - Jest は数ある JavaScript テストフレームワークの中でもだいぶ人気らしい
  - もし Vitest が微妙だったら Jest にしよう、という算段
- 場合によっては Jest より早いらしい？ (要出典)

機能面で特別な理由はありません。フレームワーク固有の機能を使えば、もっといい感じなテスト (語彙力ェ) ができるかもしれませんが、慣れてないうちは、複雑な機能は避けることにします。

## おまけ - テストコード before - after

以前やっていたテストコードの書き方と、Vitest を使用したテストコードを比べてみます。例外 throw のテストをしたかったので、`sum()` のサンプルでなく `div()` (割り算) のサンプルを作ってみます。

```div.js
export function div(a, b) {
  if(b === 0) {
    throw new Error("Zero division");
  }

  return a / b;
}
```

```before.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <script type="module">
      import { div } from '/static/div.js'

      function test_div() {
        console.log("-- test_div");

        const expected = 2 / 3;
        const actual = div(2, 3);

        if(expected === actual) {
          console.log("--- OK");
        } else {
          console.log("Expected: " + expected);
          console.log("Actual: " + actual);
          console.log("--- NG");
        }
      }

      function err_zero_div() {
        console.log("-- err_zero_div");

        try {
          div(1, 0);
          console.log("--- NG - No error thrown");
        } catch(e) {
          if(e.toString() === "Error: Zero division") {
            console.log("--- OK");
          } else {
            console.log(e);
            console.log("--- NG - Un expected error thrown");
          }
        }
      }

      document.getElementById("test-run-button").addEventListener("click", () => {
        test_div();
        err_zero_div();
      });
    </script>
  </head>

  <body>
    <button id="test-run-button">Run test</button>
  </body>
</html>
```

:::note warn
ESM の `import` `export` を使ったスクリプトの場合、HTML をローカルファイルとして開くと動いてくれません。HTTP 通信経由で開く必要があります。
:::

<details><summary>FastAPI の例</summary>

え、HTTP サーバも Node.js でやらんのかって？FastAPI しかまだ知らんのです･･･。

ファイル構成:

```
./
 |- main.py
 |- pages
 |   |- before.html
 |
 |- static
     |- div.js
```

```main.py
# coding: UTF-8

from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from uvicorn import run

templates = Jinja2Templates("./pages")

app = FastAPI()
app.mount("/static", StaticFiles(directory="./static"))


@app.get("/")
def index(request: Request):
  return templates.TemplateResponse("before.html", {"request": request})


if __name__ == "__main__":
  run("main:app", reload=True)
```

---

</details>

```after.test.js
import { div } from 'div.js'
import { test, expect } from 'vitest'

test("Division", () => {
  expect(div(2, 3)).toBe(2 / 3);
});

test("Zero division", () => {
  expect(() => div(1, 0)).toThrowError(new Error("Zero division"));
});
```

この通り、コード量が格段に減りました (そもそも HTML を書く必要が無くなった)。テスト結果を出力するコードを自力で書く必要が無くなるので、だいぶ楽です。

(`before.html` あえて共通部分のモジュール化せず遠回しに書いてる？と思われるかもしれません。でもこの非スマートな書き方し続けてたのはマジです。)

テスト書くのを面倒臭がって、ノーテストのコードが量産される前にフレームワーク導入してよかったです。いや、マジで。