Skip to content

Instantly share code, notes, and snippets.

@podhmo
Last active June 17, 2025 06:54
Show Gist options
  • Save podhmo/3dec70e42f3f3ff3c5a795b9a1e64d55 to your computer and use it in GitHub Desktop.
Save podhmo/3dec70e42f3f3ff3c5a795b9a1e64d55 to your computer and use it in GitHub Desktop.
ppx_derivingとテストコードの書き方

OCaml Alcotest入門:ゼロから学ぶ実践的テストの書き方

この記事では、OCamlのテストフレームワークであるAlcotestの使い方を、ゼロから丁寧に解説します。OCamlの基本的な文法は知っているけれど、テストの書き方はまだよく知らないという方を対象に、環境構築から実践的なテストコードの作成までをガイドします。

第1章:はじめに - なぜテストを書くのか

1-1. Alcotestへようこそ

Alcotestは、OCamlのための軽量で使いやすいテストフレームワークです。テスト結果をターミナルにカラフルに表示してくれるため、どこが成功し、どこが失敗したのかが一目でわかります。

そもそも、なぜ私たちはテストを書くのでしょうか?

  • バグの早期発見: コードを変更した際に、意図しない部分が壊れていないか(デグレードしていないか)を即座に検知できます。
  • リファクタリングの安心感: テストがあれば、コードの内部構造をより良いものに改善(リファクタリング)する際に、「振る舞いが変わっていないこと」を保証できます。
  • 生きたドキュメント: テストコードは、その関数が「どのように使われるべきか」を示す具体例になります。仕様書よりも正確で、常に最新の状態に保たれる「生きたドキュメント」です。

Alcotestを学ぶことで、あなたのOCamlプログラミングはより堅牢で、メンテナンスしやすいものになるでしょう。

1-2. テスト駆動開発(TDD)の簡単な紹介

テストの書き方として、テスト駆動開発(Test-Driven Development, TDD) という有名な手法があります。これは、プロダクトのコードを書く前に、まずテストコードを書くという開発スタイルです。

  1. Red: まず「失敗する」テストを書きます。まだ機能が存在しないので、当然テストは失敗します(赤色で表示されます)。
  2. Green: テストを「成功させる」ための最小限のコードを書きます。テストが成功すればOKです(緑色で表示されます)。
  3. Refactor: コードをより良い設計に整理します。このとき、テストが成功し続けることを確認しながら進めます。

このサイクルを繰り返すことで、常にテストに守られた状態で開発を進めることができます。このチュートリアルでは厳密なTDDは行いませんが、「まずテストを考える」という意識は、良いコードを書く上で非常に役立ちます。

1-3. チュートリアルのゴール

このチュートリアルを終える頃には、あなたは以下のスキルを身につけているはずです。

  • dune を使ってAlcotestを実行する環境をセットアップできる。
  • Alcotestの基本的な概念(testable, test_caseなど)を理解し、説明できる。
  • 基本的な関数から、optionresult、自分で定義した型まで、様々なケースのテストを書ける。
  • ppx_derivingを使って、効率的にテストコードを作成できる。

それでは、早速始めていきましょう!

第2章:最初のテスト - 環境構築と「Hello, Alcotest!」

2-1. 必要なツールの準備 (opam, dune)

まず、OCamlのパッケージマネージャopamを使って、ビルドシステムのdunealcotestをインストールします。ターミナルで以下のコマンドを実行してください。

opam install dune alcotest

2-2. プロジェクトのセットアップとAlcotestの導入

次に、duneを使って新しいプロジェクトを作成します。

dune init proj my_project
cd my_project

これにより、以下のようなファイルとディレクトリが作成されます。

  • dune-project: プロジェクト全体の設定ファイル
  • src/: ライブラリのソースコードを置くディレクトリ
  • test/: テストコードを置くディレクトリ

次に、テストでalcotestライブラリを使えるように設定します。test/duneというファイルを以下のように編集してください。

test/dune

(test
 (name my_project_test)
 (libraries my_project alcotest))
  • (test ...): これがテストの設定であることを示します。
  • name: テストの名前です。
  • libraries: このテストが依存するライブラリを指定します。my_projectsrcディレクトリのコード)とalcotestを使えるようにします。

2-3. 最初のテストコードを書いてみよう

準備が整いました。いよいよ最初のテストコードを書いてみましょう。test/my_project_test.mlを以下のように編集します。

test/my_project_test.ml

(* 1 + 1 = 2 であることをチェックする単純なテスト *)
let test_simple_addition () =
  Alcotest.(check int) "1 + 1 = 2" 2 (1 + 1)

(* 実行するテストケースのリスト *)
let () =
  Alcotest.run "My first test suite" [
    ("Simple addition", [
      Alcotest.test_case "1 + 1" `Quick test_simple_addition
    ])
  ]

コードを少しだけ見てみましょう。

  • test_simple_addition: ひとつのテスト本体です。Alcotest.checkを使って、期待する値(2)と実際の値(1 + 1)が等しいか確認しています。Alcotest.intは「整数を比較する」ための道具です。
  • Alcotest.run: テスト全体を実行する関数です。テストをグループ化して、まとめて実行します。

それでは、ターミナルでテストを実行してみましょう。

dune runtest

以下のようなカラフルな出力が表示されれば成功です!

Running "My first test suite"...............
[OK] Simple addition ▸ 1 + 1.
The full test results are available in `.../_build/default/_tests/my_project_test`.
Test Successful in ...s. 1 test passed.

緑色の[OK]が表示されましたね。これが、テストが成功した証です。おめでとうございます!これがAlcotestの第一歩です。

第3章:Alcotestの基本要素を理解する

先ほどのコードで何となく動かせてしまいましたが、ここでAlcotestを構成する重要な要素をしっかり理解しておきましょう。

3-1. testable: 値を比較するための「ものさし」

Alcotest.checkの第一引数に渡したAlcotest.intは、testableと呼ばれるものです。これは、「2つの値をどのように比較し(equal)、テストが失敗した時にどのように表示するか(pp)」 を定義した「ものさし」のようなものです。

Alcotestには、基本的な型のためにtestableが予め用意されています。

  • Alcotest.int
  • Alcotest.string
  • Alcotest.bool
  • Alcotest.float など。後の章で、自分でtestableを作る方法も学びます。

3-2. test_case: ひとつのテストの単位

Alcotest.test_caseは、ひとつの独立したテスト項目を表します。

Alcotest.test_case "テスト名" `Quick 関数
  • 第一引数 ("テスト名"): テストの内容を説明する文字列です。失敗した時にこの名前が表示されるので、分かりやすい名前をつけましょう。
  • 第二引数 (`Quick): テストの実行速度を示すラベルです。通常は`Quickで問題ありません。他に`Slowなどがあります。
  • 第三引数 (関数): 実際のテストロジックを含む関数(unit -> unit型)です。この中でAlcotest.checkなどを呼び出します。

3-3. テストスイート: テストをまとめる

テストが増えてくると、関連するテストをグループ化したくなります。そのための仕組みがテストスイートです。Alcotestでは、これは単なる(string * 'a test_case list)型のリストです。

let suite_name = "算術演算のテスト"
let test_cases = [
  Alcotest.test_case "足し算" `Quick test_addition;
  Alcotest.test_case "引き算" `Quick test_subtraction;
]

let arithmetic_suite = (suite_name, test_cases)

このように、("スイート名", [テストケースのリスト])というタプルの形で定義します。

3-4. Alcotest.run: すべてを動かす

Alcotest.runは、定義したテストスイートを実行するためのエントリーポイント(入り口)です。

Alcotest.run "テスト全体の名前" [スイート1; スイート2; ...]

第2章のコードは、これらの要素から構成されていました。

(* test_caseの本体 *)
let test_simple_addition () =
  Alcotest.(check int) "1 + 1 = 2" 2 (1 + 1) (* check関数とtestable *)

(* エントリーポイント *)
let () =
  Alcotest.run "My first test suite" [ (* テスト全体の名前 *)
    ("Simple addition", [ (* テストスイート *)
      Alcotest.test_case "1 + 1" `Quick test_simple_addition (* test_case *)
    ])
  ]

これらの基本要素を組み合わせることで、あらゆるテストを構成していくことができます。

第4章:実践的なテストを書く

基本を理解したところで、次は実際のアプリケーションコードをテストする方法を学びましょう。

4-1. 基本的な関数のテスト(int, stringなど)

まず、src/my_project.mlに関数を追加します。このファイルはduneによって自動的にMy_projectというモジュール名で認識されます。

src/my_project.ml

let add x y = x + y

let greet name = "Hello, " ^ name ^ "!"

次に、test/my_project_test.mlを修正して、これらの関数をテストします。

test/my_project_test.ml

(* My_projectモジュールの関数をテストで使えるようにする *)
open My_project

let test_add () =
  Alcotest.(check int) "add 2 3" 5 (add 2 3);
  Alcotest.(check int) "add 0 0" 0 (add 0 0)

let test_greet () =
  Alcotest.(check string) "greet OCaml" "Hello, OCaml!" (greet "OCaml")

let () =
  Alcotest.run "My_project tests" [
    ("Utils", [
      Alcotest.test_case "add function" `Quick test_add;
      Alcotest.test_case "greet function" `Quick test_greet;
    ])
  ]

Alcotest.checkの使い方を詳しく見てみましょう。 Alcotest.check (ものさし) "メッセージ" (期待する値) (実際の値) という順番で引数を渡します。

dune runtestを実行すると、2つのテストが成功することが確認できます。

4-2. OCamlらしい型のテスト (option, result)

OCamlではoption型やresult型が頻繁に使われます。Alcotestはこれらの型をテストするための便利なtestableを提供しています。これらは他のtestableを引数に取る「高階testable」です。

src/my_project.mlに、ゼロ除算を避ける安全な割り算関数を追加しましょう。

src/my_project.ml (追記)

let safe_div x y =
  if y = 0 then None else Some (x / y)

この関数をテストします。Alcotest.optionを使い、inttestableを渡してint option型のためのtestableを作ります。

test/my_project_test.ml (追記)

let test_safe_div () =
  let int_option_testable = Alcotest.(option int) in
  Alcotest.(check int_option_testable) "10 / 2" (Some 5) (safe_div 10 2);
  Alcotest.(check int_option_testable) "10 / 0" None (safe_div 10 0)

(* let () = ... のリストに以下を追加 *)
(* Alcotest.test_case "greet function" ... の後にカンマを忘れずに *)
, Alcotest.test_case "safe_div function" `Quick test_safe_div

Alcotest.(option int)は、「中身がintであるoption型」を比較するためのtestableを生成します。同様にresult型もAlcotest.(result ok_testable error_testable)のようにテストできます。

4-3. 自分で定義した型(レコード、ヴァリアント)のテスト - 手書き編

次に、自分で定義したuser型をテストしてみましょう。まず、この作業がどれだけ大変かを知るために、手動でtestableを作成します。

src/my_project.mluser型を定義します。

src/my_project.ml (追記)

type user = {
  id: int;
  name: string;
}

let find_user id =
  if id = 1 then Some { id = 1; name = "Alice" }
  else None

このuser型をテストするためには、user型用のtestableが必要です。test/my_project_test.mlで定義しましょう。

test/my_project_test.ml (追記)

(* user型のためのtestableを手で書く *)
let user_testable =
  let pp_user fmt { id; name } =
    Format.fprintf fmt "{ id = %d; name = %s }" id name
  in
  let equal_user u1 u2 =
    u1.id = u2.id && u1.name = u2.name
  in
  Alcotest.testable pp_user equal_user

let test_find_user () =
  let user_option_testable = Alcotest.(option user_testable) in
  let expected_user = Some { id = 1; name = "Alice" } in
  Alcotest.(check user_option_testable) "find user 1" expected_user (find_user 1);
  Alcotest.(check user_option_testable) "find user 2" None (find_user 2)

(* let () = ... のリストに以下を追加 *)
, Alcotest.test_case "find_user function" `Quick test_find_user

pp_user(表示用関数)とequal_user(比較用関数)を自分で定義し、Alcotest.testableに渡してuser_testableを作成しました。

これは動きますが、user型にフィールドが増えるたびにppequalの両方を修正する必要があり、非常に面倒で間違いやすいです。もっと良い方法はないのでしょうか?

4-4. ppx_derivingでテストを効率化する

ここで登場するのがppx_derivingです。これは、型定義から特定の関数(ppequalなど)を自動生成してくれる魔法のようなツールです。

まず、ppx_derivingをインストールします。

opam install ppx_deriving

次に、duneファイルにppx_derivingを使う設定を追加します。src/dunetest/duneの両方に設定が必要です。

src/dune

(library
 (name my_project)
 (preprocess (pps ppx_deriving.show)))

test/dune

(test
 (name my_project_test)
 (libraries my_project alcotest)
 (preprocess (pps ppx_deriving.show)))

ppspreprocessor specificationの略です。これで準備は完了です。

src/my_project.mluser型の定義に[@@deriving show, eq]という一行を追加します。

src/my_project.ml (user型の定義を修正)

type user = {
  id: int;
  name: string;
} [@@deriving show, eq] (* これを追加! *)
  • [@@deriving show]: pp_userという名前の表示用関数を自動生成します。
  • [@@deriving eq]: equal_userという名前の比較用関数を自動生成します。

これだけで、先ほど手で書いたpp_userequal_userが自動的に作られます! test/my_project_test.mltestable定義を、この自動生成された関数を使うように書き換えましょう。

test/my_project_test.ml (user_testableの定義を修正)

(* ppx_derivingで自動生成されたpp_userとequal_userを使う *)
let user_testable = Alcotest.testable pp_user equal_user

これだけです!手書きの定義はもう必要ありません。コードが劇的にシンプルになり、メンテナンス性も向上しました。

現実のプロジェクトでは、このようにppx_derivingを活用するのが一般的です。

第5章:一歩進んだ使い方

5-1. 失敗を期待するテスト (check_raises)

関数が意図通りに例外を発生させることを確認したい場合もあります。Alcotest.check_raisesを使いましょう。

例として、ゼロ除算でDivision_by_zero例外を投げる関数をテストします。

test/my_project_test.ml (追記)

let test_division_by_zero () =
  Alcotest.check_raises
    "division by zero"
    (Division_by_zero)
    (fun () -> ignore (1 / 0))

(* let () = ... のリストに以下を追加 *)
, Alcotest.test_case "raises Division_by_zero" `Quick test_division_by_zero

check_raises "メッセージ" (期待する例外) (例外を発生させる関数) のように使います。

5-2. テストに名前をつけて管理しやすくする

テストが増えてきたら、スイートを分割して管理しやすくしましょう。Alcotest.runの第二引数はスイートのリストなので、複数のスイートを渡すことができます。

test/my_project_test.ml (let () = ... の部分を修正)

let util_suite = "Utils", [
  Alcotest.test_case "add function" `Quick test_add;
  Alcotest.test_case "greet function" `Quick test_greet;
  Alcotest.test_case "safe_div function" `Quick test_safe_div;
]

