ReScriptでJestを使う DOM編

# 準備
[ReScriptでJestを使う](https://qiita.com/sgigagaeru/items/8544e6005bc63568a2ec)を参考に設定を整えます。
`npm create rescript-app@latest`
`npm install --save-dev @glennsl/rescript-jest`
`mkdir __tests__`
<details><summary>rescript.json</summary>

```JSON:rescript.json
{
  "name": "jest-test",
  "sources": [
    {
      "dir": "src",
      "subdirs": true
    },
    {
      "dir": "__tests__",
      "type": "dev"
    }
  ],
  "package-specs": {
    "module": "commonjs",
    "in-source": true
  },
  "suffix": ".res.js",
  "bs-dependencies": [
    "@rescript/core"
  ],
  "bs-dev-dependencies": [
    "@glennsl/rescript-jest"
  ],
  "bsc-flags": [
    "-open RescriptCore"
  ]
}
```
</details>
<details><summary>package.json</summary>

```JSON:package.json
{
  "name": "jest-test",
  "version": "0.0.0",
  "scripts": {
    "res:build": "rescript",
    "res:clean": "rescript clean",
    "res:dev": "rescript -w",
    "test": "jest"
  },
  "keywords": [
    "rescript"
  ],
  "author": "",
  "license": "MIT",
  "dependencies": {
    "@rescript/core": "^1.5.0",
    "rescript": "^11.1.1"
  },
  "devDependencies": {
    "@glennsl/rescript-jest": "^0.11.0"
  }
}
```
</details>

# Unit Testにかけるファイル
[ReScriptでDOMを扱う](https://qiita.com/sgigagaeru/items/98ce7d5d7f4b273a19e0)で作成したものを使います。
<details><summary>SousekiForm.res</summary>

```ReScript:SousekiForm.res
module Document = {
  @send external getElementById: (Dom.document, string) => Dom.element = "getElementById"
  @send external getElementsByName: (Dom.document, string) => Dom.nodeList = "getElementsByName"
  @get external getBody: Dom.document => Dom.element = "body"
}
module Element = {
  @send external addEventListener: (Dom.element, string, () => unit, bool) => unit = "addEventListener"
  @get external getDataset: Dom.element => Dom.domStringMap = "dataset"
  @get external getValue: Dom.element => string = "value"
  @get external getChecked: Dom.element => bool = "checked"
  @set external setInnerHTML: (Dom.element, string) => unit = "innerHTML"
}
module NodeList = {
  @get external getLength: Dom.nodeList => int = "length"
  @send external item: (Dom.nodeList, int) => Dom.element = "item"
}
module DomStringMap = {
  @get external getCorrectChoice: Dom.domStringMap => string = "correctChoice"
}

// 目的:設問部のinputのNodeをReScriptのlistに格納する
// getListedNodeList : Dom.nodeList -> Dom.element list
let getListedNodeList = nodeList => {
  let length = nodeList->NodeList.getLength

  // 目的: nodeList->item(0)からnodeList->item(i)までのDom.elementをReScriptのlistに格納する
  // loop : int -> Dom.element list
  let rec loop = i =>
    if i < 0 {list{}}
    else {list{nodeList->NodeList.item(i), ...loop(i - 1)}}

  loop(length - 1)
}

/* Js.List.foldRight を使うと require が必要でブラウザ実行がめんどう */
// 目的: initから始めてlstの要素を右から順にfに流し込む
// foldRight : ('a, -> 'b -> 'b) -> 'a list -> 'b
let rec foldRight = (f, lst, init) => switch lst {
| list{} => init
| list{first, ...rest} => f(first, foldRight(f, rest, init))
}

// 目的: 指定された選択肢のvalueを返す
// getAnswer : (Dom.element -> bool) -> Dom.element list -> string
let getAnswer = (lst, f) => foldRight(
  (first, rest_value) => if f(first) {first->Element.getValue} else {rest_value},
  lst,
  ""
)

// 目的: ボタンがクリックされたときの処理
// onBtnClick : unit -> unit
let onBtnClick = () => {
  let listedNodeList = document->Document.getElementsByName("Q1")->getListedNodeList
  let correctAnswer = listedNodeList->getAnswer(e => e->Element.getDataset->DomStringMap.getCorrectChoice == "true")
  let selectedAnswer = listedNodeList->getAnswer(Element.getChecked)
  let judgement = correctAnswer == selectedAnswer ? "正解です！" : "不正解です。"
  let message = "正解は" ++ correctAnswer ++ "。あなたの回答は" ++ selectedAnswer ++ "でした。"
  document->Document.getBody->Element.setInnerHTML(judgement ++ "<br>" ++ message)
}

document->Document.getElementById("btn")->Element.addEventListener("click", onBtnClick, false)
```
</details>
<details><summary>souseki_form.html</summary>

```html:souseki_form.html
<!doctype HTML>
<html lang="ja">
<head>
  <title>漱石先生クイズ</title>
</head>
<body>
  <form>
    <dl>
      <dt>Q1. 漱石先生のデビュー作は？</dt>
      <dd><p>漱石先生のデビュー作は次のうちどれでしょう？</p>
        <p><input type="radio" name="Q1" value="坊っちゃん" checked>坊っちゃん<br>
          <input type="radio" name="Q1" value="吾輩は猫である" data-correct-choice="true">吾輩は猫である<br>
          <input type="radio" name="Q1" value="明暗">明暗</p>
      </dd>
    </dl>
    <button id="btn" type="button">答え合わせ</button>
  </form>
  <script type="module" src="SousekiForm.res.js"></script>
</body>
</html>
```
</details>

# Unit Test
DOMに関係ないところで試験してみます。
`__tests__`ディレクトリに以下のファイルを作成します。
```ReScript:SousekiForm_test.res
open Jest
open SousekiForm

describe("foldRight", () => {
  open Expect

  test("concat", () => {
    expect(foldRight((a, b) => a ++ b, list{"春", "夏", "秋", "冬"}, "")) -> toBe("春夏秋冬")
  })
})
```
# ディレクトリ構成
```
jest-test/
├── rescript.json
├── package.json
├── src/
│   └── SousekiForm.res
│   └── souseki_form.html
└── __tests__/
    └── SousekiForm_test.res
```
# ビルドとテストの実行
```SH:ビルドとテスト
npm run res:build
npm run test
```
# 実行結果
```SH:SH
 FAIL  __tests__/SousekiForm_test.res.js
  ● Test suite failed to run

    The error below may be caused by using the wrong test environment, see https://jestjs.io/docs/configuration#testenvironment-string.
    Consider using the "jsdom" test environment.
    
    ReferenceError: document is not defined
```
失敗しました。
`"jsdom" test environment`を使いましょうと言われています。
# `jsdom`を使えるようにする方法
## `package.json`に書く
```JSON:package.json
  "jest": {
    "testEnvironment": "jsdom"
  }
```
## `jest.config.js`を作成して、そこに書く
```JSON:jest.config.js
module.exports = {
  testEnvironment: 'jsdom'
}
```
## docblockに記述する
```javascript:SousekiForm_test.res.js
/**
 * @jest-environment jsdom
 *
```
この方法が一番よいと思ったんですが、ReScriptでコメントを書いても、JavaScriptにコンパイルする際に消えてしまいます。
残せる良い方法が分からなかったので諦めました。
# 再度、ビルドとテスト
`package.json`に追記する方法を採用しました。
ビルドしてテストします。
```SH
 FAIL  __tests__/SousekiForm_test.res.js
  ● Test suite failed to run

    TypeError: Cannot read properties of null (reading 'addEventListener')

      57 | }
      58 |
    > 59 | document.getElementById("btn").addEventListener("click", onBtnClick, false);
         |                               ^
```
なんでこのエラーが出るのか分からなかったです。考えていたら、分かった気がしました。
1. `SousekiForm_test.res`で`open SouekiForm`としてます
2. そのためコンパイルされた`SousekiForm_test.res.js`に`require("../src/SousekiForm.res.js")`が含まれます
3. `SousekiForm.res.js`のトップレベルに`document.getElementById("btn").addEventListener("click", onBtnClick, false);`があります
4. モジュール読み込み時にこれが実行され「`null`に対して`addEventListner`はできないよ」と言われています

モジュールを読み込む前に`document.getElementById("btn")`が値を持つようにすれば良いです。
# `document.getElementById("btn")`に値を持たせる
```JSON:package.json
  "jest": {
  ...
    "testEnvironmentOptions": {
      "html": "<button id=\"btn\"></button>"
    }
  ...
  }
```
`testEnvironmentOptions`の`html`に`id="btn"`を含む要素を渡しました。[^1]
[^1]:`testEnvironmentOptions`に渡せるオブジェクトが何か不明です。取り敢えず動いたので良いことにしています。
# 実行結果
```SH
 PASS  __tests__/SousekiForm_test.res.js
  foldRight
    ✓ concat (1 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
```
うまくいきました。
# 他の関数もテストする
```ReScript:SousekiForm_test.res
let form = `
  <form>
    <input type="radio" name="Q1" value="坊っちゃん" checked>坊っちゃん
    <input type="radio" name="Q1" value="吾輩は猫である" data-correct-choice="true">吾輩は猫である
    <input type="radio" name="Q1" value="明暗">明暗
  </form>
  `
document->Document.getBody->Element.setInnerHTML(form)

let lst = document->Document.getElementsByName("Q1")
let element1 = lst->NodeList.item(0)
let element2 = lst->NodeList.item(1)
let element3 = lst->NodeList.item(2)

describe("getListedNodeList", () => {
  open Expect

  test("three forms", () => {
    expect(lst->getListedNodeList) -> toEqual(list{element3, element2, element1})
  })
})

describe("getAnswer", () => {
  open Expect

  test("correct answer", () => {
    expect(lst->getListedNodeList->getAnswer(e => e->Element.getDataset->DomStringMap.getCorrectChoice == "true")) -> toBe("吾輩は猫である")
  })

  test("selected answer", () => {
    expect(lst->getListedNodeList->getAnswer(Element.getChecked)) -> toBe("坊っちゃん")
  })
})
```
```SH
 PASS  __tests__/SousekiForm_test.res.js
  foldRight
    ✓ concat (1 ms)
  getListedNodeList
    ✓ three forms
  getAnswer
    ✓ correct answer (1 ms)
    ✓ selected answer

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
```
うまくいきました。
# ブラウザで動作確認
[Live Server](https://marketplace.visualstudio.com/items?itemName=ritwickdey.LiveServer)を起動して、Chromeで確認すると動作はしてました。
ただConsoleにはエラーが表示されます。
```console
SousekiForm.res.js:61 Uncaught 
ReferenceError: exports is not defined
    at SousekiForm.res.js:61:1
```
いまCommon JSフォーマットになるようにコンパイルしているからですね。
# JavaScript module フォーマットにしてテスト
```JSON:rescript.json
  "package-specs": {
    ...
    "module": "esmodule",
    ...
  }
```
```SH:ビルドして実行
 FAIL  __tests__/SousekiForm_test.res.js
  ● Test suite failed to run

    Jest encountered an unexpected token

    Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to support such syntax.

    Out of the box Jest supports Babel, which will be used to transform your files into valid JS based on your Babel configuration.

    By default "node_modules" folder is ignored by transformers.

    Here's what you can do:
     • If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/ecmascript-modules for how to enable it.
     • If you are trying to use TypeScript, see https://jestjs.io/docs/getting-started#using-typescript
     • To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config.
     • If you need a custom transformation specify a "transform" option in your config.
     • If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option.

    You'll find more details and examples of these config options in the docs:
    https://jestjs.io/docs/configuration
    For information about custom transformations, see:
    https://jestjs.io/docs/code-transformation

    Details:

    /Users/segi/Desktop/jest-test/__tests__/SousekiForm_test.res.js:3
    import * as Jest from "@glennsl/rescript-jest/src/jest.res.js";
    ^^^^^^

    SyntaxError: Cannot use import statement outside a module

      at Runtime.createScriptFromCode (node_modules/jest-runtime/build/index.js:1728:14)

Test Suites: 1 failed, 1 total
Tests:       0 total
```
またエラーです。
# Troubleshooting
解決方法が[Troubleshooting](https://github.com/glennsl/rescript-jest?tab=readme-ov-file#troubleshooting)にあったので、やってみます。
`npm install --save-dev esbuild-jest esbuild`
`npx rescript build -with-deps`
```JSON:package.json
"jest": {
  ...
  "transform": {
    "^.+\\.jsx?$": "esbuild-jest"
  },
  "transformIgnorePatterns": ["<rootDir>/node_modules/(?!(rescript|@glennsl/rescript-jest)/)"]
  ...
}
```
# 再々度、ビルドとテスト
```SH
 PASS  __tests__/SousekiForm_test.res.js
  foldRight
    ✓ concat (1 ms)
  getListedNodeList
    ✓ three forms
  getAnswer
    ✓ correct answer (1 ms)
    ✓ selected answer

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
```
うまくいきました。[^2]
Chromeで実行してもエラーはでません。
[^2]:書かれていることを試してみて、すんなり動くことは珍しいので、驚きました。
# 関数を修正する
`getAnswer(list<Dom.element>, Dom.element => bool)`で`foldRight`を使っていますが「`find`の方が良いのかな」と思ったので修正します。
```ReScript:SousekiForm.res
let getAnswer = (lst, f) => switch List.find(lst, f) {
| Some(e) => e->Element.getValue
| None => ""
}
```
# ビルド、テスト、エラー、修正、ビルド、テスト、成功
```SH
 FAIL  __tests__/SousekiForm_test.res.js
  ● Test suite failed to run

    Jest encountered an unexpected token
```
```SH
Details:

    /Users/segi/Desktop/jest-test/node_modules/@rescript/core/src/Core__List.res.js:3
    import * as Caml_option from "rescript/lib/es6/caml_option.js";
    ^^^^^^

    SyntaxError: Cannot use import statement outside a module
```
同じようなエラーがでました。
`@rescript`配下がtransform対象になっていないからかも知れません。
```JSON:package.json
"transformIgnorePatterns": ["<rootDir>/node_modules/(?!(@rescript|rescript|@glennsl/rescript-jest)/)"]  
```
`@rescript`を追加して、ビルド、試験すると成功しました。
```SH
PASS  __tests__/SousekiForm_test.res.js
```
# 再びブラウザで動作確認
```console
Uncaught TypeError: Failed to resolve module specifier "@rescript/core/src/Core__List.res.js". Relative references must start with either "/", "./", or "../".Understand this error
```
エラーが出て動作しません。
モジュールがうまく読み込めないようです。
これは「バンドラ」というものの出番なのでしょうか。
次はバンドラを勉強します。
# 関数の再修正
```ReScript:SousekiForm.res
let rec getAnswer = (lst, f) => switch lst {
| list{} => ""
| list{first, ...rest} => if f(first) {first->Element.getValue} else {getAnswer(rest, f)}
}
```
`List.find`を使ったためにモジュール利用になったので、高階関数を利用しない再帰で書き直しました。
これで`foldRight`も不要になったので、消したものを完成版としてます。