48
46

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

現在時刻が関わるコードを関数型で書いてテスタビリティを見てみた

Last updated at Posted at 2019-10-02

最近、現在時刻が関わるプログラムを題材に、高テスタビリティなプログラミング作法を解説した素晴らしい記事が復刻されて、感想などがTLに流れてきたので、自分もそのお題を関数型プログラミングで解いてみた記事。

はじめに

最近、こんな引用ツイートをした。

元記事は、t-wadaさんの『現在時刻が関わるユニットテストから、テスト容易性設計を学ぶ 』で、めちゃくちゃシンプルなお題なのに、解説がすばらしいのはもちろんのこと、寄せられた解答例も面白くて、自分でも書いてみたくなった。

記事の内容は概ねオブジェクト指向寄りなスタイルって気がするけど、個人的にはこの数年、関数型な Scala をやってるので、どちらかというと関数型・宣言型を意識する趣向でやってみたい。

考え方としては、FP界隈ではもともと、シンプルなものをシンプルなまま分けておくことで、自ずとテスタビリティが向上するということがよく言われているので、先にテスタビリティを意識しながら書くというより、性質や関心事が異なるものを分離して書いたら、結果的にテストも書きやすくなる(書きにくくならない)という様子を見てみたい。

(関数型になれてない人でもなるべく読めるようにしたい。。。)

お題

もとのお題は以下のようなものだった。

【仕様1】
「現在時刻」に応じて、挨拶の内容を下記のようにそれぞれ返す機能を作成したい。
(タイムゾーンは Asia/Tokyo とする)


- 朝(05:00:00以上 12:00:00未満)の場合、「おはようございます」と返す
- 昼(12:00:00以上 18:00:00未満)の場合、「こんにちは」と返す
- 夜(18:00:00以上 05:00:00未満)の場合、「こんばんは」と返す

例: 13時に `greeter.greet()` を呼ぶと "こんにちは" と返す

よく考えるとこの一つの機能の中に、3つの観点があるように見える。

  1. 定義域である時刻の集合から値域の挨拶の集合への写像。当然、参照透過1な全関数2になる。
  2. 参照透過性が成立しない、「現在」という文脈 (コンテキスト、エフェクト)に依存する時刻。
  3. 1 と 2 の 合成

以下の実装で、それぞれを分けて書いてみる。

実装

  • 言語は Scala 3.1.2 を使用
  • 関数型プログラミングの補助のため、Cats / Cats Effect を使用
  • 時刻にはシンプルに java.time.LocalTime を使用(明示的な Asia/Tokyo 指定は省略した)
  • テスティングフレームワークは munit ベースで、プロパティテストのために ScalaCheck を併用した

バージョン等の詳細はこの辺り

1. 時刻からあいさつへのマッピング

LocalTime 型のどの値がどのあいさつ文字列に対応付けられるかにのみ着目して、それ以外の関心事を含まない関数3が以下。

greet.scala
def greetingOf(t: LocalTime): String =
  val before = (h: Int, m: Int, s: Int) => t isBefore LocalTime.of(h, m, s)

  if      before( 5, 0, 0) then "こんばんは"
  else if before(12, 0, 0) then "おはようございます"
  else if before(18, 0, 0) then "こんにちは"
  else                          "こんばんは"

まったく一つのことしか扱っていないので、シンプルに書ける。もちろん参照透過も成立する。

テストコード

境界値テストは以下のように書ける。

GreetSuite.scala
test("与えられた時刻があいさつに正しく対応付けられる") {
  List(
    // 時 | 分 | 秒 | ナノ      | 挨拶
    ((  0 ,  0 ,  0 ,         0), "こんばんは"        ),
    ((  4 , 59 , 59 , 999999999), "こんばんは"        ),
    ((  5 ,  0 ,  0 ,         0), "おはようございます"),
    (( 11 , 59 , 59 , 999999999), "おはようございます"),
    (( 12 ,  0 ,  0 ,         0), "こんにちは"        ),
    (( 17 , 59 , 59 , 999999999), "こんにちは"        ),
    (( 18 ,  0 ,  0 ,         0), "こんばんは"        ),
    (( 23 , 59 , 59 , 999999999), "こんばんは"        ),
  ) foreach { case ((h, m, s, n), expected) =>
    val t = LocalTime.of(h, m, s, n)
    assertEquals(greetingOf(t), (expected))
  }
}

