SATySFiでテストを書きたい

[SATySFi Advent Calendar 2022](https://adventar.org/calendars/7515) 14日目の記事です。

SATySFi でもテストしたいよなーと思って2年くらい前につくりかけで放置しちゃってる(えっもうそんなに経ってるんだ、放置しててごめん……) [satysfi-test](https://github.com/zeptometer/satysfi-test) というライブラリがあります。これをネタにして SATySFi でテストを書くときの諸々についてちょっと書こうかなという記事です。

**注意: satysfi-test は開発中のライブラリなので一般に使用できるレベルにはなっていません！**

# 何をテストするか
SATySFiもプログラミング言語なんだから当然テストは書きたいですよね。でもSATySFiで書くテストって具体的にどんなものになるんでしょうか。ちょっと考えてみると以下のように「どのような出力をテストするか」で二つに分類できるはずだという考えに至ります。

1. inline-boxes, block-boxes, math, document などの組版のためのデータ
2. int, string, レコード型などの組版に直接関与しないデータ

SATySFi が組版システムであることを考えると1に対するテストを書きたいところですが、今のところSATySFiではこれら類のデータについて解析をしたり描画結果の比較を行ったりするAPIはないため、少なくともSATySFiでそういうテストを書くのは難しいんじゃないかなーというのが現状です。**というわけで今回は主に1、つまり組版とは関係ない部分についてのテストについて話します。 satysfi-test もそのような用途を想定したものになっています**(組版結果のテストについては昔書いた[「SATySFiの組版結果をテストする」](https://qiita.com/zptmtr/items/59b485c47edfbca67c60) を参照するといいかもしれない)。

# satysfi-test でテストを書く
ユニットテストをどのように記述するかはフレームワークによりかなり差がありますが、satysfi-test では elm-test ([公式ドキュメント](https://elmprogramming.com/easy-to-test.html)) のAPIを参考にしました。この選択をした理由としては elm-test が以下の要求を満たしていたからです。

1. SATySFi はリフレクションや例外が使えない関数型言語で、そのような状況で無理なく実装できるAPIである必要があった。
2. Jest みたいに describe~it~  とテストを記述したかった(これは個人の趣味です。こういう describe でテストを書くやつってどこが発祥なんでしょうね)。

実は satysfi-base のテストを satysfi-test で書こうとしたやつ([issue](https://github.com/nyuichi/satysfi-base/issues/142))があるので実際どんな感じか見てみましょう(⚠️ これも書きかけで放置してるんだ、すまん……)

```ocaml
@require: test/test
@require: test/expect

@import: ../src/char
@import: ../src/eq
@import: ../src/list-ext
@import: ../src/ord
@import: ../src/string

let string-test-cases = open Test in
  describe `String module` [
    it `check equality of strings` (fun () -> (
      Eq.equal String.eq `abc` `abc`
      && not Eq.equal String.eq `abc` `abd`
      |> Expect.is-true
    ));
    it `concatenate strings` (fun () -> (
      String.concat [`a`; `b`; `c`]
      |> string-same `abc`
      |> Expect.is-true
    ));
    it `check emptiness of string` (fun () -> (
      String.is-empty ` `
      && (not String.is-empty `hi`)
      |> Expect.is-true
    ));
    it `split string with a given delimiter` (fun () -> (
      let delimiter = Char.make `;` in
      `spam;ham;eggs;`
      |> String.split-by delimiter
      |> Eq.equal (List.eq String.eq) [`spam`; `ham`; `eggs`; ` `]
      |> Expect.is-true
    ));
    % and so on ...
  ]
```

こんな感じで satysfi-test では Jest みたいな感じで `describe`~`it` か `test` でテストケースを書いていきます。具体的なシグネチャは以下のような感じです([src/test.satyg](https://github.com/zeptometer/satysfi-test/blob/c065038579b9610aaff257764a8b7a919d56be7e/src/test.satyg))。

```ocaml
module Test : sig

	val test : string -> (unit -> ExpectResult.t) -> TestCase.t
	val it : string -> (unit -> ExpectResult.t) -> TestCase.t
	val describe : string -> TestCase.t list -> TestCase.t
	val run : TestCase.t -> string

end
```

`TestCase.t` がテストケースの集合を表す型です(`TestCases.t` の方がいいのでは??)。`describe`~`it` にせよ `test` にせよテストの説明と実際のテスト(= `ExpectResult.t`を返す関数)を渡す必要があります。では `ExpectResult.t` が何かと言うと以下のようになります([src/expect.satyg](https://github.com/zeptometer/satysfi-test/blob/c065038579b9610aaff257764a8b7a919d56be7e/src/expect.satyg))。

```ocaml
module Expect : sig

  val always-pass : ExpectResult.t
  val always-fail : ExpectResult.t

  val is-true : bool -> ExpectResult.t
  val is-false : bool -> ExpectResult.t

end
```

今のところは boolean に関する assertion だけです。シンプルですね～

テストを実行するためには `Test.run` を使います。例えば satysfi-base のテストのトップレベルのファイルはこうなっています([ソース](https://github.com/nyuichi/satysfi-base/blob/29996cc2079d616b3cfd3f854720c7a19135edae/test/main.test.saty))。

```ocaml
@require: test/test
@require: test/expect

@import: ../src/eq
@import: ../src/list-ext
@import: ../src/ref
@import: ../src/string

@import: array.test
@import: int.test
@import: list.test
@import: ref.test
@import: regex.test
@import: string.test

open Test in
describe `base` [
  ref-test-cases;
  string-test-cases;
  list-test-cases;
  array-test-cases;
  regex-test-cases;
  int-test-cases;
]
|> run
```

トップレベルのファイルが document でなくて string を返す(`Test.run` のシグネチャを見よ！)のはテキストモードを想定しているからです。今のところ組版関係の機能をテストできないのでテキストモードで十分なんですね(pdfを生成したくないですし)。というわけで以下のコマンドでテストを実行できます。

```bash
satysfi --text-mode "text" main.test.saty -o report.txt
```

Test report はこんな感じで出力できます。それっぽいね！

```
Test summary : 48 run, 48 success, 0 fail

Test run report
- base
  - Ref module
    - make 1 contains 1 -> PASS
    - set overrides the value of ref -> PASS
    - swap two refs -> PASS
    - set temporarily value of ref -> PASS
  - String module
    - check equality of strings -> PASS
    - concatenate strings -> PASS
    - check emptiness of string -> PASS
    - split string with a given delimiter -> PASS
    - convert a string to a list of characters -> PASS
    - compare strings with lexicographical order -> PASS
...
```

# 余談
ところで、satysfi-test を書いていて面倒くさいなあと思ったのが、例えば ``|> Eq.equal (List.eq String.eq) [`spam`; `ham`; `eggs`; ` `]`` のようにわざわざ型に応じた等価演算子を明示的に渡さないといけない点です。これを型に応じて勝手に等価演算子を選んでくれるようにしてくれると便利かなあと思っています。

例えば Haskell の Type Class 的なものがあると `Expect.equals` のようなアサーションがいい感じに作れるんですよね。 `Expect.equals`  の型を `(Eq a, Show a) => a -> a -> ExpectResult.t` のようにすると `Expect.equals`  の引数を適当な等価関数で比較した上で失敗した時に適当にstringifyして "expected xxx, but got yyy" みたいなメッセージを自動で生成できるわけです。

こういった機能を実現するにはさっきも挙げたように Haskell でいうtype classesとか、Scala でいう implicit(context) parameters とか、Rust でいう trait とかがあるといいんですよね。SATySFi だとどういう設計にするといいんでしょうね 🤔

# 余談2
satysfi 0.1.0 は標準でユニットテスト用のライブラリを提供するっぽい？

https://twitter.com/bd_gfngfn/status/1593267005150220288

というわけでこっちを使った方が無難でしょうね。(まあユニットテスト用ライブラリなんていくらあっても困らないので satysfi-test はちゃんと完成させようと思います、いえあんな放置しといてどの口が言うんだという感じですが……)

はーい、というわけで皆さまよい年末を！