let user_suite = "User", [
  Alcotest.test_case "find_user function" `Quick test_find_user;
]

let exception_suite = "Exception", [
  Alcotest.test_case "raises Division_by_zero" `Quick test_division_by_zero;
]

let () =
  Alcotest.run "My_project tests" [
    util_suite;
    user_suite;
    exception_suite;
  ]

dune runtestを実行すると、結果がUtils, User, Exceptionというグループに分かれて表示され、どこで何がテストされているか一目瞭然になります。

5-3. (発展) 非同期処理のテストの紹介 (Lwt/Async)

Alcotestはlwtasyncといった非同期ライブラリと連携することもできます。例えばlwtを使う場合は、alcotest-lwtパッケージをインストールします。

opam install alcotest-lwt

そして、Alcotest.runの代わりにAlcotest_lwt.runを使い、テスト関数はLwt.tを返すように書きます。

(* 例: alcotest-lwtを使ったテスト *)
open Lwt.Syntax

let test_async_add () =
  let* result = Lwt.return (1 + 1) in (* 非同期処理のシミュレーション *)
  Alcotest.(check int) "async add" 2 result;
  Lwt.return_unit

let () =
  Alcotest_lwt.run "Async tests" [
    ("suite", [
      Alcotest_lwt.test_case "async add" `Quick test_async_add
    ])
  ] |> Lwt_main.run

ここでは詳細に立ち入りませんが、「Alcotestは非同期処理も同じようにテストできる」ということを知っておくと、将来必ず役立ちます。

第6章:まとめ

6-1. 学習の振り返り

このチュートリアルでは、多くのことを学びました。

  • テストは品質保証だけでなく、設計やドキュメントの役割も果たすこと。
  • dunealcotestで簡単にテスト環境を構築できること。
  • Alcotestの核となるtestable, test_case, テストスイートの概念。
  • 基本的な型、option/result型、そしてカスタム型のテスト方法。
  • ppx_derivingを使い、カスタム型のテストを劇的に効率化する方法。
  • 例外や非同期処理といった、より高度なテストケースへの対応。

6-2. 次のステップへ

これで、あなたは自分のOCamlプロジェクトに自信を持ってテストを導入する準備ができました。完璧なテストを最初から目指す必要はありません。「書かないよりは、少しでも書くほうがずっと良い」のです。

まずは一番シンプルで重要な関数から、ひとつテストを書いてみてください。その小さな一歩が、あなたのコード全体の品質を大きく向上させることに繋がります。

より詳しい情報が必要になったら、Alcotestの公式ドキュメントを参照してください。

Happy Hacking

対話履歴

ユーザー: 以下それぞれに対するalcotestを使ったocamlのテストコードを書いて

  • jsonのencode/decode
    • 最初にすべて手書きする例をだして
    • ppx_derivingを使ってtestableの作成を簡単にして
    • yojsonとrecordの定義のどちらかからどちらかを生成して
    • いわゆるSchema validationの方法を書いて(jsonschema?)
  • コマンドライン引数の取り扱い
    • テスト可能にする方法を説明して
    • 設定ファイルの解析のテストを書いて
    • stdinからの入力のテストを書いて
    • stdoutへの出力のテストを書いて
  • http request
    • functorにfake clientを注入するテストを書いて
    • 複数のエンドポイントへのアクセスを仲介するライブラリのテストを書いて
    • 本物のサーバーを立ち上げてリダイレクトの確認をするテストを書いて
    • 429やバックオフの動作のテストを書いて
  • サブシェル
    • 普通にサブコマンドを呼び出す処理のテストを書いて
    • 文字列を渡して文字列を返す関数という形にうまい具合に抽象化してテストを書いて

AIの思考プロセス

Framing the OCaml Request

I'm now focusing on breaking down the user's request. It seems straightforward: OCaml code, tests, and Alcotest. I'm visualizing four key categories that will inform the guide's structure. Thinking this through helps me define the content's boundaries for better direction.

Mapping the Structure

The user's request is coming into sharper focus. I've successfully mapped the core categories – JSON, command-line, HTTP, and subshells. I'm building out a hierarchical outline to ensure all aspects are thoroughly covered. I've also identified the necessary dependencies, including alcotest, yojson, and others, and will begin setting up the dune file for a smooth testing environment.

Outlining the Alcotest Structure

I'm now diving into the specifics. For the JSON section, I've outlined the manual encoding/decoding example, including the user record and the associated to_yojson and of_yojson functions. Now, I'm focusing on the corresponding Alcotest test code, including the pp and equal functions, necessary for the testable. I'm considering using QCheck later, for this user's requests.

Developing the Alcotest Setup

Now I'm focusing on the dependency management and project setup. I'm writing the dune file now, ensuring it includes all the necessary dependencies like alcotest, yojson, and ppx_deriving_yojson. This will guarantee a reproducible and easy-to-compile testing environment for the user. I'll include concise instructions on how to use dune build and dune runtest. My plan is to start with a minimal setup and gradually build upon it.

Structuring the OCaml Guide

I'm now fully immersed in the details. I'm focusing on organizing the content into modular sections, mirroring the user's request: JSON, command-line arguments, HTTP, and subshells. I'm also preparing for the project setup, which involves setting up the dune file and managing dependencies. I'm considering using ocaml-jsonschema for schema validation and integrating it into the JSON workflow.

Focusing the Test Cases

I'm now zeroing in on test case specifics. For JSON, I've outlined tests for manual encoding/decoding, ppx_deriving, and schema validation using ocaml-jsonschema. I'm also preparing to show how to ensure testability with cmdliner for command-line arguments. My focus remains on clear examples, covering both the user.ml code and the corresponding Alcotest setup.

Focusing on Detailed Testing

I'm now detailing how to handle the Alcotest setup, which includes managing dependencies through the dune file and ensuring a reproducible testing environment. I'll include concise build and test instructions. My goal is to outline all test cases, encompassing JSON, command-line arguments, HTTP requests, and subshells. I'm prioritizing clear explanations and functional test examples.

Developing the Guide Structure

I'm solidifying the structure. The core sections—JSON, command-line, HTTP, subshells—will be meticulously expanded. I'll outline code examples and Alcotest setups within these sections, ensuring a step-by-step guide with practical instructions. I'll explain testing CLI tools using Cmdliner, including how to handle various input and output scenarios for comprehensive coverage.

Focusing on Modular Sections

My focus has shifted to the guide's structure. I'm now implementing the modular sections for JSON, command-line arguments, HTTP, and subshells. I will showcase the code examples and Alcotest setups within each section to deliver a step-by-step guide with practical examples. I'll also clarify how to test CLI tools with Cmdliner and demonstrate how to handle various input/output scenarios for comprehensive coverage.

Outlining the Project Plan

I've sketched out a detailed plan. I'll provide OCaml code, Alcotest setups, and clear instructions for users. My focus will be on JSON, command-line arguments, HTTP, and subshells, reflecting the user's request. I'll utilize modular sections with practical examples. The aim is to create a complete and helpful resource.

Outlining the Testing Strategy

I've sketched out a detailed plan. I'll provide OCaml code, Alcotest setups, and instructions for users, focusing on JSON, command-line arguments, HTTP, and subshells. I'll utilize modular sections and provide examples, aiming to create a complete guide.

Outlining the Testing Strategy

I'm now detailing a comprehensive strategy. My plan involves OCaml code, Alcotest setups, and clear explanations, particularly for JSON, command-line, HTTP, and subshells. I'll use modular sections and relevant examples, ensuring a complete and helpful guide for the user.

Outlining the Comprehensive Structure

I've sketched a detailed outline for the comprehensive OCaml testing guide, covering JSON, command-line arguments, HTTP, and subshells. I'll utilize modular sections and practical examples, with Alcotest, to ensure a complete and helpful resource for users.

Structuring the OCaml Guide

I'm now focusing on the HTTP requests section, with a specific focus on functor-based testing. I'll showcase how to use the functorial approach, demonstrating how to inject the fake HTTP client and highlight the benefits of a modular design. I'm also preparing code examples that effectively demonstrate the testing and injecting strategies with Lwt_main.

Focusing the Test Cases

I've sketched out the initial sections. Now, I'm focusing on providing the user with detailed examples for each sub-request. The aim is to create reusable patterns, encompassing JSON, command-line arguments, HTTP, and subshells. I'm focusing on testing yojson with hand-written and derived examples. I'll include schema validation with ocaml-jsonschema for the JSON portion.

Focusing the Modular Design

I'm now detailing the implementation. I've broken down the project into modular, testing-focused sections. Each includes JSON, CLI, HTTP, and subshell testing, plus clear alcotest setups. I will write test cases and instructions for each category, using the pattern and example of JSON, CLI, HTTP, and subshells to ensure clarity.

AI: 承知いたしました。OCamlのテストフレームワーク `Alcotest` を使った、ご指定の各項目に対するテストコードの書き方を、具体的な例とともに解説します。

プロジェクトの準備

まず、dune を使ったプロジェクトを準備します。以下の dune-projectdune ファイルを作成してください。

dune-project

(lang dune 3.8)

dune

alcotest はテストの実行に lwt を使うことができるので、その設定も入れておきます。

(library
 (name my_lib)
 (libraries yojson ppx_deriving_yojson cmdliner cohttp-lwt-unix jsonschema))

(test
 (name test_all)
 (libraries my_lib alcotest alcotest-lwt lwt.unix)
 (preprocess (pps ppx_deriving_yojson lwt_ppx)))

この設定により、my_lib というライブラリと test_all というテストスイートが定義されます。


1. JSONのエンコード/デコード

JSONのテストでは、OCamlのレコード型とJSON表現の相互変換が正しく行われるかを確認します。

1.1. すべて手書きする例

ppx_deriving に頼らず、変換関数とテスト用の比較関数をすべて手書きします。

ライブラリコード (my_lib/user.ml)

type t = {
  id: int;
  name: string;
  email: string option;
}

(* t -> Yojson.Safe.t への変換 *)
let to_yojson user =
  let email_field =
    match user.email with
    | None -> []
    | Some e -> [("email", `String e)]
  in
  `Assoc ([
    ("id", `Int user.id);
    ("name", `String user.name);
  ] @ email_field)

(* Yojson.Safe.t -> t への変換 *)
let of_yojson json =
  let open Yojson.Safe.Util in
  try
    let id = member "id" json |> to_int in
    let name = member "name" json |> to_string in
    (* emailはオプショナルなので `to_string_option` を使う *)
    let email = member "email" json |> to_string_option in
    Ok { id; name; email }
  with Type_error (msg, _) -> Error msg

テストコード (test/test_all.ml)

open My_lib

(* Alcotestのためのtestableを手書きする *)
(* 1. Pretty-printer (エラー時に表示される) *)
let pp_user fmt { User.id; name; email } =
  let pp_email fmt = function
    | None -> Fmt.string fmt "None"
    | Some s -> Fmt.pf fmt "Some %S" s
  in
  Fmt.pf fmt "{ id = %d; name = %S; email = %a }" id name pp_email email

(* 2. 比較関数 *)
let equal_user u1 u2 =
  u1.User.id = u2.User.id &&
  u1.User.name = u2.User.name &&
  u1.User.email = u2.User.email

(* 3. Alcotest.testable を作成 *)
let user_testable = Alcotest.testable pp_user equal_user

let test_encode_decode () =
  let user = { User.id = 1; name = "Alice"; email = Some "[email protected]" } in
  let json = User.to_yojson user in
  let decoded_user_result = User.of_yojson json in

  (* 結果がOkであることを確認し、中身を比較する *)
  Alcotest.(check (result user_testable string))
    "should encode and decode back to the original value"
    (Ok user)
    decoded_user_result

(* 不正なJSONからのデコードテスト *)
let test_decode_failure () =
    let bad_json = `Assoc [("id", `String "not an int"); ("name", `String "Bob")] in
    let result = User.of_yojson bad_json in
    Alcotest.(check bool) "should fail to decode" (Result.is_error result) true

let json_suite = [
  "Encode/Decode roundtrip", `Quick, test_encode_decode;
  "Decode failure", `Quick, test_decode_failure;
]

let () =
  Alcotest.run "My Project" [
    "JSON Hand-written", json_suite;
  ]

1.2. ppx_derivingtestableを簡単にする

[@@deriving show, eq] を使うと、pp (pretty-printer) と equal 関数が自動生成され、testable の作成が簡単になります。

ライブラリコード (my_lib/user_ppx.ml)

type t = {
  id: int;
  name: string;
  email: string option;
} [@@deriving show, eq] (* showがppを、eqがequalを生成 *)

(* to_yojson/of_yojsonは手書きのまま *)
let to_yojson = User.to_yojson
let of_yojson = User.of_yojson

テストコード

Alcotest.testable の作成が非常に簡潔になります。

(* ... test_all.ml の続き ... *)
open My_lib

let test_ppx_testable () =
  let user = { User_ppx.id = 1; name = "Alice"; email = Some "[email protected]" } in
  let json = User_ppx.to_yojson user in
  let decoded_user_result = User_ppx.of_yojson json in

  (* pp_t と equal を ppx が生成してくれる *)
  let user_ppx_testable = Alcotest.testable User_ppx.pp User_ppx.equal in

  Alcotest.(check (result user_ppx_testable string))
    "should be simple with ppx_deriving"
    (Ok user)
    decoded_user_result

let ppx_testable_suite = [
  "PPX testable", `Quick, test_ppx_testable;
]

1.3. ppx_deriving_yojsonで変換を自動生成

[@@deriving yojson] を使うと、to_yojsonof_yojson も自動生成できます。

ライブラリコード (my_lib/user_auto.ml)

type t = {
  id: int;
  name: string;
  email: string option;
} [@@deriving yojson, show, eq]
(* これだけで to_yojson, of_yojson, pp, equal がすべて生成される *)

テストコード

テスト対象の関数は自動生成されたものになりますが、テストのロジック自体は変わりません。

(* ... test_all.ml の続き ... *)
open My_lib

let test_auto_json () =
  let user = { User_auto.id = 1; name = "Alice"; email = Some "[email protected]" } in
  let json = User_auto.to_yojson user in
  let decoded_user_result = User_auto.of_yojson json in

  let user_auto_testable = Alcotest.testable User_auto.pp User_auto.equal in

  Alcotest.(check (result user_auto_testable string))
    "should work with fully automated functions"
    (Ok user)
    decoded_user_result

let auto_json_suite = [
    "Auto-generated JSON", `Quick, test_auto_json;
]

1.4. Schema validation

ocaml-jsonschema ライブラリを使って、JSONが特定のスキーマに準拠しているかを検証します。

ライブラリコード (my_lib/user_validation.ml)

let user_schema_str = {|
{
  "type": "object",
  "properties": {
    "id": { "type": "integer" },
    "name": { "type": "string" },
    "email": { "type": "string", "format": "email" }
  },
  "required": ["id", "name"]
}
|}

let schema = Jsonschema.of_string user_schema_str

let validate_user_json json =
  Jsonschema.validate ~schema json

テストコード

(* ... test_all.ml の続き ... *)

let test_schema_validation () =
  let open My_lib in
  (* 有効なJSON *)
  let valid_json = `Assoc [("id", `Int 1); ("name", `String "Alice")] in
  let validation_result1 = User_validation.validate_user_json valid_json in
  Alcotest.(check bool) "valid json should pass validation" (validation_result1 = Ok ()) true;

  (* 無効なJSON (idが文字列) *)
  let invalid_json = `Assoc [("id", `String "1"); ("name", `String "Bob")] in
  let validation_result2 = User_validation.validate_user_json invalid_json in
  Alcotest.(check bool) "invalid json should fail validation" (Result.is_error validation_result2) true

let schema_suite = [
    "Schema validation", `Quick, test_schema_validation;
]

let () =
  Alcotest.run "My Project" [
    "JSON Hand-written", json_suite;
    "JSON PPX Testable", ppx_testable_suite;
    "JSON Auto-generated", auto_json_suite;
    "JSON Schema", schema_suite;
  ]

2. コマンドライン引数の取り扱い

2.1. テスト可能にする方法

重要なのは、副作用(標準入出力、ファイルアクセス、環境変数読み取りなど)を持つロジックと、純粋なビジネスロジックを分離することです。

Cmdliner は、コマンドライン引数の解析結果を関数の引数として渡すため、この分離が自然に行えます。テストでは、Cmdliner を介さずに、ビジネスロジックの関数を直接呼び出します。

ライブラリコード (my_lib/cli_logic.ml)

(* 設定ファイルを表すレコード *)
module Config = struct
  type t = { greeting: string } [@@deriving eq, show]
  let load_from_channel ic =
    (* 簡単な例: 1行目をgreetingとして読み込む *)
    try
      let greeting = input_line ic in
      Ok { greeting }
    with End_of_file -> Error "Config file is empty"

  let load path =
    try
      let ic = open_in path in
      let result = load_from_channel ic in
      close_in ic;
      result
    with Sys_error msg -> Error msg
end

(* メインロジック: すべての依存性を引数として受け取る *)
let run ~config ~input_ch ~output_ch =
  try
    let name = input_line input_ch in
    let message = Printf.sprintf "%s, %s!" config.Config.greeting name in
    output_string output_ch message;
    flush output_ch;
    Ok ()
  with End_of_file -> Error "Input was empty"

2.2. 設定ファイルの解析のテスト

一時ファイルを作成し、それに設定を書き込んでから Config.load をテストします。

テストコード

(* ... test_all.ml ... *)
let test_config_loading () =
  let conf_content = "Hello from config" in
  (* 一時ファイルを作成 *)
  let path, oc = Filename.open_temp_file "config" ".tmp" in
  output_string oc conf_content;
  close_out oc;

  (* Fun.protectでテストが失敗してもファイルが削除されるようにする *)
  Fun.protect ~finally:(fun () -> Sys.remove path) (fun () ->
    let expected = Ok { My_lib.Cli_logic.Config.greeting = "Hello from config" } in
    let actual = My_lib.Cli_logic.Config.load path in
    Alcotest.(check (result (of_pp My_lib.Cli_logic.Config.pp) string))
      "should load config from file" expected actual
  )

let config_suite = [
    "Config loading", `Quick, test_config_loading;
]

2.3. stdinからの入力のテスト

Unix.pipe を使って、プログラムの標準入力のふりをします。

テストコード

(* ... test_all.ml ... *)
let with_pipe f =
  let r, w = Unix.pipe () in
  let ic_read = Unix.in_channel_of_descr r in
  let oc_write = Unix.out_channel_of_descr w in
  Fun.protect ~finally:(fun () -> close_in_noerr ic_read; close_out_noerr oc_write)
    (fun () -> f ic_read oc_write)

let test_stdin_processing () =
  with_pipe (fun ic_read oc_write ->
    let config = { My_lib.Cli_logic.Config.greeting = "Hi" } in
    let output_buffer = Buffer.create 16 in
    let output_ch = Buffer.to_channel output_buffer in

    (* パイプにテストデータを書き込む *)
    output_string oc_write "World\n";
    close_out oc_write; (* 書き込み側を閉じると、読み込み側でEOFが通知される *)

    (* テスト対象の関数を実行 *)
    let _ = My_lib.Cli_logic.run ~config ~input_ch:ic_read ~output_ch in

    (* 標準出力への書き込みは無視し、入力が正しく処理されたか間接的に確認 *)
    (* (この例では stdout テストと重複するので、そちらで確認する) *)
    Alcotest.(check string) "dummy check for stdin" "Hi, World!" (Buffer.contents output_buffer)
  )

2.4. stdoutへの出力のテスト

これも Unix.pipe を使いますが、今度はプログラムの出力をパイプでキャプチャします。

テストコード

(* ... test_all.ml ... *)
let capture_stdout f =
  let r, w = Unix.pipe () in
  let old_stdout = Unix.dup Unix.stdout in
  Unix.dup2 w Unix.stdout; (* stdoutをパイプの書き込み側にリダイレクト *)
  Unix.close w;
  let captured =
    Fun.protect ~finally:(fun () ->
      flush stdout;
      Unix.dup2 old_stdout Unix.stdout; (* stdoutを元に戻す *)
      Unix.close old_stdout
    ) f
  in
  let ic = Unix.in_channel_of_descr r in
  let result = In_channel.input_all ic in
  close_in ic;
  (captured, result)

let test_stdout_output () =
  let input_ch = In_channel.of_string "OCaml" in
  let config = { My_lib.Cli_logic.Config.greeting = "Yo" } in
  
  let _result, output = capture_stdout (fun () ->
      (* このブロック内のstdoutへの出力がキャプチャされる *)
      let temp_oc = stdout in (* stdoutはリダイレクトされている *)
      My_lib.Cli_logic.run ~config ~input_ch ~output_ch:temp_oc
  ) in

  Alcotest.(check string) "should write correct message to stdout" "Yo, OCaml!" output

let cli_suite = [
    "Config loading", `Quick, test_config_loading;
    "Stdout output", `Quick, test_stdout_output;
]

: stdinstdout のテストは、run 関数が両方を使うため、1つのテストケースにまとめる方が自然な場合も多いです。


3. HTTPリクエスト

3.1. FunctorにFake Clientを注入するテスト

ネットワークアクセスを抽象化し、テスト時に偽のクライアント(モック)を注入するのが最も一般的な方法です。

ライブラリコード (my_lib/github_api.ml)

open Lwt.Syntax

(* HTTPクライアントのシグネチャを定義 *)
module type HTTP_CLIENT = sig
  val get: Uri.t -> (Cohttp.Response.t * Cohttp_lwt.Body.t) Lwt.t
end

(* ビジネスロジックをFunctorで定義 *)
module Make (Client : HTTP_CLIENT) = struct
  let get_user_repo_count username =
    let uri = Uri.of_string (Printf.sprintf "https://api.github.com/users/%s" username) in
    let* _response, body = Client.get uri in
    let* body_str = Cohttp_lwt.Body.to_string body in
    let json = Yojson.Safe.from_string body_str in
    match Yojson.Safe.Util.member "public_repos" json with
    | `Int n -> Lwt.return (Ok n)
    | _ -> Lwt.return (Error "public_repos field not found or not an int")
end

(* 本番用のクライアント *)
module Real_client = Cohttp_lwt_unix.Client
(* 本番用のモジュール *)
module Prod_api = Make(Real_client)

テストコード (test/test_all.ml)

open Lwt.Syntax
open My_lib

(* テスト用の偽クライアント *)
module Fake_client = struct
  let get uri =
    let path = Uri.path uri in
    let body_str, status =
      if path = "/users/ocaml" then
        ({|{"public_repos": 30}|}, `OK)
      else
        ({|{"message": "Not Found"}|}, `Not_found)
    in
    let resp = Cohttp.Response.make ~status () in
    let body = Cohttp_lwt.Body.of_string body_str in
    Lwt.return (resp, body)
end

(* テスト用モジュールを偽クライアントで作成 *)
module Test_api = Github_api.Make(Fake_client)

let test_fetch_repo_count_success () =
  let* result = Test_api.get_user_repo_count "ocaml" in
  Alcotest.(check (result int string)) "should get correct repo count" (Ok 30) result;
  Lwt.return_unit

let test_fetch_repo_count_notfound () =
  let* result = Test_api.get_user_repo_count "nonexistent" in
  Alcotest.(check bool) "should result in an error" (Result.is_error result) true;
  Lwt.return_unit

let http_functor_suite = [
  Alcotest_lwt.test_case "Success case" `Quick test_fetch_repo_count_success;
  Alcotest_lwt.test_case "Not found case" `Quick test_fetch_repo_count_notfound;
]

3.2. 複数のエンドポイントへのアクセスを仲介するライブラリ

Fake_clientget をより複雑にし、URIのパスに応じて異なるレスポンスを返すようにします。

3.3. 本物のサーバーを立ち上げてリダイレクトを確認

テスト中に cohttp-lwt-unix で一時的なサーバーを起動し、それに対してリクエストを投げることで、より統合テストに近いテストが可能です。

テストコード

(* ... test_all.ml ... *)
open Lwt.Syntax
open Cohttp
open Cohttp_lwt_unix

let test_redirect () =
  (* リダイレクトを返すだけのサーバー *)
  let server_callback _conn _req _body =
    let headers = Header.init_with "Location" "http://localhost:8081/final" in
    Server.respond ~status:`Moved_permanently ~headers ~body:`Empty ()
  in

  let stop_server, stopper = Lwt.wait () in
  let server =
    Server.create ~mode:(`TCP (`Port 8080)) (Server.make ~callback:server_callback ())
  in
  Lwt.on_termination server (fun () -> Lwt.wakeup_later stopper ());

  (* サーバーが起動するのを少し待つ *)
  let* () = Lwt_unix.sleep 0.1 in

  (* リダイレクトを自動で追わないようにしてリクエスト *)
  let* resp, _body = Client.get ~follow_redirect:false (Uri.of_string "http://localhost:8080/start") in

  (* サーバーを停止 *)
  Server.shutdown server;
  let* () = stop_server in

  (* レスポンスを検証 *)
  Alcotest.(check int) "status should be 301" 301 (Code.code_of_status resp.status);
  let location = Header.get resp.headers "Location" in
  Alcotest.(check (option string)) "location header should be correct"
    (Some "http://localhost:8081/final") location;

  Lwt.return_unit

let integration_suite = [
  Alcotest_lwt.test_case "Redirect test with real server" `Slow test_redirect;
]

3.4. 429やバックオフの動作のテスト

Fake Client をステートフルにし、バックオフのための sleep 関数も偽物(モック)を注入できるようにします。

ライブラリコード (my_lib/retry_logic.ml)

open Lwt.Syntax
module Make (Client : Github_api.HTTP_CLIENT) = struct
  let get_with_retry ?(sleep=Lwt_unix.sleep) uri =
    let rec attempt n =
      let* resp, body = Client.get uri in
      match resp.status with
      | `OK -> Lwt.return (Ok (resp, body))
      | `Too_many_requests when n > 0 ->
          let* () = sleep (2.0) in (* 簡単のため固定で2秒待つ *)
          attempt (n - 1)
      | status -> Lwt.return (Error (Cohttp.Code.string_of_status status))
    in
    attempt 3 (* 最大3回試行 *)
end

テストコード

(* ... test_all.ml ... *)
module Stateful_fake_client = struct
  let counter = ref 0
  let get _uri =
    incr counter;
    let status, body_str =
      if !counter <= 2 then
        (`Too_many_requests, "Rate limited")
      else
        (`OK, "Success")
    in
    let resp = Cohttp.Response.make ~status () in
    let body = Cohttp_lwt.Body.of_string body_str in
    Lwt.return (resp, body)
end

module Test_retry = Retry_logic.Make(Stateful_fake_client)

let test_backoff_and_retry () =
  Stateful_fake_client.counter := 0;
  let sleep_durations = ref [] in
  (* sleep の偽物: 待たずに呼ばれた時間を記録する *)
  let fake_sleep duration =
    sleep_durations := duration :: !sleep_durations;
    Lwt.return_unit
  in

  let* result = Test_retry.get_with_retry ~sleep:fake_sleep (Uri.of_string "/dummy") in

  Alcotest.(check bool) "request should eventually succeed" (Result.is_ok result) true;
  Alcotest.(check int) "client should be called 3 times" 3 !(Stateful_fake_client.counter);
  Alcotest.(check (list float)) "should sleep twice for 2.0s" [2.0; 2.0] (List.rev !sleep_durations);
  Lwt.return_unit

let retry_suite = [
    Alcotest_lwt.test_case "Backoff and retry" `Quick test_backoff_and_retry
]

4. サブシェル

4.1. 普通にサブコマンドを呼び出す処理のテスト

HTTPクライアントと同様に、シェルコマンドの実行を抽象化します。

ライブラリコード (my_lib/shell_utils.ml)

open Lwt.Syntax

(* シェルコマンド実行のシグネチャ *)
module type SHELL = sig
  val exec: string -> (string, int) result Lwt.t
end

(* ビジネスロジック *)
module Make (S : SHELL) = struct
  let get_git_branch () =
    let* result = S.exec "git rev-parse --abbrev-ref HEAD" in
    match result with
    | Ok output -> Lwt.return (Ok (String.trim output))
    | Error code -> Lwt.return (Error (`Git_error code))