実際は、テストを先に書いて Red-Green-Refactor を何度か繰り返して簡単にしたが、見たままのわかりやすいテストコードになっていると思う。

※ 元記事でも、最小単位が秒でよいかと問われてたけど、最近見た BigQuery の集計でミリ秒部分が抜けていて毎日1秒分のデータが無視されていたケースがあったので、ここでは LocalTime の解像度ぎりぎりの境界値を設定しておいた。

2. 現在という文脈上の時刻を得る

Scala では例えば以下のように書ける。

greet.scala
trait Now[F[_]]:
  def time: F[LocalTime]

言葉にすると「F[_] は抽象的に変数化されたある種の文脈であり、Now[F].time と書けば、その文脈の中で LocalTime が得られる」といったものになる。また、Now[F].time をさらに言い換えれば、「現在(Now)という文脈(F)における時刻(time)」を、Scalaの文法でそのままくっつけた感じになる。

F[_]には、同じような型のパターンを持つ Id[_]IO[_] のような具体的なデータタイプが入る(こうした構成自体が「記述と実行の分離」となっていて、関心事の分離の例だったりする)。

デフォルト実装は後回しにして、この Now が、参照透過な greetingOf とどのように合成されるかを次に見る。

3. 合成

F[_]: Functor: Now の部分を解説すると、greetNow が使われるスコープ内に Functor[F](函手4)と Now[F] のインスタンスがあることがコンパイルできる条件ということになる。言い換えれば、Functor[F]Now[F] のインスタンスが暗黙に渡されることになる。

Greeter.scala
def greetNow[F[_]: Functor: Now]: F[String] =
  Now[F].time map greetingOf

上のコードを命令型パラダイム的に解釈すると、

  • 暗黙に与えられた Now[F] インスタンスの time メソッドを呼び出して
  • 返された F[LocalTime] 型の戻り値の中身に、関数greetingOfを適用して
  • F[String] として返しなさい

のように読めるが、宣言型/関数型っぽく捉えれば5F[_]が函手なので greetingOf から F[greetingOf] が得られ、これを Now[F].time合成すると greetNow が得られる。」とも読める。図に書くと以下のようになる。
map_greetingOf.png

テストコード

先に書いた greetingOf を使うと、以下のような自明なプロパティ(性質)が成り立つ。

Now[F].time が返す現在時刻がどんな値でも、その値を greetingOf に与えて得られた挨拶と、greetNow で得た挨拶は等しい。

プロパティが得られると、関数型プログラミングでよく使われるプロパティベーステストに持ち込めるので、ScalaCheck を使って以下のように書ける

GreetSuite.scala
property("現在時刻があいさつに正しく対応付けられる") {
  given Arbitrary[LocalTime] = Arbitrary {
    val hourGen = Gen.choose(0, 23)
    val minGen  = Gen.choose(0, 59)
    val secGen  = Gen.choose(0, 59)
    val nanoGen = Gen.choose(0, 999999999)
    (hourGen, minGen, secGen, nanoGen) mapN LocalTime.of
  }
  forAll { (t: LocalTime) =>
    given Now[Id] with
      def time: Id[LocalTime] = t

    greetNow[Id] == greetingOf(t)
  }
}

これもプロパティをそのまま Scalaコードに落とした感じで、わかりやすいのではと思う。

Id についてだけざっくり解説すると、型Aと型F[A] を区別せずに扱えるような F[_] の一種で、このサンプルの具体例だと LocalTimeId[LocalTime] として使えるようになって、テストコードが簡単になっている。これも「記述と実行を分離」した結果得られる、テスタビリティ向上の一例と言えると思う。

デモ

ここまでのコードを使った少しだけリアルな使用例を書くと、以下のような感じになる。

