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

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

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

JJUG CCC 2021 Springでの講演資料

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 のラムダ式と異なる ジェネリック型のスタブ/ モックは特殊な記述が必要