0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ReScriptでJestを使う DOM編

Posted at

準備

ReScriptでJestを使うを参考に設定を整えます。
npm create rescript-app@latest
npm install --save-dev @glennsl/rescript-jest
mkdir __tests__

rescript.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"
  ]
}
package.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"
  }
}

Unit Testにかけるファイル

ReScriptでDOMを扱うで作成したものを使います。

SousekiForm.res
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)
souseki_form.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>

Unit Test

DOMに関係ないところで試験してみます。
__tests__ディレクトリに以下のファイルを作成します。

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

ビルドとテストの実行

ビルドとテスト
npm run res:build
npm run test

実行結果

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に書く

package.json
  "jest": {
    "testEnvironment": "jsdom"
  }

jest.config.jsを作成して、そこに書く

jest.config.js
module.exports = {
  testEnvironment: 'jsdom'
}

docblockに記述する

SousekiForm_test.res.js
/**
 * @jest-environment jsdom
 *

この方法が一番よいと思ったんですが、ReScriptでコメントを書いても、JavaScriptにコンパイルする際に消えてしまいます。
残せる良い方法が分からなかったので諦めました。

再度、ビルドとテスト

package.jsonに追記する方法を採用しました。
ビルドしてテストします。

 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.resopen SouekiFormとしてます
  2. そのためコンパイルされたSousekiForm_test.res.jsrequire("../src/SousekiForm.res.js")が含まれます
  3. SousekiForm.res.jsのトップレベルにdocument.getElementById("btn").addEventListener("click", onBtnClick, false);があります
  4. モジュール読み込み時にこれが実行され「nullに対してaddEventListnerはできないよ」と言われています

モジュールを読み込む前にdocument.getElementById("btn")が値を持つようにすれば良いです。

document.getElementById("btn")に値を持たせる

package.json
  "jest": {
  ...
    "testEnvironmentOptions": {
      "html": "<button id=\"btn\"></button>"
    }
  ...
  }

testEnvironmentOptionshtmlid="btn"を含む要素を渡しました。1

実行結果

 PASS  __tests__/SousekiForm_test.res.js
  foldRight
    ✓ concat (1 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total

うまくいきました。

他の関数もテストする

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("坊っちゃん")
  })
})
 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を起動して、Chromeで確認すると動作はしてました。
ただConsoleにはエラーが表示されます。

SousekiForm.res.js:61 Uncaught 
ReferenceError: exports is not defined
    at SousekiForm.res.js:61:1

いまCommon JSフォーマットになるようにコンパイルしているからですね。

JavaScript module フォーマットにしてテスト

rescript.json
  "package-specs": {
    ...
    "module": "esmodule",
    ...
  }
ビルドして実行
 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にあったので、やってみます。
npm install --save-dev esbuild-jest esbuild
npx rescript build -with-deps

package.json
"jest": {
  ...
  "transform": {
    "^.+\\.jsx?$": "esbuild-jest"
  },
  "transformIgnorePatterns": ["<rootDir>/node_modules/(?!(rescript|@glennsl/rescript-jest)/)"]
  ...
}

再々度、ビルドとテスト

 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で実行してもエラーはでません。

関数を修正する

getAnswer(list<Dom.element>, Dom.element => bool)foldRightを使っていますが「findの方が良いのかな」と思ったので修正します。

SousekiForm.res
let getAnswer = (lst, f) => switch List.find(lst, f) {
| Some(e) => e->Element.getValue
| None => ""
}

ビルド、テスト、エラー、修正、ビルド、テスト、成功

 FAIL  __tests__/SousekiForm_test.res.js
  ● Test suite failed to run

    Jest encountered an unexpected token
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対象になっていないからかも知れません。

package.json
"transformIgnorePatterns": ["<rootDir>/node_modules/(?!(@rescript|rescript|@glennsl/rescript-jest)/)"]  

@rescriptを追加して、ビルド、試験すると成功しました。

PASS  __tests__/SousekiForm_test.res.js

再びブラウザで動作確認

Uncaught TypeError: Failed to resolve module specifier "@rescript/core/src/Core__List.res.js". Relative references must start with either "/", "./", or "../".Understand this error

エラーが出て動作しません。
モジュールがうまく読み込めないようです。
これは「バンドラ」というものの出番なのでしょうか。
次はバンドラを勉強します。

関数の再修正

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も不要になったので、消したものを完成版としてます。

  1. testEnvironmentOptionsに渡せるオブジェクトが何か不明です。取り敢えず動いたので良いことにしています。

  2. 書かれていることを試してみて、すんなり動くことは珍しいので、驚きました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?