JJUG CCC 2021 Springでの講演資料
View Slide
米久保 剛 (Takeshi Yonekubo)株式会社電通国際情報サービス 所属自社パッケージ製品の開発リーダーtwitter: @tyonekubo2
31. Spockの紹介2. テスト駆動開発のコツ3. テストコードの保守性向上
1. テストの書き方がよくわからない2. テストを書くのが面倒くさい3. テストしにくい4. テストコードのメンテナンスが大変4
✓ Groovy製のテスティング・フレームワーク✓ Javaとの相互運用性の高さ✓ Groovyの文法 + DSLで楽にテストを書ける✓ 強力なアサート機能✓ 機能が豊富なのでSpock単体で広くカバー可能✓ モック機能✓ Springとの統合
JUnitじゃダメなの?
1. テストの書き方がよくわからない2. テストを書くのが面倒くさい3. テストしにくい4. テストコードのメンテナンスが大変7
テストコードは具体的であるため放っておくと散らかる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
少しでもテストコードを楽に書きたい10
result == ["cat", "dog"] // Listのリテラル表記// [cat: 3, dog: 4] (Mapリテラル)ListやMapなどのコレクションリテラルがあるので、簡単に書ける== による等価判定(Javaのeqauals)11
where:unitPrice | quantity || expectedAmount100 | 0 || 00 | 5 || 0100 | 1 || 100100 | 2 || 200テストの入力値や期待値のセットを表形式で簡潔に定義できる(パラメータ化テスト)12
1. テストの書き方がよくわからない2. テストを書くのが面倒くさい3. テストしにくい4. テストコードのメンテナンスが大変13
Spock超入門14
1. Setup 前処理テストの事前条件(テストフィクスチャ)をそろえる2. Exercise 実行テスト対象処理(SUT)を呼び出す3. Verify 検証実行結果(事後条件)が期待通りかを確認する4. Teardown 後処理必要な場合、後始末を行う15
class SampleSpec extends Specification {}spock.lang.Specificationを継承16テストクラス名は~Spec が慣習
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で処理実行
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
def “アサーションのサンプル"() {when:def list = ["cat", "dog"]then:list // 等式でなくても、Truthy評価される。// リストの場合、nullでなく要素が1つ以上あれば真list.size() == 2 // プリミティブの等価性比較list == ["cat", "dog"] // オブジェクトの等価性比較}thenブロックは特別扱いされる。真と評価されるべき文を列挙して検証を行う。if文やfor文などの制御構文は入れられないので注意19
まずはこれだけ覚えれば最初のSpockテストはすぐ書ける!20
211. テストの書き方がよくわからない2. テストを書くのが面倒くさい3. テストしにくい4. テストコードのメンテナンスが大変
テストしにくい=テスト容易性が低い22
テストを先に書くことで、プロダクトコードは自然とテストしやすいコードになる(テストファースト)23
テスト駆動開発Test-Driven Development24
テスト駆動開発(TDD):テストファーストの手法を使ってソフトウェアを漸進的に開発していくプロセスをオートマティックにするやり方25
①失敗するテストを書く②テストが通るようにコードを書く③リファクタリングする呼吸するかの如く自然にサイクルを回すことが大事TDD26
TDDを体得するには?27
- ひたすら鍛錬あるのみ28
29
達人の指南を受ける技を盗む30
TDDを実践する上でのコツ31
TDDのコツ①ユニットテストの単位32
クラス単位ではなく、振舞いを提供するコンポーネント単位でテストを書く33
✓ テスト駆動で漸進的にコンポーネントの実装を進める✓ コンポーネントが、単一クラスとなるか集合体となるかは結果論✓ コンポーネントをブラックボックスとして捉えてテストを書けば、カバレッジは自然と担保される6章のボーリングスコア計算アプリの例34
“ユニットとはふるまいの単位、つまり独立した検証可能なふるまいのことだ”“あらゆる観察可能なふるまいが、それに紐づくテストを持つべきである”35
✓ テスト対象クラスを他と独立して検証するために、スタブやモックを多用することになる✓ 結果として、脆いテスト(Fragile Test)になりやすい(内部設計の変更でテストが壊れる)36
TDDのコツ②スタブ・モックの使いどころ37
コンポーネント(SUT) 依存コンポーネント(DOC) コンポーネント(SUT) スタブ✓ 他のコンポーネント(ふるまいの集合体)をスタブに置き換えてテスト✓ DOCの呼び出され方(相互作用)は検証しない※DOC …Depended-on Component 38
コンポーネント(SUT) 外部サービス コンポーネント(SUT) モック✓ 外部サービスをモックに置き換えてテストする(サービスの例:メール送信、API呼出し)✓ 外部サービスが呼び出されることが仕様の一部なので、モック機能によって相互作用を検証する39
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
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
421. テストの書き方がよくわからない2. テストを書くのが面倒くさい3. テストしにくい4. テストコードのメンテナンスが大変
テストコードは具体的であるため放っておくと散らかる43
44
整理整頓テクニック①複雑なオブジェクトの生成パターン45
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.Completedand: "ストーリーも完了"story.status == Status.Completed}テスト対象オブジェクトの生成処理が大半を占める46
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
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 {@OverrideObject newInstance(FactoryBuilderSupport builder, Object name, Object value, Map attributes) throwsInstantiationException, IllegalAccessException {def storyName = attributes.get("name", "Some Story")def story = new UserStory(storyName)def point = attributes.get("point", 1)story.estimate(point)story}@Overridevoid setChild(FactoryBuilderSupport builder, Object parent, Object child) {def story = parent as UserStorydef task = child as Taskstory.addTask(task)}}Groovy標準ライブラリを利用してBuilderを実装(説明は割愛)48
// ユーザーストーリー関連の Object Motherclass 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
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.Completedand: "ストーリーも完了"story.status == Status.Completed}コード量削減に加え、意図が明確な名前によりテストの事前条件を把握しやすい別のテクニックとして、テスト対象の振舞いに影響を与えない(ストーリー名などの)情報は省略する50
Test Data Builder Object MotherPros ✓ 柔軟性が高い ✓ テストデータの生成ロジックを1箇所にまとめられる✓ 再利用性が高い✓ テストコードの可読性が高くなるCons ✓ 再利用性は高くない✓ テストコードの冗長さは残る✓ ファクトリメソッドが増えて神クラスになりがち✓ 複数のテストがObject Motherに依存することになる※どちらも複数のテストクラスで利用するデータ生成問題を解決するために使用する(単一テストクラスであればprivateな生成メソッドで十分)51
整理整頓テクニック②パラメータ化テストパターン52
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
@Unrolldef "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 || description500 | 300 || "金額 > クーポン値引き額"300 | 300 || "金額 = クーポン値引き額"299 | 299 || "金額 < クーポン値引き額"}whereブロックに表形式で記述した1行1行がテストケースとして実行されるwhereブロックの列ヘッダ名を変数として参照できる実行結果もわかりやすい54
551. テストの書き方がよくわからない2. テストを書くのが面倒くさい3. テストしにくい4. テストコードのメンテナンスが大変
✓ テストコードも大切なプロジェクト資産56✓ TDDやテストパターンを活用しプロダクトコードもテストコードも保守性を高めよう✓ Spockはとにかく使いやすいので、使ってみてください!
ご清聴ありがとうございました発表資料や補足情報などはtwitterで発信します@tyonekubo57
【補足】Spock導入時のコツ58
Q. JUnitより遅くないの?59
A. 単純比較だと遅いけど、使い方次第60✓ JUnitでもテストスイートが大きくにつれ遅くなる✓ TDDで回すときはサブモジュールやパッケージ限定で実行✓ 最後に全テストスイートを実行
Q. メンバーがGroovy/Spockをすぐに使いこなせるか不安です61
A. テストコードを書くために覚える量は多くない62✓ 軽量なガイドラインとサンプルコードがあればOK
Groovyの基本文法を含む十数ページ相当のガイドラインで、すぐに皆が使い方を覚えた。
Zenn Bookに移植したので参考にしてくださいhttps://zenn.dev/yonekubo/books/6f4bde620a7bac
Q. IDEとの統合は問題ないか?65
A. Eclipse < IntelliJ(個人の感想)66✓ Eclipseのプラグインはあるが、難点がありIntelliJを薦める✓ Eclipseだと、テストメソッド単位での実行ができない✓ Eclipseだと、テストスイートのロードに時間がかかる
Q. ハマることはないの?67
A. 稀にあります68✓ 過去にハマった例:✓ Javaのラムダ式に対応するのはクロージャだが、記法に差異があるため初歩的ミスに気がつけなかった✓ ジェネリックなクラス/インタフェースのスタブ/モック方法が難しい(特殊な記述が必要)
def “クロージャ”() {given: “Fake”// Functiondef 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>(){}.type)stub.apply("baz") >> "BAZ"and: "SUT"def sut = new SUT3(stub)when: "実行"def result = sut.foo("baz")then: "結果が正しい"result == "BAZBAZ"} 69クロージャの記法はJavaのラムダ式と異なるジェネリック型のスタブ/モックは特殊な記述が必要
- 以上 -70