ClojureでRDDとTDDのハイブリッドな開発スタイルを実践しよう
Clojure Advent Calendar 2024 24日目の記事です🎄
はじめに
Lispの系譜に連なる関数型のJVM言語Clojureには、その動的な性質を最大限に活かした高機能なREPLベースの開発環境があります。
REPLと統合された開発環境を活用して局所的に評価してこまめに動かしながらコードを書き進めるLispらしい開発スタイルは 「REPL駆動開発」(RDD; REPL-driven development)と呼ばれることがあります。
REPL駆動開発の基本については過去にClojureでREPL駆動開発を始めようという記事で紹介したこともありました。
この"RDD"は単独でも効率的かつ効果的ですが、 テスト駆動開発(TDD; test-driven development)と組み合わせることができ、両者の相乗効果によってさらに高速で安全で探索的な開発体験が得られる可能性があります。
ここでは定番のFizzBuzz問題を例に、RDDとTDDのハイブリッドな実践例を簡単に紹介します[1]。
🐬のRDD & TDDスタイルの作業風景
S式編集支援機能(Paredit, Smartparensなど)も活用しながら式を書き換え、エディタにシームレスに繋がったREPLを介して式を評価して結果を確かめる、という操作を短時間のうちに交互に繰り返す(そして小さな式から大きな式を組み立てていく)のがRDDの基本です。
そこに、テストコードに導かれながら動作する最小限のコードを積み上げていくTDD的なスタイルを組み込んでいます。
🐬のRDD & TDDスタイルの開発の流れ
1. [事前準備] プロダクトコードとテストコードのためのファイルを用意する
ここでは以下のようなファイルを用意して、Clojure開発環境構築済みのエディタ/IDEからREPLに接続[2]しておきます。
テストコード用のファイルにはあらかじめ想定仕様を一通り書き出してみました。
(ns clj-tdd-with-rdd.core)
(defn fizzbuzz [n])
(ns clj-tdd-with-rdd.core-test
(:require
[clj-tdd-with-rdd.core :as sut]
[clojure.test :as t]))
(t/deftest test-fizzbuzz
(t/testing "3の倍数ならFizz")
(t/testing "5の倍数ならBuzz")
(t/testing "3の倍数かつ5の倍数ならFizz Buzz")
(t/testing "3の倍数でも5の倍数でもなければ整数の文字列"))
私の開発環境(Spacemacs + Clojure layer)では、(ここでは上下にレイアウトして)ファイルを開きREPLに接続するとこのような見た目になりました。
2. 「3の倍数ならFizz」という仕様を満たすようにする
まずは初期状態で (fizzbuzz 3)
を評価してみると、(予想通り)期待と異なる結果になります。
最も単純な対応として固定値 "Fizz"
を返す関数に書き換えてその関数定義を評価し、再び (fizzbuzz 3)
を評価してみると、今度は(当然ながら)期待通りの結果が得られました。
ちなみに、Clojure/Lispの開発環境では局所評価した結果を一時的に表示するだけでなくコメントとして書き出す操作[3]が用意されている場合もあります(評価結果を記録したり詳細に確認したり再利用したりする場合に便利です)。
評価結果が妥当そうなので (t/is (= expected actual))
という形の式に書き換えてテストケースとして記録します。
この式も試しに評価してみると true
になりました。
(現在はまだ1ケースのみですが)テスト全体を実行してみると、テスト結果green 🟢であることが確認できました。
入力が 3
以外の例もテストに追加して期待通りに振る舞うかどうか確かめます。
例えばこのような流れで、こまめに局所的に式を評価して評価結果を確認したり再利用したりするRDDをしながら、スピーディーにTDDスタイルでの実装を進めることができます。
個々のテストケースを完成させなくてもテスト対象関数の動作を先に確かめることが可能なため、テストケースを書き上げて初めて動かすのではなく、 動かした結果が妥当なら必要に応じてテストケースとして記録する という使い方ができます。
また、評価結果の全部または一部をデータとしてそのままテストケースに使うこと(評価結果をコピーしてexpectedの位置に貼り付けるなど)ができ、関数の入出力が複雑な場合には特に便利です。
3. 「5の倍数ならBuzz」という仕様を満たすようにする
途中過程は省略して、このように書き進めてみました。
4. 「3の倍数かつ5の倍数ならFizz Buzz」という仕様を満たすようにする
同じく途中過程は省略して、このように書き進めてみました。
5. 「3の倍数でも5の倍数でもなければ整数の文字列」という仕様を満たすようにする
さらに途中過程は省略して、このように書き進めてみました。
6. リファクタリング
基本的な仕様を一通り満たせたようなので、TDDの基本に従ってテストコードに守られながらリファクタリングを試みます。
ここでは (zero? (mod n 3))
と (zero? (mod n 5))
が複数回現れるのがあまりスマートではないかもと考えて以下のように書き換えてみました。
1〜30の整数に適用した結果をまとめて確認してみたところ良さそうなので、テストコード全体を再実行して外形的な振る舞いが変わらないことを確認しました😌
7. [おまけ] 整数以外の数、0以下の整数に対する振る舞いを確かめる
現在の実装では分数や(小数部が0でない)小数(e.g. 1/2
, 2.5
)に対してもそれらしく動作しますが、FizzBuzz問題の前提を考えると入力は正の整数の範囲に限定されるのが妥当そうです。
そこで、標準ライブラリclojure.specを利用して関数の引数と戻り値の仕様を明確化することにします。
fdefで関数のspecを定義し、instrumentで引数に対するチェックを有効化してから再び分数に対して試してみると、想定通りspec違反でエラーになります。
clojure.specで関数に対するspecを定義した場合にテストケースで自動的に事前に有効化されるようにするには、use-fixtureを利用して以下のように設定する方法があります。
(t/use-fixtures
:once (fn [f]
(stest/instrument)
(f)))
(spec違反に対するテストケースは静的型付き言語における型チェックに準じて省略しても良い状況が多そうですが)テストケース化するとしたら例えば以下のように書けます。
8. 最終形
最終的に以下のようなコードに仕上がりました🎉
(ns clj-tdd-with-rdd.core
(:require
[clojure.spec.alpha :as s]
[clojure.string :as str]))
(s/fdef fizzbuzz
:args (s/cat :n pos-int?)
:ret string?)
(defn fizzbuzz [n]
(-> (cond-> []
(zero? (mod n 3)) (conj "Fizz")
(zero? (mod n 5)) (conj "Buzz"))
seq
(or [n])
(#(str/join " " %))))
(comment
(map fizzbuzz (range 1 (inc 30)))
)
(ns clj-tdd-with-rdd.core-test
(:require
[clj-tdd-with-rdd.core :as sut]
[clojure.spec.test.alpha :as stest]
[clojure.test :as t]))
(t/use-fixtures
:once (fn [f]
(stest/instrument)
(f)))
(t/deftest test-fizzbuzz
(t/testing "3の倍数ならFizz"
(t/is (= "Fizz"
(sut/fizzbuzz 3)))
(t/is (= "Fizz"
(sut/fizzbuzz 9)))
(t/is (= "Fizz"
(sut/fizzbuzz 21))))
(t/testing "5の倍数ならBuzz"
(t/is (= "Buzz"
(sut/fizzbuzz 5)))
(t/is (= "Buzz"
(sut/fizzbuzz 25)))
(t/is (= "Buzz"
(sut/fizzbuzz 40))))
(t/testing "3の倍数かつ5の倍数ならFizz Buzz"
(t/is (= "Fizz Buzz"
(sut/fizzbuzz 15)))
(t/is (= "Fizz Buzz"
(sut/fizzbuzz 30)))
(t/is (= "Fizz Buzz"
(sut/fizzbuzz 75))))
(t/testing "3の倍数でも5の倍数でもなければ整数の文字列"
(t/is (= "2"
(sut/fizzbuzz 2)))
(t/is (= "11"
(sut/fizzbuzz 11)))
(t/is (= "29"
(sut/fizzbuzz 29))))
(t/testing "正の整数以外に対して動作しない"
(t/is (thrown-with-msg?
clojure.lang.ExceptionInfo #"did not conform to spec"
(sut/fizzbuzz 1/2)))
(t/is (thrown-with-msg?
clojure.lang.ExceptionInfo #"did not conform to spec"
(sut/fizzbuzz 2.5)))
(t/is (thrown-with-msg?
clojure.lang.ExceptionInfo #"did not conform to spec"
(sut/fizzbuzz 0)))))
おわりに
ClojureのREPLベースの開発環境を活かしたRDDとTDDのハイブリッドな開発スタイルを簡単に紹介しました。
Clojure言語に私🐬が長らく惹かれている理由はどこにあるだろうかと改めて考えてみると、
- 関数型言語として: (副作用が局所化されるという意味で)安全で、抽象的/宣言的なプログラミング
- JVM言語として: Java/JVMエコシステムと繋がっていることによる実用上の利便性
- Lispとして: REPLと強く結び付いた開発環境の快適さ、コードとデータが同形(homoiconic)であることによる扱いやすさ
というように、この言語の基本的な3つの属性それぞれに魅力を感じていることを再認識しました。
私は広く関数型言語が好きですが、他の関数型言語にはない総合的なプログラミング体験が楽しくて便利でついつい使いたくなってしまいます。
Clojure/LispのS式を読み書きする不思議な心地良さが少しでも伝わればうれしいです。
Further Reading
- ClojureでREPL駆動開発を始めよう - Qiita
- Clojureで快適なREPL駆動開発のために"reloaded workflow"を実践しよう - Qiita
- Clojure開発環境での基本操作まとめ: Spacemacs, IntelliJ IDEA (Cursive), VS Code (Calva), Vim (vim-iced), rebel-readline - Qiita
- Clojure REPL: The Good Parts | ドクセル
-
Clojurian (Clojure使い)を含むLisperはおそらくそれぞれに好んで使うエディタ/IDEとREPLの操作や開発のフローがあり、ここで示すのはあくまで私lagénorhynqueによる一例です。 ↩︎
-
操作方法の参考に: Clojure開発環境での基本操作まとめ > REPLの起動と接続 ↩︎
-
EmacsプラグインCIDERでは
cider-pprint-eval-last-sexp-to-comment
。 ↩︎
Discussion