テスト駆動開発

Ken Wakita (https://prg1-2019.github.io/lecture/web/)

2019-10-15

テスト駆動開発

テスト駆動開発の実際

  • ひとまず,やる気のないコードを作成
    (※レシピの「Step 2:関数定義の準備」を参照)

  • テストを実施するコードを作成
    (※レシピの「Step 3:入出力の例」に沿ってテストコードを記述)

  • 以下を繰り返し

    • テストを実行

    • テストに合格するようにプログラムを修正

    • 想定外のバグを発見 → バグを再現するテストを追加

例:閏年の計算

目標:西暦(y)が与えられたときに,その年が閏年か否かを答える関数isLeap1を作成しなさい.

日本における閏年の根拠法
明治三十一年勅令第九十号

  • 明治三十一年勅令第九十号(閏年ニ関スル件・明治三十一年五月十一日勅令第九十号

    • 神武天皇即位紀元年数ノ四ヲ以テ整除シ得ヘキ年ヲ閏年トス

    • 但シ紀元年数ヨリ六百六十ヲ減シテ百ヲ以テ整除シ得ヘキモノノ中更ニ四ヲ以テ商ヲ整除シ得サル年ハ平年トス

日本における閏年の根拠法
明治三十一年勅令第九十号

  • 明治三十一年勅令第九十号(閏年ニ関スル件・明治三十一年五月十一日勅令第九十号
    • 神武天皇が即位した年を紀元とする年数2(これが紀元年数)が四で割り切れるものを閏年とする
    • ただし、紀元年数から660を減じたもの3が100で割り切れるもののうち、さらにその商が4で割り切れないもの4は平年とする。

早い話が

  • グレゴリオ暦では、次の規則に従って400年間に(100回ではなく)97回の閏年を設ける。

    • 西暦年が4で割り切れる年は閏年

    • ただし、西暦年が100で割り切れる年は平年

    • ただし、西暦年が400で割り切れる年は閏年

A: 空のコードとテストを用意する

↓ GitHub 上のコードへのリンクが埋め込まれています。
package a

/** TDDのステップ
 *  0: 空のコードとテストを用意する。
 **/

object Calendar {
}

object A extends App {
  import Calendar._  // Calendarオブジェクト内の定義をすべて読み込む

  println("おめでとうございます。すべてのテストをパスしました!")
}

B: やる気のないコード

やる気のないコードとして、これ以上はないほど愚かなコードを作る.型だけは仕様に合わせる.

object Calendar {
  /**
   * 入力:西暦の年数(y)を表す正の整数 (Int)
   * 出力:閏年か否かにあたる論理値 (Boolean)
   * 契約:y > 0
   **/
  def isLeap(y: Int) : Boolean = {
    true
  }
}

B: 実行

sbt:lx05> runMain b.A
[warn] Multiple main classes detected.  Run 'show discoveredMainClasses' to see the list
[info] Running (fork) b.A
[info] おめでとうございます。すべてのテストをパスしました!
[success] Total time: 1 s

C: コーディング

テストのためのコードを作成
– 完璧でなくてよい

object A extends App {
  import Calendar._  // Calendarオブジェクト内の定義をすべて読み込む

  {
    val msg = "4で割り切れる年は閏年である"
    assert(isLeap(2016), msg)
    assert(isLeap(2020), msg)
  }

  println("おめでとうございます。すべてのテストをパスしました!")
}

C: 実行

テストは成功!

もしかして完成しちゃった?

sbt:lx05> runMain c.A
[warn] Multiple main classes detected.  Run 'show discoveredMainClasses' to see the list
[info] Running (fork) c.A
[info] おめでとうございます。すべてのテストをパスしました!
[success] Total time: 1 s

と、喜んでいると、天の声

  • 曰く「4で割り切れない年は平年」

    • 「やばい、ばれてる。テストを追加しなくちゃ」

D: テストの追加

4で割り切れない年のテストを追加

object A extends App {
  import Calendar._  // Calendarオブジェクト内の定義をすべて読み込む

  {
    val msg = "4で割り切れる年は閏年である"
    assert(isLeap(2016), msg)
    assert(isLeap(2020), msg)
  }

  {
    val msg = "4で割り切れない年は閏年ではない"
    assert(!isLeap(2017), msg)
    assert(!isLeap(2018), msg)
    assert(!isLeap(2019), msg)
  }

  println("おめでとうございます。すべてのテストをパスしました!")
}

D: 実行

当然、テストは失敗する。

sbt:lx05> runMain d.A
[warn] Multiple main classes detected.  Run 'show discoveredMainClasses' to see the list
[info] Running (fork) d.A
[error] Exception in thread "main" java.lang.AssertionError: assertion failed: 4で割り切れ
ない年は閏年ではない
[error]         at scala.Predef$.assert(Predef.scala:219)
[error]         at d.A$.delayedEndpoint$d$A$1(d.scala:33)
[error]         at d.A$delayedInit$body.apply(d.scala:22)
...
☆☆☆ とても長いスタックトレースはバッサリと省略 ☆☆☆

D: テストの内容を確認

もちろん33行目付近のテストは正しい。

{
    val msg = "4で割り切れない年は閏年ではない"
    assert(!isLeap(2017), msg)
    assert(!isLeap(2018), msg)
    assert(!isLeap(2019), msg)
  }

D: プログラムの問題を探す

(探すまでもなく,明らかだが)、以下を修正して,leapyear(2001) → false となるようにすればよい.

def isLeap(y: Int) : Boolean = {
    true
  }

でも、ここで敢て(半端に)ずる賢い変更を施してみよう

def isLeap(y: Int) : Boolean = {
    false
  }

E: 小狡いやり方は失敗するので、真面目に対応

4で割り切れば閏年なんでしょ?

def isLeap(y: Int) : Boolean = {
    y % 4 == 0
  }

E: 実行

sbt:lx05> runMain e.A
[warn] Multiple main classes detected.  Run 'show discoveredMainClasses' to see the list
[info] Running (fork) e.A
[info] おめでとうございます。すべてのテストをパスしました!
[success] Total time: 1 s

もっとテスト!

F: 調子にのって、テストを追加

{
    val msg = "100で割り切れる年は閏年ではない"
    assert(!isLeap(1800), msg)
    assert(!isLeap(1900), msg)
    assert(!isLeap(2000), msg)
  }

F: 実行

こけるのは想定内(TDDだからね)

sbt:lx05> runMain f.A
[warn] Multiple main classes detected.  Run 'show discoveredMainClasses' to see the list
[info] Running (fork) f.A
[error] Exception in thread "main" java.lang.AssertionError: assertion failed: 100で割り切
れる年は閏年ではない
[error]         at scala.Predef$.assert(Predef.scala:219)
[error]         at f.A$.delayedEndpoint$f$A$1(f.scala:42)
[error]         at f.A$delayedInit$body.apply(f.scala:24)
[error]         at scala.Function0.apply$mcV$sp(Function0.scala:34)
[error]         at scala.Function0.apply$mcV$sp$(Function0.scala:34)
...
☆☆☆ とても長いスタックトレースはバッサリと省略 ☆☆☆

G: テストにあわせて修正

def isLeap(y: Int) : Boolean = {
    !(y % 100 == 0) &&
    y % 4 == 0
  }

G: 実行

sbt:lx05> runMain g.A
[warn] Multiple main classes detected.  Run 'show discoveredMainClasses' to see the list
[info] Running (fork) g.A
[info] おめでとうございます。すべてのテストをパスしました!
[success] Total time: 1 s

仕様をよく読もう

H: 天の声 いやいや,まだ駄目でしょ

  • 曰く「ただし西暦年が400で割り切れる年は閏年」

    • ということは,2000年とか1600年は閏年?

H: テストの間違いを修正

400で割り切れる例外を忘れてた!

{
    val msg = "100で割り切れる年は(4で割り切れたとしても)閏年ではない"
    assert(!isLeap(1800), msg)
    assert(!isLeap(1900), msg)
  }

  {
    val msg = "(100で割り切れるけれど)400で割り切れる年は閏年"
    assert(isLeap(1600), msg)
    assert(isLeap(2000), msg)
  }

I: テストにあわせて修正

def isLeap(y: Int) : Boolean = {
    y % 400 == 0 ||
    !(y % 100 == 0) &&
    y % 4 == 0
  }
sbt:lx05> runMain i.A
[warn] Multiple main classes detected.  Run 'show discoveredMainClasses' to see the list
[info] Running (fork) i.A
[info] おめでとうございます。すべてのテストをパスしました!
[success] Total time: 1 s

J: 天から再びお告げが

  • 曰く「勅令の施行日というものを知っておるかな?」

    世の中には、明示されない仕様というものがあります

    • げっ!施行前には閏年という概念がなかったのか?
      ↑ (妙に賢い学生)

J: 明治31年(= 1898年)より前には閏年は未定義

J1
y < 1898年 を入力仕様違反として無視するアプローチ
J2
y < 1898年ならエラーにするアプローチ
J3
y < 1898年なら未定値とするアプローチ

J1: y < 1898年を入力仕様違反として無視

ご存知assertを利用する。

def isLeap(y: Int) : Boolean = {
    assert(y >= 1898)
    y % 400 == 0 ||
    !(y % 100 == 0) &&
    y % 4 == 0
  }

J2: y < 1898年ならエラー

require

def isLeap(y: Int) : Boolean = {
    require(y >= 1898)
    y % 400 == 0 ||
    !(y % 100 == 0) &&
    y % 4 == 0
  }

assert vs require

assert

Tests an expression, throwing an AssertionError if false. Calls to this method will not be generated if -Xelide-below is greater than ASSERTION.

ソフトウェア製品版は assert を省略するようにコンパイラを設定することが普通(検査のコストを減ずるため)

require

Tests an expression, throwing an IllegalArgumentException if false. This method is similar to assert, but blames the caller of the method for violating the condition.

どんな状況においても、この検査は実施される

検査が失敗したときに発せられる例外が異なることにも注意

例外が発生することを検査するには?

{
    val msg = "勅令施行前は未定義"
    def assertShouldFail(y: Int): Unit = {
      try {
        isLeap(y)
        assert(false, msg + ":isLeap が正常終了してはいけない")
      } catch {
        case e: AssertionError => assert(true)
        case _ => assert(false, msg + ": 例外の種類がおかしい")
      }
    }

    assertShouldFail(1600)
    assertShouldFail(1800)
    assertShouldFail(1897)
  }

J3: y < 1898年なら未定値

Option[T] ::= None | Some(v)
  • Option[T]

    • 値が定まっていない場合: None

    • 値(v : T)が定まってる場合: Some(v)

    • Option[T] 型から値を取り出すときはパターンマッチ

J3: テストの変更

def assertEq(y: Int, b: Boolean): Unit = {
    isLeap(y) match {
      case None    => assert(false)
      case Some(x) => assert(x == b)
    }
  }

  {
    val msg = "4で割り切れる年は閏年である"
    assertEq(2016, true)
    assertEq(2020, true)
  }

  ... ばっさりと省略 ...

  {
    val msg = "勅令施行前は未定義"
    assert(isLeap(1600) == None)
    assert(isLeap(1800) == None)
    assert(isLeap(1897) == None)
  }

  {
    val msg = "勅令施行後は通常"
    assertEq(1898, false)
    assertEq(1899, false)
    assertEq(1900, false)
  }

J3: 実装の変更

  • 出力: BooleanOption[Boolean]
  • 契約の追加
/**
   * 入力:西暦の年数(y)を表す正の整数 (Int)
   * 出力:閏年か否かにあたる論理値 (Option[Boolean])
   * 契約:y >= 1898 なら Some(閏年?)、さもなくば None
   **/
  def isLeap(y: Int) : Option[Boolean] = {
    if (y < 1898) None
    else {
      Some(y % 400 == 0 ||
          !(y % 100 == 0) &&
          y % 4 == 0)
    }
  }

考えてみましょう。

どうやってテストする?

  • rotate (テストが書けないから、コーディングできないとか?)

  1. leap-year(形容詞)
    うるう年の↩︎

  2. これが紀元年数↩︎

  3. 神武天皇の即位の年は西暦(−660)年とされている↩︎

  4. つまり、西暦換算が400で割り切れないものについて語っている↩︎