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

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

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

JJUG CCC 2021 Springでの講演資料

2722ca734f5f05badb74f976d1f2b6c8?s=128

yonetty

May 23, 2021
Tweet

Transcript

  1. None
  2. 米久保 剛 (Takeshi Yonekubo) 株式会社電通国際情報サービス 所属 自社パッケージ製品の開発リーダー twitter: @tyonekubo 2

  3. 3 1. Spockの紹介 2. テスト駆動開発のコツ 3. テストコードの保守性向上

  4. 1. テストの書き方がよくわからない 2. テストを書くのが面倒くさい 3. テストしにくい 4. テストコードのメンテナンスが大変 4

  5. ✓ Groovy製のテスティング・フレームワーク ✓ Javaとの相互運用性の高さ ✓ Groovyの文法 + DSLで楽にテストを書ける ✓ 強力なアサート機能

    ✓ 機能が豊富なのでSpock単体で広くカバー可能 ✓ モック機能 ✓ Springとの統合
  6. JUnitじゃダメなの?

  7. 1. テストの書き方がよくわからない 2. テストを書くのが面倒くさい 3. テストしにくい 4. テストコードのメンテナンスが大変 7

  8. テストコードは具体的であるため 放っておくと散らかる 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
  9. テストコードの量は プロダクトコードの量の数倍になる と言われている 9

  10. 少しでもテストコードを楽に書きたい 10

  11. result == ["cat", "dog"] // Listのリテラル表記 // [cat: 3, dog:

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

    0 0 | 5 || 0 100 | 1 || 100 100 | 2 || 200 テストの入力値や期待値のセット を表形式で簡潔に定義できる (パラメータ化テスト) 12
  13. 1. テストの書き方がよくわからない 2. テストを書くのが面倒くさい 3. テストしにくい 4. テストコードのメンテナンスが大変 13

  14. Spock超入門 14

  15. 1. Setup 前処理 テストの事前条件(テストフィクスチャ)をそろえる 2. Exercise 実行 テスト対象処理(SUT)を呼び出す 3. Verify

    検証 実行結果(事後条件)が期待通りかを確認する 4. Teardown 後処理 必要な場合、後始末を行う 15
  16. class SampleSpec extends Specification { } spock.lang.Specification を継承 16 テストクラス名は

    ~Spec が慣習
  17. 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で処理実行
  18. 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
  19. def “アサーションのサンプル"() { when: def list = ["cat", "dog"] then:

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

  21. 21 1. テストの書き方がよくわからない 2. テストを書くのが面倒くさい 3. テストしにくい 4. テストコードのメンテナンスが大変

  22. テストしにくい =テスト容易性が低い 22

  23. テストを先に書くことで、プロダクト コードは自然とテストしやすいコードに なる(テストファースト) 23

  24. テスト駆動開発 Test-Driven Development 24

  25. テスト駆動開発(TDD): テストファーストの手法を使ってソフト ウェアを漸進的に開発していくプロセス をオートマティックにするやり方 25

  26. ①失敗するテストを書く ②テストが通るよう にコードを書く ③リファクタリングする 呼吸するかの如く自然にサイクルを回すことが大事 TDD 26

  27. TDDを体得するには? 27

  28. - ひたすら鍛錬あるのみ 28

  29. 29

  30. 達人の指南を受ける 技を盗む 30

  31. TDDを実践する上でのコツ 31

  32. TDDのコツ① ユニットテストの単位 32

  33. クラス単位ではなく、振舞いを提供する コンポーネント単位でテストを書く 33

  34. ✓ テスト駆動で漸進的にコンポーネントの実装を進 める ✓ コンポーネントが、単一クラスとなるか集合体と なるかは結果論 ✓ コンポーネントをブラックボックスとして捉えて テストを書けば、カバレッジは自然と担保される 6章のボーリングスコア計算アプリの例

    34
  35. “ユニットとはふるまいの 単位、つまり独立した検証 可能なふるまいのことだ” “あらゆる観察可能なふる まいが、それに紐づくテス トを持つべきである” 35

  36. ✓ テスト対象クラスを他と独立して検証するために、 スタブやモックを多用することになる ✓ 結果として、脆いテスト(Fragile Test)に なりやすい(内部設計の変更でテストが壊れる) 36

  37. TDDのコツ② スタブ・モックの使いどころ 37

  38. コンポーネント(SUT) 依存コンポーネント(DOC) コンポーネント(SUT) スタブ ✓ 他のコンポーネント(ふるまいの集合体)を スタブに置き換えてテスト ✓ DOCの呼び出され方(相互作用)は検証しない ※DOC

    …Depended-on Component 38
  39. コンポーネント(SUT) 外部サービス コンポーネント(SUT) モック ✓ 外部サービスをモックに置き換えてテストする (サービスの例:メール送信、API呼出し) ✓ 外部サービスが呼び出されることが仕様の一部 なので、モック機能によって相互作用を検証する

    39
  40. 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
  41. 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
  42. 42 1. テストの書き方がよくわからない 2. テストを書くのが面倒くさい 3. テストしにくい 4. テストコードのメンテナンスが大変

  43. テストコードは具体的であるため 放っておくと散らかる 43

  44. 44

  45. 整理整頓テクニック① 複雑なオブジェクトの生成パターン 45

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

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

    UserStory story = builder.story(name: "最初のユニットテストを書く", point: 2) { task(name: "Spockをインストールする") { assignee(userId: "Alice", email: "alice@example.com") finish() } task(name: "CIに組み込む") { assignee(userId: "Bob", email: "bob@example.com") } } when: "2つ目のタスクを完了する" def task2 = story.getTask("Spockをインストールする") task2.finish() … } 一種のDSLにより構造化 してテストデータを記述 できるので把握しやすい 47
  48. 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
  49. // ユーザーストーリー関連の 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", "someone@example.com") task.assign(user) task } } • テストデータのファクトリメソッドの集合 • テストデータをパターン化し、意図が明確な名前を与える 49
  50. 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
  51. Test Data Builder Object Mother Pros ✓ 柔軟性が高い ✓ テストデータの生成ロジックを

    1箇所にまとめられる ✓ 再利用性が高い ✓ テストコードの可読性が高くなる Cons ✓ 再利用性は高くない ✓ テストコードの冗長さは残る ✓ ファクトリメソッドが増えて 神クラスになりがち ✓ 複数のテストがObject Motherに 依存することになる ※どちらも複数のテストクラスで利用するデータ生成問題を解決するために 使用する(単一テストクラスであればprivateな生成メソッドで十分) 51
  52. 整理整頓テクニック② パラメータ化テストパターン 52

  53. 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
  54. @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
  55. 55 1. テストの書き方がよくわからない 2. テストを書くのが面倒くさい 3. テストしにくい 4. テストコードのメンテナンスが大変

  56. ✓ テストコードも大切なプロジェクト資産 56 ✓ TDDやテストパターンを活用しプロダクト コードもテストコードも保守性を高めよう ✓ Spockはとにかく使いやすいので、使って みてください!

  57. ご清聴ありがとうございました 発表資料や補足情報などはtwitterで発信します @tyonekubo 57

  58. 【補足】 Spock導入時のコツ 58

  59. Q. JUnitより遅くないの? 59

  60. A. 単純比較だと遅いけど、使い方次第 60 ✓ JUnitでもテストスイートが大きくにつれ遅くなる ✓ TDDで回すときはサブモジュールやパッケージ限定で実行 ✓ 最後に全テストスイートを実行

  61. Q. メンバーがGroovy/Spockをすぐに 使いこなせるか不安です 61

  62. A. テストコードを書くために 覚える量は多くない 62 ✓ 軽量なガイドラインとサンプルコードがあればOK

  63. Groovyの基本文法を 含む十数ページ相当の ガイドラインで、すぐ に皆が使い方を覚えた。

  64. Zenn Bookに移植した ので参考にしてください https://zenn.dev/yonekubo/books/6f4bde620a7bac

  65. Q. IDEとの統合は問題ないか? 65

  66. A. Eclipse < IntelliJ(個人の感想) 66 ✓ Eclipseのプラグインはあるが、難点がありIntelliJを薦める ✓ Eclipseだと、テストメソッド単位での実行ができない ✓

    Eclipseだと、テストスイートのロードに時間がかかる
  67. Q. ハマることはないの? 67

  68. A. 稀にあります 68 ✓ 過去にハマった例: ✓ Javaのラムダ式に対応するのはクロージャだが、記法に差 異があるため初歩的ミスに気がつけなかった ✓ ジェネリックなクラス/インタフェースのスタブ/モック

    方法が難しい(特殊な記述が必要)
  69. 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 のラムダ式と異なる ジェネリック型のスタブ/ モックは特殊な記述が必要
  70. - 以上 - 70