Greeter.scala
object GreeterDemo extends IOApp.Simple:
  import greet._

  given Now[IO] with
    def time: IO[LocalTime] = IO.delay(LocalTime.now)

  def run: IO[Unit] = 
    ((Now[IO].time map greetingOf) >>= IO.println) >>
    (greetNow[IO]                  >>= IO.println)
  • ここで初めて、プロダクトコード中の Now インスタンスを作ったが、通常は、デフォルト(本物)実装をどこか適当なところにおいておくことになる。
  • F[_] には、非同期・遅延実行を意識して Cats Effect のIOを使ってみた。サンプルなので LocalTime.now としたが、例えば現在時刻取得サーバのような外部サービスにアクセスする場合でも、同じシグネーチャで使える。もちろん Monix や ZIO でもかまわない。

自己レビュー

元記事のテストコードのポイントと照らし合わせてセルフチェックしてみる。

良いユニットテストは Repeatable (繰り返し可能、再現可能)

  • 参照透過性が無くなる境界に Now[Id] 型のテストダブルがあり、テスト内で制御できるようになっている
  • ScalaCheck を使ったところはランダムに値が生成されるので、常に同じ結果が再現されるとは言えないが、どの値で失敗したかは分かるようにレポートされる。

良いユニットテストは Independent (独立している)

  • テストコード含めて副作用のないピュアな関数型コードなので、テストの順序による影響や、後始末忘れによる挙動の変化などはない。

アサーションルーレット(Assertion Roulette)に注意する

  • greetingOf のテストでは、TableDrivenPropertyChecks を使ってパラメータ化テストぽく書いてみたが、失敗時にどのパラメータ×期待結果のペアで失敗したのかレポートされるようになっているものの、一つコケると次に進むことなく止まってしまうのがやや難。

脆いテスト(Fragile Test)に注意する

  • greetingOf が private じゃなくてよいか一瞬迷ったが、内部ロジックというより明らかに仕様の一部なので、むしろ public で良しとした。その他、実装依存で壊れやすいテストコードは特にない気がする。

おわりに/補足

  • シンプルなお題ながら、実にいろいろと考えさせられる。この問題自体が シンプルだけど必ずしもイージーでないもの の一つの例のような気もする
  • 今回は「合成」の部分での Now の与え方として、シンプルな型制約で implicit に渡す方式としたが、他の関数型DI の技法もいくつかある。
  • 特に、仕様2のロケールのような文脈依存値が追加されたりすると、trait Locale[F[_]] を追加してから型制約に F[_]: Now: Locale: ... のように書き足すことになり、増えていくと取り回しやらいろいろ不便になってくることもある。回避策は若干高度な関数型プログラミングの技法になる。
  • 今回は Scala でやったけど、他の関数型言語(Lisp系、ML系)でやっても面白いと思う。
  • 元記事で言及されている『xUnit Test Patterns』は、かなり分厚い本だけど、すくなくともアンチパターンのところだけでも、もっと広く読まれるべき価値がある気がする。
  • 2022/06/21 に、サンプルコードを Scala 3 系と Cats Effect 3 系で書き直した。
  1. 関数呼び出し(式)をその結果の値に置き換えても常にプログラムの振る舞いが変わらないこと

  2. total function。定義域のどの要素をとっても値域のなんらかの要素に対応づけることができる関数。逆に、定義域のある要素を与えると対応付けが得られなくなる(プログラム的には値を返す代りに例外を送出するような)関数は、部分関数という。

  3. Scala 用語では、def で定義したものをクラスの構成要素であることを示してメソッドと言って、関数(値)(function value)と区別したりするけど、ここではもっと一般的な用語法の、集合から集合への写像の同義語としての呼び方の「関数」を採用した。

  4. 圏論的な定義はおいといて、プログラミングに関係するところだけあえて不完全に説明すると、型Aを型Bに対応付ける関数から、型F[A]から型F[B]に対応付ける関数が得られるような構造、というか前提といったもの。Haskell だと fmap :: (a -> b) -> f a -> f bのようなシグネーチャの関数が函手ごとに提供される。

  5. 宣言型なスタイル(declarative style)は Domain-­Driven Design(DDD)でも、むかしから結構強めに称揚されている。

48
46
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
48
46

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?