Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Spockで学ぶテスト駆動開発のコツ

 Spockで学ぶテスト駆動開発のコツ

JJUG CCC 2021 Springでの講演資料

Avatar for yonetty

yonetty

May 23, 2021
Tweet

More Decks by yonetty

Other Decks in Programming

Transcript

  1. テストコードは具体的であるため 放っておくと散らかる public static double avg(double... numbers) { if (numbers.length

    == 0) { throw new IllegalArgumentException(); } double sum = 0; for (double d: numbers) { sum += d; } return sum / numbers.length; } {} //空の配列 {1.0} //要素1つ {1.0, 2.3} //要素2つ {1.0, 2.3, 3.4} //要素3つ … プロダクトコード テストコード 引数の数を変えて具体値で テストケースを記述する 8
  2. result == ["cat", "dog"] // Listのリテラル表記 // [cat: 3, dog:

    4] (Mapリテラル) ListやMapなどのコレクション リテラルがあるので、簡単に書ける == による等価判定(Javaのeqauals) 11
  3. where: unitPrice | quantity || expectedAmount 100 | 0 ||

    0 0 | 5 || 0 100 | 1 || 100 100 | 2 || 200 テストの入力値や期待値のセット を表形式で簡潔に定義できる (パラメータ化テスト) 12
  4. 1. Setup 前処理 テストの事前条件(テストフィクスチャ)をそろえる 2. Exercise 実行 テスト対象処理(SUT)を呼び出す 3. Verify

    検証 実行結果(事後条件)が期待通りかを確認する 4. Teardown 後処理 必要な場合、後始末を行う 15
  5. class SampleSpec extends Specification { def "引数が2つの場合に平均値を取得できる"() { given: "引数が2つの配列がある"

    def numbers = [1.2, 1.8] as double[] when: "平均値を求める" def avg = Sample.avg(numbers); then: "平均値が正しい" avg == 1.5 } } ブロックラベル: BDDスタイルの given-when-then がおすすめ 17 文字列をメソッド名に できるのでテスト ケース名を記述 givenには 前処理を記述 thenにて結果検証 (アサーション) whenで処理実行
  6. class SampleSpec extends Specification { def setup() { // テストケース毎に実行される共通の前処理

    } def "引数が2つの場合に平均値を取得できる"() { given: "引数が2つの配列がある" def numbers = [1.2, 1.8] as double[] when: "平均値を求める" def avg = Sample.avg(numbers); then: "平均値が正しい" avg == 1.5 } def cleanup() { // テストケース毎に実行される共通の後処理 } } 各テストケースに 共通の前処理は setupメソッド に記述 各テストケースに 共通の後処理は cleanupメソッド に記述 18
  7. def “アサーションのサンプル"() { when: def list = ["cat", "dog"] then:

    list // 等式でなくても、Truthy評価される。 // リストの場合、nullでなく要素が1つ以上あれば真 list.size() == 2 // プリミティブの等価性比較 list == ["cat", "dog"] // オブジェクトの等価性比較 } thenブロックは特別扱いされる。 真と評価されるべき文を列挙して検証を行う。 if文やfor文などの制御構文は 入れられないので注意 19
  8. 29

  9. def "スタブの利用例"() { given: "スタブのセットアップ" def stubDoc = Stub(DOC) stubDoc.bar("baz")

    >> "BAZ" // stubDoc.bar(_) >> "BAZ" // ワイルドカード利用例 and: "SUT" def sut = new SUT1(stubDoc) // スタブを注入 when: "実行" def result = sut.foo("baz") then: "結果が正しい" result == "BAZBAZ" } Stub(クラス名/インタフェース名) でスタブ作成 スタブの振舞いを DSLで簡潔に記述 40
  10. def "モックの利用例"() { given: "モックの作成" def mockService = Mock(Service) and:

    "SUT" def sut = new SUT2(mockService) when: "実行" def result = sut.foo("baz") then: "結果が正しい" result == "BAZBAZ" and: "サービスとの相互作用が正しい" 1 * mockService.bar("baz") >> "BAZ" } Mock(クラス名/インタフェース名) でスタブ作成 期待されるモック呼出しを DSLで簡潔に記述 41
  11. 44

  12. def "タスクが全て完了となったらストーリーも完了する"() { given: "ストーリーがある" def story = new UserStory("最初のユニットテストを書く")

    story.estimate(2) and: "1つ目のタスク(完了)" def task1 = new Task("Spockをインストールする") def alice = new User("Alice", "[email protected]") task1.assign(alice) story.addTask(task1) task1.finish() and: "2つ目のタスク(着手)" def task2 = new Task("CIに組み込む") def bob = new User("Bob", "[email protected]") task2.assign(bob) story.addTask(task2) task2.start() when: "2つ目のタスクを完了する" task2.finish() then: "タスクが完了" task2.status == Status.Completed and: "ストーリーも完了" story.status == Status.Completed } テスト対象オブジェ クトの生成処理が 大半を占める 46
  13. def "タスクが全て完了となったらストーリーも完了する_TestDataBuilder"() { given: "ストーリーがある" def builder = new UserStoryBuilder()

    UserStory story = builder.story(name: "最初のユニットテストを書く", point: 2) { task(name: "Spockをインストールする") { assignee(userId: "Alice", email: "[email protected]") finish() } task(name: "CIに組み込む") { assignee(userId: "Bob", email: "[email protected]") } } when: "2つ目のタスクを完了する" def task2 = story.getTask("Spockをインストールする") task2.finish() … } 一種のDSLにより構造化 してテストデータを記述 できるので把握しやすい 47
  14. class UserStoryBuilder extends FactoryBuilderSupport { UserStoryBuilder() { registerFactory("story", new UserStoryFactory())

    registerFactory("task", new TaskFactory()) registerFactory("finish", new TaskFinishFactory()) registerFactory("assignee", new UserFactory()) } } class UserStoryFactory extends AbstractFactory { @Override Object newInstance(FactoryBuilderSupport builder, Object name, Object value, Map attributes) throws InstantiationException, IllegalAccessException { def storyName = attributes.get("name", "Some Story") def story = new UserStory(storyName) def point = attributes.get("point", 1) story.estimate(point) story } @Override void setChild(FactoryBuilderSupport builder, Object parent, Object child) { def story = parent as UserStory def task = child as Task story.addTask(task) } } Groovy標準ライブラリを 利用してBuilderを実装 (説明は割愛) 48
  15. // ユーザーストーリー関連の Object Mother class ObjectMother { // 完了タスクを1つもつユーザーストーリーを生成 static

    def aStoryWithATaskFinished(name = "Some Story") { def story = new UserStory(name) def task = aTask() story.addTask(task) task.finish() story } // タスクを生成 static def aTask(name = "Some Task") { def task = new Task(name) def user = new User("Someone", "[email protected]") task.assign(user) task } } • テストデータのファクトリメソッドの集合 • テストデータをパターン化し、意図が明確な名前を与える 49
  16. def "タスクが全て完了となったらストーリーも完了する_ObjectMother"() { given: "ストーリーがある" def story = ObjectMother.aStoryWithATaskFinished() and:

    "2つ目のタスクを追加して開始" def task2 = ObjectMother.aTask() story.addTask(task2) task2.start() when: "2つ目のタスクを完了する" task2.finish() then: "タスクが完了" task2.status == Status.Completed and: "ストーリーも完了" story.status == Status.Completed } コード量削減に加え、意図 が明確な名前によりテスト の事前条件を把握しやすい 別のテクニックとして、 テスト対象の振舞いに影響 を与えない(ストーリー名 などの)情報は省略する 50
  17. Test Data Builder Object Mother Pros ✓ 柔軟性が高い ✓ テストデータの生成ロジックを

    1箇所にまとめられる ✓ 再利用性が高い ✓ テストコードの可読性が高くなる Cons ✓ 再利用性は高くない ✓ テストコードの冗長さは残る ✓ ファクトリメソッドが増えて 神クラスになりがち ✓ 複数のテストがObject Motherに 依存することになる ※どちらも複数のテストクラスで利用するデータ生成問題を解決するために 使用する(単一テストクラスであればprivateな生成メソッドで十分) 51
  18. def "定額割引クーポンの値引き計算が正しい 金額>クーポン値引き額"() { given: def sut = aFixedAmountCoupon(300) when:

    def discounted = sut.discount(BigDecimal.valueOf(500)) then: discounted == 300 } def "定額割引クーポンの値引き計算が正しい 金額=クーポン値引き額"() { given: def sut = aFixedAmountCoupon(300) when: def discounted = sut.discount(BigDecimal.valueOf(300)) then: discounted == 300 } def "定額割引クーポンの値引き計算が正しい 金額<クーポン値引き額"() { given: def sut = aFixedAmountCoupon(300) when: def discounted = sut.discount(BigDecimal.valueOf(299)) then: discounted == 299 } 事前条件、テスト対象の 振舞い、検証内容が似通っ たテストケースが複数存在 問題点: • テストコードが冗長 • 網羅性が把握しにくい 53
  19. @Unroll def "Discount calculation for FixedAmountCoupon(300), Amount: #amount Expected: #expected"()

    { given: def sut = aFixedAmountCoupon(300) when: def discounted = sut.discount(BigDecimal.valueOf(amount)) then: discounted == BigDecimal.valueOf(expected) where: amount | expected || description 500 | 300 || "金額 > クーポン値引き額" 300 | 300 || "金額 = クーポン値引き額" 299 | 299 || "金額 < クーポン値引き額" } whereブロックに表形式で 記述した1行1行がテスト ケースとして実行される whereブロックの列ヘッダ 名を変数として参照できる 実行結果もわかりやすい 54
  20. def “クロージャ”() { given: “Fake” // Function<String, String> def fake

    = { arg -> “BAZ”} // def fake = { “BAZ” } // 暗黙のitを捨ててこうも書ける and: "SUT" def sut = new SUT3(fake) when: "実行" def result = sut.foo("baz") then: "結果が正しい" result == "BAZBAZ" } def "ジェネリック"() { given: "Stub" def stub = Stub(type: new TypeToken<Function<String, String>>(){}.type) stub.apply("baz") >> "BAZ" and: "SUT" def sut = new SUT3(stub) when: "実行" def result = sut.foo("baz") then: "結果が正しい" result == "BAZBAZ" } 69 クロージャの記法はJava のラムダ式と異なる ジェネリック型のスタブ/ モックは特殊な記述が必要