現在時刻が関わるユニットテストから、テスト容易性設計を学ぶ

これは何か

和田卓人さん(🦁)の「現在時刻が関わるユニットテストから、テスト容易性設計を学ぶ」を Clojure で挑戦しました。

お題: 現在時刻が関わるテスト

* 問1: 時間に応じて異なるあいさつをする関数を作成する(テスタブルに)
* 問2: ロケールによって異なるあいさつに対応させる(テスタブルに)

この解答例の大きな目的

* 関数とプロトコルによる問題のモデリング過程
* 責務の分割とはどういったことか

問題を解くポイント

今回のお題には大きく二つのテーマがあります。一つ目は時間の表現方法です、例えばタイムゾーンの処理はどうする?秒、ミリ秒には対応する?日本でよく見かける「25時」とかには対応する?

もう一つは翻訳とロケールの処理です、例えば翻訳するべき文言をどう定義する?ロケールへのアクセス方法は?翻訳もれはテストする?

課題の分割方法

今回チャレンジしてみるのは課題を3分割する方法です

(1) システムから現在時刻を取得する役割
(2) 現在時刻から抽象化された挨拶を計算するロジック
(3) 抽象化された挨拶から各言語に表示するための文言を取得する役割

ではまず(1)の定義から始めましょう

;; greets/spec.clj

(defprotocol LocalTime
 "A time without a time-zone in the ISO-8601 calendar system, such as 10:15:30."
 (^Integer get-hour [this] "Gets the hour-of-day."))

現在時刻へのアクセスを行うプロトコルを LocalTime と名付けて問1に必要十分な get-hour メソッドだけを定義しています。

;; greets/core.clj

(ns greets.core
 (:require [greets.spec :as s]
           [tongue.core :as tongue])
 (:import [java.time LocalTime])
 (:gen-class))

(extend LocalTime
 s/LocalTime
 {:get-hour (fn [t] (.getHour t))})

そこから java.time の LocalTime クラスを拡張(*)して我らの LocalTime プロトコルに対応させました。これによって greet 関数は現在時刻の扱いを知る必要はなくLocalTime プロトコルに対応したコラボレーターから取得すればいいことになりますので現在時刻の問題は解決しました。

(*) https://eli.thegreenplace.net/2016/the-expression-problem-and-its-solutions/

次は (2) を書きます、ここではビジネスロジックに集中したいので、ロケールへのアクセス方法などは対応せず、抽象化されたキーワードだけ返して翻訳は外部から提供される translate 関数と locale キーに任せてます。

;; greets/core.clj

(defn greet-for
 "returns a greet for the given time"
 ([time]
  {:pre [(satisfies? s/LocalTime time)]
   :post [(keyword? %)]}
  (let [hour (s/get-hour time)]
    (assert (<= 0 hour 23))
    (cond
      (<= 5 hour 11)  :s/good-morning
      (<= 12 hour 17) :s/good-afternoon
      :else           :s/good-evening)))

 ([time {:keys [translate locale]}]
  {:pre [(and (satisfies? s/LocalTime time)
              (fn? translate)
              (keyword? locale))]
   :post [(string? %)]}
  (let [greet (greet-for time)]
    (translate locale greet))))

ここでのコツですが、各モジュールで使う文言をあらかじめ整理しておくとあとでまとめて翻訳ツールに取り込むときの手間が減らせます。

;; greets/spec.clj

(def translations
 {::good-morning {:message "Good Morning"
                  :description "朝の挨拶 (05:00:00以上 12:00:00未満)"}
  ::good-afternoon {:message "Good Afternoon"
                    :description "お昼の挨拶(12:00:00以上 18:00:00未満)"}
  ::good-evening {:message "Good Evening"
                  :description "夕方と夜の挨拶(18:00:00以上 05:00:00未満)"}})

あと、翻訳もれなどが無いようにテストに組み込むのは技術的に可能ですが、経験上敢えて未翻訳のままテストする場合や別の言語にフォールバックさせる場合もあるのでテストせずそのままにしました。

最後に肝心のテストですが

(ns greets.core-test
 (:require [clojure.test :refer :all]
           [greets.spec :as s]
           [greets.core :refer :all]
           [tongue.core :as tongue]))

(defn local-time-for [{:keys [hour]}]
 (reify s/LocalTime
   (get-hour [_] hour)))

(def translations {:en
                  {:s/good-morning   "Good Morning"
                   :s/good-afternoon "Good Afternoon"
                   :s/good-evening   "Good Evening"}

                  :ja
                  {:s/good-morning   "おはようございます"
                   :s/good-afternoon "こんにちは"
                   :s/good-evening   "こんばんは"}})

(deftest greet-test
 (testing "Spec 01: greetings without locale"
   (are [x expected] (= expected (greet-for (local-time-for {:hour x})))
     0  :s/good-evening
     1  :s/good-evening
     2  :s/good-evening
     3  :s/good-evening
     4  :s/good-evening
     5  :s/good-morning
     6  :s/good-morning
     7  :s/good-morning
     8  :s/good-morning
     9  :s/good-morning
     10 :s/good-morning
     11 :s/good-morning
     12 :s/good-afternoon
     13 :s/good-afternoon
     14 :s/good-afternoon
     15 :s/good-afternoon
     16 :s/good-afternoon
     17 :s/good-afternoon
     18 :s/good-evening
     19 :s/good-evening
     20 :s/good-evening
     21 :s/good-evening
     22 :s/good-evening
     23 :s/good-evening))

 (testing "Spec 02: greetings with locale"
   (let [translate (tongue/build-translate translations)]
     (are [x locale expected] (= expected (greet-for (local-time-for {:hour x})
                                                     {:translate translate
                                                      :locale locale}))
       5  :ja-JP "おはようございます"
       12 :ja-JP "こんにちは"
       18 :ja-JP "こんばんは"
       11 :en-US "Good Morning"
       17 :en-US "Good Afternoon"
       23 :en-US "Good Evening"))))

そんなに多くなかったので全ケーステストしましたが、分単位で内容を変える場合は生成テストなどを導入した方がいいと思います。

あと和田さんのブログに出てきた「アサーションルーレット(Assertion Roulette)」ですが、clojure.test/areマクロで回避しており、ミスった場合はこのような分かりやすい再現しやすいようにエラーメッセージが表示されます。

lein test greets.core-test

lein test :only greets.core-test/greet-test

FAIL in (greet-test) (core_test.clj:23)
Spec 01: greetings without locale
expected: (= :s/good-morning (greet-for (local-time-for {:hour 11})))
 actual: (not (= :s/good-morning :s/good-evening))

lein test :only greets.core-test/greet-test

FAIL in (greet-test) (core_test.clj:51)
Spec 02: greetings with locale
expected: (= "Good Morning" (greet-for (local-time-for {:hour 11}) {:translate translate, :locale :en-US}))
 actual: (not (= "Good Morning" "Good Evening"))

Ran 1 tests containing 30 assertions.
2 failures, 0 errors.
Tests failed.

まとめ

今回はClojureのプロトコルとテストツールを使って実装してみましたが如何でしょうか?本当にプロダクション用のコードでしたらClean Architectureを考慮してもう少し綺麗に分けていたかもしれません。

例えば:
(1) 時間、翻訳ツールなどはアプリケーションを横断して社内である程度統一した方がいいと思いますのでappication business logicのレイヤーに移す
(2) 抽象化された文言から人間が読む文言に翻訳するところはviewsやUIに近いので別のレイヤーに移す

いいなと思ったら応援しよう!