SATySFi Advent Calendar 2022 14日目の記事です。
SATySFi でもテストしたいよなーと思って2年くらい前につくりかけで放置しちゃってる(えっもうそんなに経ってるんだ、放置しててごめん……) satysfi-test というライブラリがあります。これをネタにして SATySFi でテストを書くときの諸々についてちょっと書こうかなという記事です。
注意: satysfi-test は開発中のライブラリなので一般に使用できるレベルにはなっていません!
何をテストするか
SATySFiもプログラミング言語なんだから当然テストは書きたいですよね。でもSATySFiで書くテストって具体的にどんなものになるんでしょうか。ちょっと考えてみると以下のように「どのような出力をテストするか」で二つに分類できるはずだという考えに至ります。
- inline-boxes, block-boxes, math, document などの組版のためのデータ
- int, string, レコード型などの組版に直接関与しないデータ
SATySFi が組版システムであることを考えると1に対するテストを書きたいところですが、今のところSATySFiではこれら類のデータについて解析をしたり描画結果の比較を行ったりするAPIはないため、少なくともSATySFiでそういうテストを書くのは難しいんじゃないかなーというのが現状です。というわけで今回は主に1、つまり組版とは関係ない部分についてのテストについて話します。 satysfi-test もそのような用途を想定したものになっています(組版結果のテストについては昔書いた「SATySFiの組版結果をテストする」 を参照するといいかもしれない)。
satysfi-test でテストを書く
ユニットテストをどのように記述するかはフレームワークによりかなり差がありますが、satysfi-test では elm-test (公式ドキュメント) のAPIを参考にしました。この選択をした理由としては elm-test が以下の要求を満たしていたからです。
- SATySFi はリフレクションや例外が使えない関数型言語で、そのような状況で無理なく実装できるAPIである必要があった。
- Jest みたいに describe~it~ とテストを記述したかった(これは個人の趣味です。こういう describe でテストを書くやつってどこが発祥なんでしょうね)。
実は satysfi-base のテストを satysfi-test で書こうとしたやつ(issue)があるので実際どんな感じか見てみましょう(⚠️ これも書きかけで放置してるんだ、すまん……)
@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)。
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)。
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 のテストのトップレベルのファイルはこうなっています(ソース)。
@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を生成したくないですし)。というわけで以下のコマンドでテストを実行できます。
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 は標準でユニットテスト用のライブラリを提供するっぽい?
というわけでこっちを使った方が無難でしょうね。(まあユニットテスト用ライブラリなんていくらあっても困らないので satysfi-test はちゃんと完成させようと思います、いえあんな放置しといてどの口が言うんだという感じですが……)
はーい、というわけで皆さまよい年末を!