Tinymist CLIでTypstコードの自動テストをする
はじめに
Typstはマークアップ言語であると同時にスクリプト言語でもあります。従来の組版システムに比べてより自由な表現がしやすくなった反面、その分コードは複雑になりやすく、品質の維持が難しくなることがあります。複雑なTypstコードはソフトウェアとしての性質が強く、継続的な保守のための品質を保つ仕組みが重要です。そこで、本記事ではTinymist CLIを利用してTypstコードに自動テストを導入する方法を紹介します。
Tinymistについて
TinymistはTypstの統合言語サービスです。具体的には、Typstの分析ツール、言語サーバー、プレビューなどの様々な機能を提供しています。
同名のVS Code Extensionが有名だと思われますが、TinymistはCLIからも利用できます。
Tinymist CLIの導入
リポジトリのReleasesからバイナリが入手可能です。
導入にはaqua, mise, Nixなどを利用すると便利です。例えば、miseを利用している場合は次のコマンドで導入できます。
mise use tinymist
単体テストの実行
説明のための題材として、ページ番号の配列を受け取り、ページ番号の範囲を文字列として返す関数を考えます。例えば、(1,2,3,5,6,8)という配列を与えた場合、"1-3,5-6,8"という文字列を返す関数です。
#let compress-pages(
pages,
sep: ",",
dash: "-",
) = {
let filtered = pages.filter(p => p >= 1)
let sorted = filtered.sorted()
let unique = sorted
.fold((out: (), last: none), (acc, p) => {
if acc.last == none or p != acc.last {
(out: acc.out + (p,), last: p)
} else {
acc
}
})
.out
if unique.len() == 0 {
""
} else {
let state = unique.fold((ranges: (), start: none, end: none), (acc, p) => {
if acc.start == none {
(ranges: acc.ranges, start: p, end: p)
} else if p == acc.end + 1 {
(ranges: acc.ranges, start: acc.start, end: p)
} else {
(
ranges: acc.ranges + ((start: acc.start, end: acc.end),),
start: p,
end: p,
)
}
})
let ranges = state.ranges + ((start: state.start, end: state.end),)
ranges
.map(r => if r.start == r.end { str(r.start) } else {
str(r.start) + dash + str(r.end)
})
.join(sep)
}
}
この関数に対してテストを実行してみましょう。Tinymist CLIでは次のコマンドでテストを実行できます。ここで、--coverageはコードカバレッジを表示するためのオプションです。
tinymist test main.typ --coverage
Info Found 0 tests and 0 examples
Info Written coverage to target/coverage.json ...
Cov Coverage Summary 0/0 (100.00%)
Info All test cases passed...
Tinymistでは、test-*という名前の関数がテストとして認識されます。この時点ではテストケースが存在せず、関数内のすべての条件分岐が実行されていないため、コードカバレッジは0/0となっています。ここで、次のようなテストケースを追加します。
#let test-compress-pages() = {
assert(compress-pages((1, 2, 3, 5, 6, 8)) == "1-3,5-6,8")
}
Info Found 1 tests and 0 examples
Running test(test-compress-pages)
Passed test(test-compress-pages)
Info Written coverage to target/coverage.json ...
Cov Coverage Summary 28/32 (87.50%)
Info All test cases passed...
テストを1件追加したことで、コードカバレッジが87.50%まで向上しました。計測結果はtarget/coverage.jsonに保存されます。
{
"/path/to/main.typ": [
/* `"executed": true`は省略 */
{
"executed": false,
"location": { "start": { "line": 12, "character": 13 }, "end": { "line": 12, "character": 14 } }
},
{
"executed": false,
"location": { "start": { "line": 14, "character": 6 }, "end": { "line": 14, "character": 7 } }
}
]
}
網羅されていないケースを確認し、さらにいくつかのテストケースを追加します。
#let test-compress-pages() = {
assert(compress-pages((1, 2, 3, 5, 6, 8)) == "1-3,5-6,8")
assert(compress-pages((2, 2)) == "2")
assert(compress-pages(()) == "")
}
Info Found 1 tests and 0 examples
Running test(test-compress-pages)
Passed test(test-compress-pages)
Info Written coverage to target/coverage.json ...
Cov Coverage Summary 32/32 (100.00%)
Info All test cases passed...
テストケースを網羅したことで、コードカバレッジが100%になりました。実際にテストを書く際には、複数のケースを細かく分けて記述すると、失敗時の原因特定やユースケースの把握がしやすくなります。最終的に次のようなテストケースが考えられます。
#let test-compress-pages() = {
// Empty
assert(compress-pages(()) == "")
// Single page
assert(compress-pages((5,)) == "5")
// Adjacent pages
assert(compress-pages((1, 2)) == "1-2")
// Gapped pages
assert(compress-pages((1, 3)) == "1,3")
// Multiple ranges
assert(compress-pages((1, 2, 3, 5, 7, 8, 9)) == "1-3,5,7-9")
// Unsorted duplicates
assert(compress-pages((3, 1, 2, 2)) == "1-3")
// Drops nonpositive values
assert(compress-pages((0, -2, 4)) == "4")
}
Tinymistではこのほかにも、ベンチマークテスト用のbench-*やパニックテスト用のpanic-on-*がサポートされています。さらに、ファイル名をexample-*.typとすることで、PNGやHTMLエクスポートに対するスナップショットテストが実行できます。詳細はTinymistのドキュメントを参照してください。
おわりに
本記事では、Tinymist CLIを利用してTypstコードに自動テストを導入する方法を紹介しました。Tinymistは発展途上のプロジェクトであり、Typstでの開発や保守に役立つ強力なツールとして、今後もますます期待されます。ぜひTinymistを活用して、Typstプロジェクトの品質向上に役立ててください。
Discussion