end

(* 本番用: lwt_process を使う *)
module Real_shell = struct
  let exec cmd =
    let* result = Lwt_process.pread (Lwt_process.shell cmd) in
    (* ここでは簡単のためexit codeを無視 *)
    Lwt.return (Ok result)
end

テストコード

(* ... test_all.ml ... *)
open My_lib

(* テスト用の偽シェル *)
module Fake_shell = struct
  let exec cmd =
    if cmd = "git rev-parse --abbrev-ref HEAD" then
      Lwt.return (Ok "  feature/new-stuff  \n")
    else
      Lwt.return (Error 127)
end

module Test_git = Shell_utils.Make(Fake_shell)

let test_get_git_branch () =
  let* branch = Test_git.get_git_branch () in
  Alcotest.(check (result string (of_pp Fmt.nop)))
    "should get and trim branch name" (Ok "feature/new-stuff") branch;
  Lwt.return_unit

let shell_suite = [
    Alcotest_lwt.test_case "Get git branch" `Quick test_get_git_branch
]

4.2. 文字列を渡して文字列を返す関数として抽象化

これは、サブプロセスを「データの変換器」と見なす、より高度な抽象化です。

ライブラリコード (my_lib/transformer.ml)

open Lwt.Syntax

(* 変換器の型: string -> string Lwt.t *)
type 'a transformer = string -> ('a, string) result Lwt.t

(* この変換器を使って何かをする関数 *)
let process_data (t : string transformer) (data : string) : (string, string) result Lwt.t =
  if String.length data = 0 then
    Lwt.return (Error "Empty data")
  else
    let* result = t data in
    match result with
    | Ok transformed -> Lwt.return (Ok (Printf.sprintf "Processed: %s" transformed))
    | Error e -> Lwt.return (Error (Printf.sprintf "Transformer failed: %s" e))

(* 本番で使う `sed` を使った変換器 *)
let sed_transformer : string transformer = fun input ->
  Lwt_process.with_process_in (Lwt_process.shell "sed 's/hello/hi/g'") (fun proc ->
    let* () = Lwt_io.write proc#stdin input in
    let* () = Lwt_io.close proc#stdin in
    let* output = Lwt_io.read proc#stdout in
    Lwt.return (Ok output)
  )

テストコード

sed コマンドを実際に実行する代わりに、OCamlの純粋な関数を「変換器」として渡して、process_data のロジックをテストします。

(* ... test_all.ml ... *)
open My_lib

(* テスト用の偽の変換器 (OCamlの純粋な関数) *)
let uppercase_transformer : string Transformer.transformer = fun input ->
  Lwt.return (Ok (String.uppercase_ascii input))

let failing_transformer : 'a Transformer.transformer = fun _ ->
  Lwt.return (Error "it always fails")

let test_process_data_success () =
  let* result = Transformer.process_data uppercase_transformer "world" in
  Alcotest.(check (result string string))
    "should process data with transformer" (Ok "Processed: WORLD") result;
  Lwt.return_unit

let test_process_data_failure () =
    let* result = Transformer.process_data failing_transformer "world" in
    Alcotest.(check (result string string))
      "should handle transformer failure" (Error "Transformer failed: it always fails") result;
    Lwt.return_unit

let transformer_suite = [
    Alcotest_lwt.test_case "Process data success" `Quick test_process_data_success;
    Alcotest_lwt.test_case "Process data failure" `Quick test_process_data_failure;
]

最終的なテスト実行ファイル (test/test_all.ml)

これまでのスイートをすべてまとめます。

(* 上記のすべてのテストコードをここにまとめる *)

let () =
  Alcotest_lwt.run "My Project" [
    (* JSONのテストはLwt不要なのでAlcotest.runでラップする *)
    "JSON Hand-written", json_suite;
    "JSON PPX Testable", ppx_testable_suite;
    "JSON Auto-generated", auto_json_suite;
    "JSON Schema", schema_suite;
    "CLI", cli_suite;
    "HTTP Functor", http_functor_suite;
    "HTTP Integration", integration_suite;
    "HTTP Retry", retry_suite;
    "Shell Functor", shell_suite;
    "Shell Transformer", transformer_suite;
  ] |> Lwt_main.run

実行方法

プロジェクトのルートディレクトリで以下のコマンドを実行します。

dune test

これにより、すべてのテストが実行され、結果が表示されます。

(返答終了理由: STOP)

メタデータ

{
  "runSettings": {
    "temperature": 1,
    "model": "models/gemini-2.5-pro-preview-06-05",
    "topP": 0.95,
    "topK": 64,
    "maxOutputTokens": 65536,
    "safetySettings": [
      {
        "category": "HARM_CATEGORY_HARASSMENT",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_HATE_SPEECH",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
        "threshold": "OFF"
      }
    ],
    "responseMimeType": "text/plain",
    "enableCodeExecution": false,
    "enableSearchAsATool": false,
    "enableBrowseAsATool": false,
    "enableAutoFunctionResponse": false
  },
  "systemInstruction": {}
}

対話履歴

ユーザー: ocamlでオブジェクトのフィールドにoneOfを含んだjsonをtagged unionを含んだレコードにencode/decodeする処理とそのテストが欲しいです。

可能なら別のあるフィールドがenumの範囲であることも保証したいです。

手書きせずなるべくppx経由で生成できるか教えてください。

実装コードとalcotestでのテストコードをください。


AI: はい、承知いたしました。OCamlでoneOfを含むJSONをタグ付き共用体(variant type)を含むレコードに変換する処理ですね。さらに、別のフィールドがenumの範囲内であることも保証する実装と、そのAlcotestでのテストコードを、ppx_deriving_yojsonを最大限活用しつつ作成します。

要件の整理

  1. JSONの構造:

    • あるフィールド(例: "type")の値によって、他のフィールドの構造が変わる(oneOf)。
    • "type"フィールドは特定の値(enum)しか取らない。

    JSONの例:

    // typeが"MESSAGE"の場合
    {
      "id": 1,
      "type": "MESSAGE",
      "text": "Hello, OCaml!"
    }
    
    // typeが"REACTION"の場合
    {
      "id": 2,
      "type": "REACTION",
      "emoji": "👍"
    }
  2. OCamlの型定義:

    • JSON全体を表現するレコード型。
    • oneOfの部分をタグ付き共用体(variant)で表現。
    • "type"フィールドをenumとして表現(これもvariant)。

    OCamlの型の例:

    type event_type = MESSAGE | REACTION
    type payload =
      | Message of { text : string }
      | Reaction of { emoji : string }
    type event = {
      id : int;
      event_type : event_type;
      payload : payload;
    }
  3. 実装方針:

    • ppx_deriving_yojson を利用して、単純な部分(event_typeなど)の変換コードを自動生成する。
    • oneOfのような複雑な構造は、PPXが直接は生成できないため、手動でエンコーダ・デコーダを記述する。ただし、その内部ではPPXが生成したヘルパー関数を使い、手書き部分を最小限に抑える。
    • Alcotestで、エンコード・デコードの正当性、不正なJSONの拒否、往復変換(round-trip)のテストを行う。

プロジェクトのセットアップ

まず、Duneを使ってプロジェクトをセットアップします。以下のファイル構成を想定します。

.
├── dune-project
├── dune
├── oneof_json.opam
├── lib/
│   ├── dune
│   └── oneof_json.ml
└── test/
    ├── dune
    └── test_oneof_json.ml

dune-project

(lang dune 3.8)
(name oneof_json)

oneof_json.opam (任意ですが、良い習慣です)

opam-version: "2.0"
name: "oneof_json"
version: "1.0.0"
depends: [
  "ocaml" {>= "4.14.0"}
  "dune" {>= "3.0"}
  "yojson" {>= "2.0.0"}
  "ppx_deriving_yojson" {>= "3.7.0"}
  "alcotest" {>= "1.6.0"}
]

lib/dune

(library
 (name oneof_json)
 (preprocess (pps ppx_deriving_yojson)))

test/dune

(test
 (name test_oneof_json)
 (libraries oneof_json alcotest))

実装コード (lib/oneof_json.ml)

ppx_deriving_yojsonは、OCamlのvariantをデフォルトで ["TAGNAME", value] というJSON配列に変換します。今回はJSONオブジェクトのキーの値に基づいて構造が変わるため、メインのレコード型(event)の変換関数は手動で実装する必要があります。これが最もクリーンな方法です。

(* lib/oneof_json.ml *)

(* event_type はenumとして扱う。
   ppx_deriving_yojsonが文字列への変換/からの変換を自動生成する。
   `show`と`eq`はAlcotestでの比較と表示に使う。 *)
type event_type = MESSAGE | REACTION
[@@deriving yojson, show, eq]

(* oneOf に対応するペイロード部分。
   こちらも show と eq をAlcotestのために導出しておく。 *)
type payload =
  | Message of { text : string }
  | Reaction of { emoji : string }
[@@deriving show, eq]

(* メインのレコード型 *)
type event = {
  id : int;
  event_type : event_type;
  payload : payload;
}
[@@deriving show, eq]

(* --- ここからが手動実装部分 --- *)

(* Yojson.Safe.Util はJSON操作のための便利なヘルパーモジュール *)
open Yojson.Safe.Util

(* [payload] 型をJSONからデコードするヘルパー関数
   この関数は event_type の値に基づいて、どのフィールドを読むべきかを決定する *)
let payload_of_yojson event_type json =
  try
    match event_type with
    | MESSAGE ->
        let text = json |> member "text" |> to_string in
        Ok (Message { text })
    | REACTION ->
        let emoji = json |> member "emoji" |> to_string in
        Ok (Reaction { emoji })
  with Type_error (msg, _) | Not_found ->
    Error msg

(* [payload] 型をJSONにエンコードするヘルパー関数 *)
let yojson_of_payload = function
  | Message { text } -> `Assoc [("text", `String text)]
  | Reaction { emoji } -> `Assoc [("emoji", `String emoji)]


(* [event] 型のデコーダ
   ppxでは自動生成できない、今回の要件の核となる部分 *)
let event_of_yojson json =
  try
    (* 共通のフィールドをまず読み込む *)
    let id = json |> member "id" |> to_int in
    (* event_type_of_yojson は ppx_deriving_yojson が自動生成した関数 *)
    let event_type = json |> member "type" |> event_type_of_yojson |> Result.get_ok in

    (* 読み込んだ event_type に基づいて payload をデコード *)
    match payload_of_yojson event_type json with
    | Ok payload -> Ok { id; event_type; payload }
    | Error msg -> Error (Printf.sprintf "Failed to decode payload: %s" msg)
  with
  | Type_error (msg, _) -> Error (Printf.sprintf "Type error: %s" msg)
  | Not_found -> Error "Missing field 'id' or 'type'"
  | Failure msg -> Error (Printf.sprintf "Failure during type decoding: %s" msg)


(* [event] 型のエンコーダ *)
let yojson_of_event { id; event_type; payload } =
  let common_fields =
    [
      ("id", `Int id);
      (* yojson_of_event_type は ppx_deriving_yojson が自動生成した関数 *)
      ("type", yojson_of_event_type event_type);
    ]
  in
  (* `Assoc [...] で表される payload のJSONオブジェクトと共通フィールドを結合する *)
  let payload_json = yojson_of_payload payload in
  Yojson.Safe.Util.combine (`Assoc common_fields) payload_json

コードのポイント

  • event_type: [@@deriving yojson] をつけるだけで、MESSAGE"MESSAGE" の変換が自動生成されます。デコード時に "MESSAGE" でも "REACTION" でもない文字列が来ると、自動的にエラーになります。これがenumの範囲保証です。
  • payload_of_yojson / yojson_of_payload: oneOfのロジックをカプセル化するヘルパー関数です。
  • event_of_yojson: JSONオブジェクトから "type" フィールドを先に読み込み、その値を使って payload 部分のデコード方法を分岐させています。
  • yojson_of_event: idtype の共通フィールドと、payload から生成されるフィールドを Yojson.Safe.Util.combine でマージして1つのJSONオブジェクトを生成しています。

テストコード (test/test_oneof_json.ml)

Alcotest を使って、エンコード・デコード処理をテストします。

(* test/test_oneof_json.ml *)
open Oneof_json

(* Alcotestが 'event' 型を比較・表示するためのモジュール *)
let event_testable = Alcotest.testable pp_event equal_event

(* Alcotestが 'yojson' 型を比較・表示するためのヘルパー *)
let yojson_testable = Alcotest.testable Yojson.Safe.pp Yojson.Safe.equal

(* 正常系のテスト: Messageのデコード *)
let test_decode_message () =
  let json_str = {|
    {
      "id": 1,
      "type": "MESSAGE",
      "text": "Hello, OCaml!"
    }
  |} in
  let expected =
    Ok { id = 1; event_type = MESSAGE; payload = Message { text = "Hello, OCaml!" } }
  in
  let actual = Yojson.Safe.from_string json_str |> event_of_yojson in
  Alcotest.(check (result event_testable string))
    "should decode a message event" expected actual

(* 正常系のテスト: Reactionのデコード *)
let test_decode_reaction () =
  let json_str = {|
    {
      "id": 2,
      "type": "REACTION",
      "emoji": "👍"
    }
  |} in
  let expected =
    Ok { id = 2; event_type = REACTION; payload = Reaction { emoji = "👍" } }
  in
  let actual = Yojson.Safe.from_string json_str |> event_of_yojson in
  Alcotest.(check (result event_testable string))
    "should decode a reaction event" expected actual

(* 異常系のテスト: typeとpayloadのフィールドが不一致 *)
let test_decode_mismatch () =
  let json_str = {|
    {
      "id": 3,
      "type": "MESSAGE",
      "emoji": "🤔" 
    }
  |} in
  let result = Yojson.Safe.from_string json_str |> event_of_yojson in
  Alcotest.(check bool) "should fail on mismatched payload" (Result.is_error result) true;
  match result with
  | Error msg ->
      Alcotest.(check string) "should contain specific error message"
        "Failed to decode payload: text" msg
  | Ok _ -> Alcotest.fail "Expected an error but got Ok"

(* 異常系のテスト: typeがenumの範囲外 *)
let test_decode_invalid_type () =
  let json_str = {|
    {
      "id": 4,
      "type": "LIKE",
      "text": "this won't work"
    }
  |} in
  let result = Yojson.Safe.from_string json_str |> event_of_yojson in
  Alcotest.(check bool) "should fail on invalid event_type" (Result.is_error result) true;
    match result with
  | Error msg ->
      Alcotest.(check string) "should contain specific error message"
        "Failure during type decoding: Yojson.Json_error(\"Oneof_json.event_type_of_yojson: unknown constructor LIKE\")" msg
  | Ok _ -> Alcotest.fail "Expected an error but got Ok"


(* 正常系のテスト: Messageのエンコード *)
let test_encode_message () =
  let event_record =
    { id = 1; event_type = MESSAGE; payload = Message { text = "Hello, OCaml!" } }
  in
  (* NOTE: JSONオブジェクトのキーの順序は保証されないため、
     比較する際はパース済みの `Yojson.Safe.t` 同士で比較するのが堅牢です。*)
  let expected_json =
    Yojson.Safe.from_string {|{"id":1, "type":"MESSAGE", "text":"Hello, OCaml!"}|}
  in
  let actual_json = yojson_of_event event_record in
  Alcotest.check yojson_testable "should encode a message event" expected_json actual_json

(* 正常系のテスト: Reactionのエンコード *)
let test_encode_reaction () =
  let event_record =
    { id = 2; event_type = REACTION; payload = Reaction { emoji = "👍" } }
  in
  let expected_json =
    Yojson.Safe.from_string {|{"id":2,"type":"REACTION","emoji":"👍"}|}
  in
  let actual_json = yojson_of_event event_record in
  Alcotest.check yojson_testable "should encode a reaction event" expected_json actual_json

(* 往復変換のテスト *)
let test_roundtrip () =
  let original_event =
    { id = 10; event_type = MESSAGE; payload = Message { text = "roundtrip" } }
  in
  let roundtrip_result =
    original_event |> yojson_of_event |> event_of_yojson
  in
  Alcotest.(check (result event_testable string))
    "should be equal after encoding and decoding" (Ok original_event) roundtrip_result

(* テストスイートの定義 *)
let () =
  Alcotest.run "OneOf JSON Tests" [
    ("decoding", [
      Alcotest.test_case "Decode Message" `Quick test_decode_message;
      Alcotest.test_case "Decode Reaction" `Quick test_decode_reaction;
      Alcotest.test_case "Decode Mismatched Payload" `Quick test_decode_mismatch;
      Alcotest.test_case "Decode Invalid Type" `Quick test_decode_invalid_type;
    ]);
    ("encoding", [
      Alcotest.test_case "Encode Message" `Quick test_encode_message;
      Alcotest.test_case "Encode Reaction" `Quick test_encode_reaction;
    ]);
    ("roundtrip", [
      Alcotest.test_case "Encode-Decode Roundtrip" `Quick test_roundtrip;
    ]);
  ]

ビルドと実行

プロジェクトのルートディレクトリで以下のコマンドを実行します。

  1. 依存関係のインストール:

    opam install . --deps-only --with-test
  2. テストの実行:

    dune runtest

実行すると、Alcotestがすべてのテストケースを実行し、成功すれば以下のような出力が表示されます。

Running "OneOf JSON Tests".
........
This run has been successful!

これで、ご要望の「oneOfを含むJSON」と「タグ付き共用体を含むOCamlレコード」の間の型安全な変換処理と、そのテストが完成しました。PPXで生成できる部分は自動化し、複雑なロジックのみを明示的に記述することで、可読性と保守性のバランスの取れたコードになっています。

(返答終了理由: STOP)

メタデータ

{
  "runSettings": {
    "temperature": 1,
    "model": "models/gemini-2.5-pro-preview-06-05",
    "topP": 0.95,
    "topK": 64,
    "maxOutputTokens": 65536,
    "safetySettings": [
      {
        "category": "HARM_CATEGORY_HARASSMENT",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_HATE_SPEECH",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
        "threshold": "OFF"
      }
    ],
    "responseMimeType": "text/plain",
    "enableCodeExecution": false,
    "enableSearchAsATool": false,
    "enableBrowseAsATool": false,
    "enableAutoFunctionResponse": false,
    "thinkingBudget": -1
  },
  "systemInstruction": {}
}

対話履歴

ユーザー: ocamlでテストコードを書く時には何がおすすめですか?


AI: OCamlでテストコードを書く際には、いくつかの優れた選択肢があります。プロジェクトの性質やテストの種類によって最適なものは異なりますが、一般的に推奨されるものをいくつかご紹介します。

  1. Alcotest (アルコテスト)

    • 種類: ユニットテストフレームワーク
    • 特徴:
      • 軽量で使いやすく、カラフルな出力が見やすいです。
      • OCamlコミュニティで現在最も広く使われているテストフレームワークの一つです。
      • Dune との連携が非常に簡単です。
      • テストケースとテストスイートを構造化して定義できます。
      • 基本的なアサーション関数が提供されています。
    • おすすめのケース:
      • ほとんどのユニットテストのニーズに対応できます。
      • OCaml を始めたばかりの方にも比較的理解しやすいです。
    • 簡単な例 (イメージ):
      (* test/my_test.ml *)
      let test_add () =
        Alcotest.(check int) "same int" 3 (My_module.add 1 2)
      
      let () =
        Alcotest.run "My_module tests" [
          "arithmetic", [
            "test_add", `Quick, test_add;
          ];
        ]
      (test
       (name my_test)
       (libraries my_module alcotest))
  2. QCheck (キューチェック)

    • 種類: プロパティベーステストライブラリ
    • 特徴:
      • HaskellのQuickCheckに影響を受けたライブラリです。
      • ランダムな入力データを多数生成し、指定したプロパティ(性質)が常に成り立つかをテストします。
      • Alcotest や OUnit2 と組み合わせて使用することが一般的です。
      • 手動では思いつかないようなエッジケースを発見するのに役立ちます。
    • おすすめのケース:
      • 関数の普遍的な性質をテストしたい場合。
      • より堅牢なテストを目指す場合。
      • 複雑なデータ構造やアルゴリズムのテスト。
    • 簡単な例 (イメージ、Alcotestと連携):
      (* test/my_property_test.ml *)
      let rev_rev_id =
        QCheck.Test.make ~count:1000
          QCheck.(list int)
          (fun l -> List.rev (List.rev l) = l)
      
      let () =
        Alcotest.run "My_module property tests" [
          "list_properties", [
            QCheck_alcotest.to_alcotest rev_rev_id;
          ];
        ]
  3. ppx_inline_test

    • 種類: インラインテスト用PPX拡張
    • 特徴:
      • ソースコードファイル (.ml.mli) 内に直接テストを記述できます。
      • テスト対象のコードのすぐ近くにテストを書けるため、コードとテストの関連性が明確になります。
      • Dune との連携が前提です。dune runtest で実行されます。
      • 小規模なテストや、関数の基本的な振る舞いを示すドキュメンテーションとしてのテストに適しています。
    • おすすめのケース:
      • 小さな関数やモジュールのテスト。
      • テストをコードと一緒に管理したい場合。
    • 簡単な例 (イメージ):
      (* lib/my_module.ml *)
      let add x y = x + y
      
      let%test "add_positive" = add 2 3 = 5
      let%test "add_negative" = add (-1) (-2) = -3
      
      (* またはテストスイートとして *)
      let%test_module "String functions" = (module struct
        let%test "capitalize" = String.capitalize_ascii "hello" = "Hello"
      end)
      dune ファイルに (inline_tests) を追加する必要があります。
  4. Expect tests (ppx_expect)

    • 種類: 出力比較テスト用PPX拡張
    • 特徴:
      • テストの期待される標準出力や値をソースコード中に記述し、実際の実行結果と比較します。
      • 初回実行時や期待値が変更された場合、dune runtest --auto-promote などで期待値を自動的に更新できます。
      • Jane Street によって開発され、広く使われています。
      • 複雑な出力や、テキストベースのプロトコルのテストに便利です。
    • おすすめのケース:
      • 標準出力に結果を出すプログラムのテスト。
      • テキストデータの生成・変換処理のテスト。
    • 簡単な例 (イメージ):
      (* lib/my_printer.ml *)
      let greet name = Printf.printf "Hello, %s!\n" name
      
      let%expect_test "greeting" =
        greet "world";
        [%expect {| Hello, world! |}]
  5. OUnit2 (オーユニットツー)

    • 種類: ユニットテストフレームワーク
    • 特徴:
      • Alcotest 登場以前から存在する、歴史のあるテストフレームワークです。
      • 機能は豊富ですが、Alcotest に比べるとセットアップや記述が若干冗長になることがあります。
      • 依然として多くのプロジェクトで使用されています。
    • おすすめのケース:
      • 既存の OUnit2 ベースのプロジェクト。
      • Alcotest にはない特定の機能が必要な場合 (ただし、多くは Alcotest で代替可能です)。

どれを選ぶべきか?

  • 一般的なユニットテスト: Alcotest が最もおすすめです。手軽に始められ、Dune との連携もスムーズです。
  • より堅牢なテスト (プロパティベーステスト): QCheck を Alcotest と組み合わせて使うのが強力です。
  • コードの近くに小さなテストを書きたい: ppx_inline_test が便利です。
  • 出力結果を正確にテストしたい: ppx_expect が適しています。
  • CLIツールのテスト: Dune には Cram tests という、シェルスクリプトのような形式でコマンドラインインターフェースをテストする仕組みもあります。

Dune との連携

上記のツールの多くは、OCaml の標準的なビルドシステムである Dune と非常にうまく連携します。Dune ファイルに数行追加するだけでテストを実行できるようになるため、開発体験が向上します。

まとめ

まずは Alcotest から試してみて、必要に応じて QCheckppx_inline_testppx_expect などを検討するのが良いでしょう。プロジェクトの特性やチームの好みに合わせて、これらのツールを組み合わせて使うことも一般的です。

それぞれのツールのGitHubリポジトリや公式ドキュメントには、より詳しい情報や例が載っていますので、ぜひ参照してみてください。

(返答終了理由: STOP)


ユーザー: エラーリポーターが親切なのはどれですか?あるいはそれぞれのテスト失敗時のエラーはどのようなものでしょうか?例を挙げてください。


AI: エラーリポーターの「親切さ」は、どの情報がどのように提示されるかによって評価が分かれることがありますが、OCamlの主要なテストツールはそれぞれ特徴的な方法で失敗を報告してくれます。

以下に、主要なツールごとにテスト失敗時のエラーメッセージの例と、その「親切さ」についてのポイントを挙げます。

1. Alcotest

Alcotest は、失敗したテストケース、期待値、実際の結果を明確に表示し、色付けされた出力(ターミナルが対応していれば)により視認性が高いのが特徴です。特に文字列やリストなどの比較では diff 形式で差異を示してくれます。

  • 親切さのポイント:

    • 失敗箇所(テスト名)が明確。
    • 期待値と実測値の直接比較。
    • 構造化されたデータ(リスト、文字列など)の差分表示。
    • カラフルな表示で見やすい。
  • 失敗例:

    (* test/example_alcotest.ml *)
    let add x y = x + y
    let check_addition () =
      Alcotest.(check int) "sum of 2 and 3" 5 (add 2 3); (* これは通る *)
      Alcotest.(check int) "sum of 1 and 1" 3 (add 1 1); (* これは失敗する *)
      Alcotest.(check string) "string equality" "hello world" "hello OCaml" (* これも失敗する *)
    
    let () =
      Alcotest.run "My Alcotest" [
        "suite_name", [
          "test_addition", `Quick, check_addition;
        ]
      ]

    dune exec ./test/example_alcotest.exe を実行した際の出力(イメージ):

    Testing `My Alcotest'.
    This run has ID `2E8B9C4A-7D1F-4B8E-9A0A-C3F0D1E2F3A4'.
    [FAIL] My Alcotest.suite_name.test_addition
    sum of 1 and 1:
    expected: 3
    got:      2
    
    [FAIL] My Alcotest.suite_name.test_addition
    string equality:
    --- expected
    +++ got
    @@ -1 +1 @@
    -hello world
    +hello OCaml
    
    Ran 1 test case in 0.00 seconds (1 token).
    Critically failed 2 tests.
    
    • 1つ目の失敗では、期待値 3 に対して実際の値が 2 であったことが示されます。
    • 2つ目の失敗では、文字列の差分が diff 形式で表示されます。

2. QCheck (Alcotest との連携)

QCheck はプロパティベーステストなので、失敗時にはそのプロパティを破る「反例 (counter-example)」を提示します。多くの場合、QCheck は反例をできるだけ単純な形に「縮小 (shrink)」しようとします。

  • 親切さのポイント:

    • 失敗を引き起こした具体的な入力値(反例)がわかる。
    • 反例が縮小されるため、問題の原因特定がしやすい。
  • 失敗例:

    (* test/example_qcheck.ml *)
    let rev_neq_id =
      QCheck.Test.make ~name:"list_rev_neq_id"
        ~count:1000
        QCheck.(list int)
        (fun l -> List.rev l <> l) (* わざと間違ったプロパティ: リストの逆順は常に元と異なる (回文リストで失敗する) *)
    
    let () =
      Alcotest.run "My QCheck tests" [
        "list_properties", [
          QCheck_alcotest.to_alcotest rev_neq_id;
        ];
      ]

    dune exec ./test/example_qcheck.exe を実行した際の出力(イメージ):

    Testing `My QCheck tests'.
    This run has ID `A1B2C3D4-E5F6-G7H8-I9J0-K1L2M3N4O5P6'.
    [FAIL] My QCheck tests.list_properties.list_rev_neq_id
    Test `list_rev_neq_id` failed (2 shrinks):
    (found counterexample: [1])
    (after 1 shrink: [])
    (seed: 123456789)
    Ran 1 test case in 0.01 seconds (1 token).
    Critically failed 1 test.
    
    • この例では、List.rev l <> l というプロパティが [1] というリスト(rev [1][1] なので [1] <> [1] は偽)で失敗したことを示しています。さらに、QCheckが反例を [] まで縮小したことも示しています。

3. ppx_inline_test (多くは ppx_assert と共に使用)

ppx_inline_test 自体はテストの失敗を報告しますが、アサーションの詳細は提供しません。そのため、ppx_assert のようなアサーションPPXと組み合わせて使うと、より親切なエラーメッセージが得られます。

  • 親切さのポイント (ppx_assert と組み合わせた場合):

    • 失敗したテストがソースコードのどの行にあるか明確。
    • ppx_assert が提供するアサーションの種類に応じて、値の比較や差分を表示。
  • 失敗例 (with ppx_assert): dune ファイルに (preprocess (pps ppx_assert)) を追加します。

    (* lib/my_inline_module.ml *)
    let add x y = x + y
    
    let%test "addition test" =
      [%assert_eq: int] (add 2 2) 5 (* 期待値5, 実際は4 *)
    
    let%test_module "String tests" = (module struct
      let%test "concatenation" =
        [%assert_eq: string] ("hello" ^ " " ^ "world") "hello OCaml"
    end)

    dune runtest を実行した際の出力(イメージ):

    File "lib/my_inline_module.ml", line 4, characters 2-41:
    lib/my_inline_module.ml:addition test
    FAILED
    (add 2 2)
    (=)
    5
    `4` is not equal to `5`.
    
    File "lib/my_inline_module.ml", line 8, characters 4-70:
    lib/my_inline_module.ml:String tests:concatenation
    FAILED
    ("hello" ^ " " ^ "world")
    (=)
    "hello OCaml"
    `"hello world"` is not equal to `"hello OCaml"`.
    --- `"hello world"`
    +++ `"hello OCaml"`
    @@ -1,11 +1,11 @@
     hello
    -world
    +OCaml
    
    • 失敗したテストのファイル名、行番号、テスト名が表示されます。
    • ppx_assert が比較した値と、その差分(文字列の場合)を表示します。

4. ppx_expect

ppx_expect は、期待される出力と実際の出力の差分を diff 形式で表示します。非常に明確で、dune runtest --auto-promote で期待値を簡単に更新できるのが大きな利点です。

  • 親切さのポイント:

    • 期待される出力と実際の出力の差分が diff 形式で非常に明確。
    • テストが失敗したソースコードの場所がわかる。
    • --auto-promote で簡単に期待値を更新できる。
  • 失敗例:

    (* lib/my_expect_printer.ml *)
    let print_greeting name =
      Printf.printf "Hello, %s!\nWelcome." name
    
    let%expect_test "greeting" =
      print_greeting "OCaml";
      [%expect {|
        Hello, OCaml!
        Welcome to the test.
      |}]

    dune runtest を実行した際の出力(イメージ):

    File "lib/my_expect_printer.ml", line 5, characters 2-100:
    EXPECTATION FAILED (to auto-promote, run: dune runtest --auto-promote)
    --- EXPECTED
    +++ ACTUAL
    @@ -1,2 +1,2 @@
     Hello, OCaml!
    -Welcome to the test.
    +Welcome.
    
    • 期待していた出力 (EXPECTED) と実際の出力 (ACTUAL) の差分が明確に示されます。
    • --auto-promote コマンドで [%expect] ブロックの内容を実際の出力に更新できる旨のメッセージも表示されます。

まとめ

  • Alcotest: ユニットテストの基本的な期待値/実測値の比較、構造化データの diff に優れています。
  • QCheck: プロパティベーステストで、なぜ失敗したかの「反例」を具体的に示してくれるのが非常に強力です。
  • ppx_inline_test (with ppx_assert): コード中のどこでテストが失敗したかが分かりやすく、ppx_assert による詳細な比較情報が得られます。
  • ppx_expect: テキストベースの出力のテストに特化しており、diff による差分表示と自動更新機能が非常に親切です。

どのツールも、それぞれのテストの目的に応じて「親切な」エラー情報を提供してくれます。プロジェクトの性質やテストしたい内容に応じて、これらのツールを使い分ける(あるいは組み合わせる)と良いでしょう。

(返答終了理由: STOP)


ユーザー: 記憶が確かなら他の言語の経験を元に評価したときにびっくりするくらいOUnit2は使いにくかった記憶があります。


AI: そうでしたか、OUnit2が使いにくいと感じられたのですね。おっしゃる通り、他の言語のモダンなテストフレームワーク(例えば、JavaのJUnit、Pythonのpytest、JavaScriptのJestなど)に慣れていると、OUnit2は少し古風に感じられたり、記述が冗長に感じられたりするかもしれません。

OUnit2が使いにくいと感じられる可能性のある点としては、以下のようなものが考えられます(これは一般的な傾向であり、個々の感じ方によります):

  1. セットアップと記述の冗長さ:

    • テストケースやテストスイートの定義に、ある程度のボイラープレートコードが必要になることがあります。
    • Alcotestのような後発のライブラリと比較すると、より明示的に多くのことを記述する必要があるかもしれません。
  2. アサーションの表現:

    • 基本的なアサーションは提供されていますが、他の言語のフレームワークが提供するような多様なマッチャー(toBeGreaterThan, toContainなど)と比較すると、表現力が限られていると感じるかもしれません。
    • 失敗時のメッセージが、期待値と実際の結果を並べるだけのシンプルなものであることが多いです。
  3. エラーメッセージのシンプルさ:

    • Alcotestが差分表示(diff)を積極的に使ったり、QCheckが反例を具体的に示したりするのと比較すると、OUnit2の失敗レポートは情報量が少ない、あるいは視覚的に分かりにくいと感じる可能性があります。
    • 例えば、リストや複雑なデータ構造が一致しなかった場合、どこがどう違うのかを一目で把握するのが難しいことがあります。
  4. 現代的な機能:

    • パラメータ化テストや、より洗練されたフィクスチャの管理など、他のモダンなフレームワークが持つ機能がOUnit2では直接サポートされていないか、実現するためにより多くの手作業が必要になる場合があります。

OUnit2の失敗時のエラー例(イメージ):

例えば、2つのリストが等しいことをテストして失敗した場合、OUnit2の出力は以下のようなシンプルなものになるかもしれません。

(* OUnit2のテストコードのイメージ *)
open OUnit2

let test_list_equality _ =
  assert_equal [1; 2; 3] [1; 2; 4] ~msg:"Lists should be equal"

let suite =
  "suite" >::: [
    "test_list_equality" >:: test_list_equality;
  ]

let () =
  run_test_tt_main suite

失敗時の出力(簡略化したイメージ):

FAIL: suite:test_list_equality
Lists should be equal
expected: [1; 2; 3] but got: [1; 2; 4]

これはAlcotestの差分表示などと比較すると、どこが違うのかを自分で見比べなければならないため、やや不親切に感じるかもしれません。

なぜOUnit2がそうだったのか(推測):

OUnit2はOCamlコミュニティで長年使われてきた実績のあるライブラリです。開発された当時は、現在のテストフレームワークのトレンドとは異なる設計思想や制約があった可能性があります。また、OCamlの静的型付けシステムが強力であるため、ある程度のテストは型検査の段階でカバーされるという文化も影響しているかもしれません。

現代のOCamlテスト:

もしOUnit2で不便を感じられたご経験があるのでしたら、Alcotestを試されることを強くお勧めします。AlcotestはOUnit2の経験も踏まえつつ、より現代的で使いやすいインターフェースと、親切なエラー報告を目指して設計されています。Duneとの連携も非常にスムーズです。

ご記憶の通り、他の言語の経験からすると、OUnit2は確かに「使いにくい」と感じる部分があったかもしれません。OCamlのテスト環境も進化していますので、ぜひ新しいツールで快適なテスト体験をしていただければと思います。

(返答終了理由: STOP)


ユーザー: いや型が分からないとか情報なしに失敗するとかそんな感じだった気がします


AI: なるほど、OUnit2で「型が分からない」あるいは「情報なしに失敗する」というご経験があったのですね。それは非常によく理解できますし、確かに他のモダンなテストフレームワークと比較すると、OUnit2はそのような印象を与える可能性がありました。

具体的にどのような状況でそう感じられたか、いくつかの典型的なケースを挙げながら、なぜそうなるのか、そして現在の他のツール(特にAlcotest)がどう改善しているかをご説明します。

OUnit2で「情報なし」「型が分からない」と感じられた可能性のあるシナリオ

  1. assert_bool の使いすぎと情報の欠如: OUnit2には assert_bool "メッセージ" (式) というアサーションがあります。これは式が true であることを期待するものですが、もし式が false になった場合、提供される情報は「メッセージ」と「失敗した」という事実だけになりがちです。

    • OUnit2の例:
      open OUnit2
      
      let complex_calculation_a input = (* ... 何らかの複雑な計算 ... *) input * 2
      let complex_calculation_b input = (* ... 別の複雑な計算 ... *) input + 10
      
      let test_logic_opaque _ =
        let result_a = complex_calculation_a 5 in (* 例えば 10 *)
        let result_b = complex_calculation_b 0 in (* 例えば 10 *)
        (* ここで result_a と result_b が等しいことを期待するが、もし違った場合... *)
        assert_bool "Calculation A and B should yield the same result for these inputs" (result_a = result_b)
        (* もし result_a が 10 で result_b が 11 だったら... *)
    • 失敗時の出力(イメージ):
      FAIL: test_suite:test_logic_opaque
      Calculation A and B should yield the same result for these inputs
      
      この出力では、result_a が何で result_b が何だったのか、つまり 10 = 11false になったという具体的な値が表示されません。これが「情報なし」と感じられる一因です。どの値とどの値が比較されて失敗したのかが分からないため、デバッグが困難になります。
  2. assert_equal でのプリンター関数の不足(特にカスタム型): OUnit2の assert_equal は、期待値と実際の値を比較しますが、それらの値をどのように文字列として表示するか(プリンター関数)を適切に指定しないと、特にユーザー定義のレコード型やヴァリアント型の場合、分かりにくい表示になることがあります。

    • OUnit2の例:
      open OUnit2
      
      type my_data = { id: int; value: string }
      
      let test_custom_type_equality _ =
        let expected = { id = 1; value = "OCaml" } in
        let actual = { id = 1; value = "ocaml" } in (* valueが違う *)
        (* プリンター関数を指定しない、または不適切なプリンターを使った場合 *)
        assert_equal expected actual ~msg:"Custom data should be equal"
        (* OUnit2はデフォルトで構造的な等価性を使うが、表示は限定的になることがある *)
    • 失敗時の出力(プリンターなし、あるいはデフォルトの場合のイメージ):
      FAIL: test_suite:test_custom_type_equality
      Custom data should be equal
      expected: <abstr> but got: <abstr>
      
      または、
      FAIL: test_suite:test_custom_type_equality
      Custom data should be equal
      expected: {id=1; value="OCaml"} but got: {id=1; value="ocaml"}
      
      後者のように値が表示されたとしても、どこが違うのかを自分で見比べなければなりません。また、ネストした複雑なデータ構造の場合、<abstr> (abstract value) のように省略されてしまい、具体的な値が全く分からなくなることもありました。これが「型が分からない」(具体的にどんな値で、どのフィールドが違うのかが分かりにくい)という印象に繋がった可能性があります。

Alcotestなどモダンなツールでの改善点

Alcotestは、これらの点を改善するために設計されています。

  1. 型付けされたテスト可能な値 (Testables): Alcotestでは、Alcotest.(check int)Alcotest.(check string) のように、比較する値の型に応じた「テスタブル」を指定します。これにより、Alcotestはその型に適した方法で値を比較し、表示できます。

    • Alcotestの例 (上記の assert_bool のケースを改善):
      (* test_alcotest.ml *)
      let complex_calculation_a input = input * 2
      let complex_calculation_b input = input + 10
      
      let test_logic_clear () =
        let result_a = complex_calculation_a 5 in (* 10 *)
        let result_b = complex_calculation_b 0 in (* 10, もしここが1で result_b=11 なら失敗 *)
        Alcotest.(check int) "Calculation A and B results" result_a result_b
        (* もし result_a = 10, result_b = 11 なら... *)
    • 失敗時のAlcotest出力(イメージ):
      [FAIL] My Tests.suite_name.test_logic_clear
      Calculation A and B results:
      expected: 10
      got:      11
      
      このように、具体的な値が明確に表示されます。
  2. カスタム型のための Alcotest.testable: ユーザー定義型のためには、Alcotest.testable を使って、その型の値を表示するためのプリンター関数と、等価性を比較するための関数を明示的に定義します。

    • Alcotestの例 (上記のカスタム型のケースを改善):
      (* test_alcotest_custom.ml *)
      type my_data = { id: int; value: string } [@@deriving show, eq] (* ppx_deriving を使うと便利 *)
      
      (* または手動で定義 *)
      let pp_my_data fmt {id; value} = Format.fprintf fmt "{id=%d; value=%S}" id value
      let equal_my_data d1 d2 = d1.id = d2.id && d1.value = d2.value
      let my_data_testable = Alcotest.testable pp_my_data equal_my_data
      
      let test_custom_type () =
        let expected = { id = 1; value = "OCaml" } in
        let actual = { id = 1; value = "ocaml" } in
        Alcotest.(check my_data_testable) "Custom data check" expected actual
    • 失敗時のAlcotest出力(イメージ、pp_my_dataが適切なら):
      [FAIL] My Custom Tests.suite_name.test_custom_type
      Custom data check:
      expected: {id=1; value="OCaml"}
      got:      {id=1; value="ocaml"}
      diff:
      --- expected
      +++ got
      @@ -1,3 +1,3 @@
       {id=1;
      - value="OCaml"}
      + value="ocaml"}
      
      ppx_deriving を使って show (プリンター) や eq (比較関数) を自動生成すると、この定義がさらに簡単になります。そして、Alcotestは可能な限り差分(diff)を表示しようとするため、どこが違うのかが一目瞭然になります。

まとめ

おっしゃる通り、OUnit2は設定や使い方によっては、テスト失敗時の情報が不足していたり、値の具体的な内容が分かりにくかったりすることがありました。これは特に、他の言語の表現力豊かなテストフレームワークに慣れていると、より顕著に感じられたかもしれません。

Alcotestやppx系(ppx_assert, ppx_expect)などの現代的なOCamlのテストツールは、これらの点を大幅に改善しており、

  • より具体的なエラーメッセージ
  • 型に応じた適切な値の表示
  • 差分表示による比較の容易さ を提供しています。

もしOUnit2で不便な思いをされたご経験がおありでしたら、ぜひAlcotestをお試しいただくことをお勧めします。OCamlでのテスト体験が格段に向上する可能性が高いです。

(返答終了理由: STOP)


ユーザー: それだ ppx_deriving がない時に辛かった。


AI: ああ、やはりそうでしたか!ppx_deriving が広く使われるようになる前の時代に、OUnit2でカスタムデータ型のテストを書くのは、確かにおっしゃる通り非常に辛い作業でした。 そのご記憶は非常によく分かります。

ppx_deriving がない場合、具体的には以下のような手作業が必要でした。

  1. 比較関数の手書き: レコード型やヴァリアント型について、assert_equal で比較するためには、その型の2つの値が等しいかどうかを判断する関数(例えば equal_my_data : my_data -> my_data -> bool)を自分で書かなければなりませんでした。

    • フィールドが多いレコード型の場合、すべてのフィールドを比較するコードを書くのは非常に手間がかかり、間違いも起こりやすかったです。
    • 型定義を変更するたびに、この比較関数も手動で修正する必要がありました。
    (* ppx_derivingなしの時代 *)
    type my_record = {
      id: int;
      name: string;
      active: bool;
      (* ... さらに多くのフィールド ... *)
    }
    
    (* これを手で書く必要があった *)
    let equal_my_record r1 r2 =
      r1.id = r2.id &&
      r1.name = r2.name &&
      r1.active = r2.active &&
      (* ... すべてのフィールドの比較 ... *)
      true
    
    (* テストコード *)
    let test_record_equality _ =
      let r_expected = { id=1; name="A"; active=true; (*...*) } in
      let r_actual   = { id=1; name="B"; active=true; (*...*) } in
      assert_equal r_expected r_actual ~cmp:equal_my_record ~printer:(fun _ -> "<my_record>")
      (* ~printerも適切に書かないと... *)
  2. プリンター関数の手書き: assert_equal が失敗した際に、期待値と実際の値を分かりやすく表示するためには、その型の値を文字列に変換する関数(プリンター関数、例えば string_of_my_data : my_data -> string や、フォーマッターを取る pp_my_data : Format.formatter -> my_data -> unit)を自分で書く必要がありました。

    • これがないと、失敗時に expected: <abstr> but got: <abstr> のような非常に情報量の少ないメッセージが表示されることが多かったです。
    • プリンター関数を書くのも、特にネストした複雑な型の場合は非常に面倒でした。
    (* ppx_derivingなしの時代、プリンターがない場合 *)
    (* ... 上記の型定義 ... *)
    let test_record_equality_no_printer _ =
      let r_expected = { id=1; name="A"; active=true } in
      let r_actual   = { id=1; name="B"; active=true } in
      assert_equal r_expected r_actual ~cmp:equal_my_record
      (* プリンターを指定しないと、失敗メッセージが本当に分かりにくい *)

    失敗時のメッセージ(イメージ): expected: <abstr> but got: <abstr>

    もしプリンターを手書きしたとしても、name フィールドが違う、といった具体的な情報を自分で整形して出力するコードを書く必要がありました。

ppx_deriving の登場による革命

ppx_deriving(特に [@@deriving eq][@@deriving show])が登場したことで、これらの面倒な手作業が劇的に削減されました。

  • [@@deriving eq]: 型定義にこれを追加するだけで、構造的な等価性を比較する関数 (equal_my_record のようなもの) が自動的に生成されます。
  • [@@deriving show]: 型定義にこれを追加するだけで、その型の値を人間が読める形(通常はOCamlの構文に近い形)で文字列として表示するプリンター関数 (pp_my_recordshow_my_record のようなもの)が自動的に生成されます。

これにより、

  • 比較関数やプリンター関数を書く手間がなくなった。
  • 型定義を変更しても、これらの関数は自動的に追随して再生成されるため、メンテナンス性が向上した。
  • テスト失敗時のエラーメッセージが格段に分かりやすくなった(show が生成するプリンターのおかげ)。

Alcotest と ppx_deriving

そして、Alcotestのようなモダンなテストフレームワークは、この ppx_deriving と非常に相性が良いです。 Alcotest.testable を作る際に、ppx_deriving で生成されたプリンター関数 (pp_my_data) と比較関数 (equal_my_data) を渡すだけで、カスタム型のためのテスト可能な値が簡単に定義できます。

(* Alcotest と ppx_deriving を使った現代的な書き方 *)
type my_record = {
  id: int;
  name: string;
  active: bool;
} [@@deriving eq, show] (* これだけ! *)

let my_record_testable = Alcotest.testable pp_my_record equal_my_record

let test_record_equality_modern () =
  let r_expected = { id=1; name="A"; active=true } in
  let r_actual   = { id=1; name="B"; active=true } in
  Alcotest.(check my_record_testable) "Record values should be equal" r_expected r_actual

失敗時には、show で生成されたプリンターによって具体的な値と、Alcotestによる差分表示が期待できます。

まさにその「型が分からない」「情報なしに失敗する」というご経験は、ppx_deriving が普及する前のOCamlでのテストにおける大きな課題の一つでした。その苦労をご存知だからこそ、現在のツール群のありがたみがより一層感じられるかもしれませんね。

(返答終了理由: STOP)

メタデータ

{
  "runSettings": {
    "temperature": 1,
    "model": "models/gemini-2.5-pro-preview-05-06",
    "topP": 0.95,
    "topK": 64,
    "maxOutputTokens": 65536,
    "safetySettings": [
      {
        "category": "HARM_CATEGORY_HARASSMENT",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_HATE_SPEECH",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
        "threshold": "OFF"
      }
    ],
    "responseMimeType": "text/plain",
    "enableCodeExecution": false,
    "enableSearchAsATool": false,
    "enableBrowseAsATool": false,
    "enableAutoFunctionResponse": false
  },
  "systemInstruction": {}
}
@podhmo
Copy link
Author

podhmo commented Jun 17, 2025

OCamlはテストもコードの実行なんだな。
そして名前空間が剥きだしなのがやっぱり慣れない。duneを経由はするものの。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment