安心して生きるための

自動テスト

株式会社ゆめみ 

浜名将輝

自己紹介

自己紹介

所属: 株式会社ゆめみ(2020年新卒入社)

職種: サーバサイドエンジニア

使用技術: Kotlin + SpringBoot

お仕事: LINEミニアプリのサーバサイド

マイブーム:  歌, 物件探し

欲しいもの: 電動折り畳み自転車

自動テストとは?

色々な自動テスト

UIテスト

統合テスト

ユニットテスト

UIテスト

User Name

Password

Login

Failed!

Invalid User Name or Password. 

Shoki

●●●●●●●

実際の画面遷移まで含めたテスト

Success! 

 

Hello, Shoki.

統合テスト

@Controller
class UserController() {

  @PostMapping("/login")
  fun login(request: Request) : HTTPResponse{
    val encodedPassword = encode(request.password)
    val user = userService.findByNameAndPassword(request.name, encodedPassword)
    if(user.isNull) {
      return HTTPResponse(NOT_FOUND, ResultStrings("Failed!", "Invalid User Name or Passoword"));
    } else {
      return HTTPResponse(OK, ResultStrings("Success!", "Hello, ${user.name}."))
    }
  }
  
  @PostMapping("/register")
  fun register(request: Request) : HTTPResponse{
  ...
  }
...
}

ビジネス的にある程度まとまった処理単位ごと = エンドポイント

※擬似コードです

単体テスト

@Component
class RateManager() {
  fun lookuptaxRate(region: String) : Float {
  
     if( region.isEmpty() ) { 
       throw MyException()
     }
    
     if( region == "Alberta" )
       return 0.1
     }
    
     if( region == "Saskatchewan" )
       return 0.2
     }
    
    return 0.3
  }
  ...
}

 クラスやメソッド単位ごと

※擬似コードです

各自動テストの特徴

UIテスト

統合テスト

ユニットテスト

  • APIのテスト
  • 複数ロジックの繋がりを見れる
  • 詳細さに欠ける
  • 単一ロジックのテスト
  • 高速
  • 結合部分は弱い
  • ユーザと同じ目線でテスト
  • テストに時間がかかる

広範囲

詳細

ゆめみの開発形態

フロントエンドチームと

サーバサイドチームに明確に分かれて開発

サーバサイドはREST APIの開発をすることが多い

ゆめみ(サーバーサイド)は?

UIテスト

統合テスト

ユニットテスト

←やる

←やる

←やらない

 自動テストの仕方

自動テスティングライブラリを用いて行います

  • Rspec(Ruby)
  • Jasmine(JS)
  • PHPUnit (PHP)
  • JUnit(Java, Kotlin)

なぜ自動テスト?

自動テストを書くということ

  • 時間がかかる
  • 記述量も多くなりがちで腕がしんどい
  • 地味でつまらない
  • テストで無駄にハマったときの徒労感...

自動テストをなぜ書くのか

書いたコードが正しく動いているか

の確認をするためだけではありません

  • 既存コードを壊さずに開発する
    • 精神の衛生が保たれる
    • 長い目で見ると開発スピードが上がる
  • テストコード自体が他の開発者のドキュメントになりうる
    • 実装者の意図がわかる
  • 一定の品質を証明できる
    • 品質が高いことを証明するわけではありません

もし自動テストがなかったら

カオスになります

  • 仕様に対して現状を把握しずらい
  • 知らないところでデグレが起きまくっている
    • かと言ってどこにバグがあるのかもわからない
  • ???「ドキュメントはコード」

自動テストが無い所からはできれば逃げましょう

僕の開発の流れ

  • まずざっくりと40点ぐらいの実装をする
  • 統合テストとユニットテストを書く
  • 以下繰り返し
    • 少しテストを増やす
    • テストを通すように実装を進める
  • 手戻りを回避しながら実装を進められる
  • 自分の作業進捗がある程度可視化される

最初から100点を目指さない

TDD

自動テストを書きながら実装する

 

 

 

セーブしながらゲームする

ことに似ている気がしないでもない...

基本的なテストの書き方

統合テストの書き方

class UserControllerTests {
   ...
  @BeforeEach
  fun init() {
    // テスト用のControllerを用意する
    userControllerMock = MockMvcBuilders
                .standaloneSetup(userController)
                .build()
  }

  /* ログイン成功時のテスト */
  @Test
  fun loginTest_OK() {
    val expectedValue = ...
    // エンドポイントに対してリクエストを送る
    userControllerMock.perform(MockMvcRequestBuilders.post("/login"))
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .content(ReqestBody(userName = "Shoki", password = "my_password")
    // ステータスコードが200 OKかの確認
   ).andExpect(status().isOk)
    // Content-Typeが想定通りの型かどうかの確認
    .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) 
    // レスポンスの内容が期待通りの値かどうかの確認
    .andExpect(content().json(convertToJsonString(expectedValue)))          
  }
}

統合テストの書き方

class UserControllerTests {
   ...
  @BeforeEach
  fun init() {
   // テスト用のControllerを用意する
    userControllerMock = MockMvcBuilders
                .standaloneSetup(userController)
                .build()
  }

  /* ログイン失敗時のテスト */
  @Test
  fun loginTest_NOT_FOUND() {
    val expectedValue = ...
    // エンドポイントに対してリクエストを送る
    userControllerMock.perform(MockMvcRequestBuilders.post("/login"))
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .content(ReqestBody(userName = "Shoki", password = "invalid_password")
    // ステータスコードが404 NOT FOUNDかの確認
   ).andExpect(status().isNotFound)
    // Content-Typeが想定通りの型かどうかの確認
    .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) 
    // レスポンスの内容が期待通りの値かどうかの確認
    .andExpect(content().json(convertToJsonString(expectedValue)))          
  }
}

統合テストケース&項目

  • テストケース
    • 正常系
      • 想定内の入力(200 OK)
      • リダイレクト(302 Redirect)
    • 異常系
      • 型違いの入力(400 BadRequest)
      • 存在しないリソースに対するリクエスト(404 NotFound)
  • テスト項目
    • レスポンスボディが期待通りか
    • ステータスコードが期待通りか

ユニットテストの書き方

class RateMaganerTests {
    @Autowired
    lateinit var rateManager: RateManager
    
    /*デフォルト値を取るテスト*/
    @Test
    fun defaultBehaviorTest() {
      asserEquals (0.3, rateManager.lookupTaxRate("Somewhere"))
    }
    
    /*特殊ケースのテスト*/
    @Test
    fun specialCasesTest() {
      asserEquals (0.1, rateManager.lookupTaxRate("Alberta"))
      asserEquals (0.2, rateManager.lookupTaxRate("Saskatchewan"))
    }
    
    /*例外テスト*/
    @Test(expected = MyException::class)
    fun testIllegalValueTest() {
      rateManager.lookupTaxRate("")
    }
}

ユニットテストケース

  • ハッピーパス
    • 理想的な入力/条件
  • 特殊ケース
    • 注意が必要なケースやエッジケース
  • 例外
    • エラーが正しく動作するか
  • ロジックの流れ
    • パス、条件分岐など
  • その他不安箇所
    • 不安箇所は全部テスト書く!

テストの書き方応用編

入力/期待値の配置を考える

class RateMaganerTests {
    ...
    companion object {
      val EXPECTATION_DEFAULT = 0.3
      val EXPECTATION_ALBERTA = 0.1
      val EXPECTATION_SASKATCHEWAN = 0.2
        
      val INPUT_DEFAULT_CASE = "Somewhere"
      val INPUT_SPECIAL_CASE_ALBERTA = "Alberta"
      val INPUT_SPECIAL_CASE_SASKACHEWAN = "Saskatchewan"
      val INPUT_EMPTY_REGION = ""
    }
    ...
    /*デフォルト値を取るテスト*/
    @Test
    fun defaultBehaviorTest() {
      asserEquals (EXPECTATION_DEFAULT, rateManager.lookupTaxRate(INPUT_DEFAULT_CASE))
    }
    
    /*特殊ケースのテスト*/
    @Test
    fun specialCasesTest() {
      asserEquals (EXPECTATON_ALBERTA, rateManager.lookupTaxRate(INPUT_SPECIAL_CASE_ALBERTA))
      asserEquals (EXPECTATON_SASKATCHEWAN, rateManager.lookupTaxRate(INPUT_SPECIAL_CASE_SASKACHEWAN))
    }
    
    /*例外テスト*/
    @Test(expected = MyException::class)
    fun testIllegalValueTest() {
      rateManager.lookupTaxRate(INPUT_EMPTY_REGION)
    }
}
  • 入力値/期待値の変数化はやり過ぎ注意
  • 入力値/期待値はテストの近くに置いておく方が良い

テストコードが増えると

可読性が下がる...

テスト関数名を日本語で書く

class RateMaganerTests {
    @Autowired
    lateinit var rateManager: RateManager
    ...
   
    @Test
    fun `デフォルト値を取る時のテスト`() {
      asserEquals (0.3, rateManager.lookupTaxRate("Somewhere"))
    }
    
    @Test
    fun `特殊ケースのテスト`() {
      asserEquals (0.1,  rateManager.lookupTaxRate("Alberta"))
      asserEquals (0.2, rateManager.lookupTaxRate("Saskatchewan"))
    }
    
    @Test(expected = MyException::class)
    fun `例外で落とすケースのテスト`() {
      rateManager.lookupTaxRate("")
    }
}

英語で書いて見通しが悪くなるぐらいなら

日本語も選択肢にする

Parameterized Test

class UserControllerTests {
   ...

  /* ログイン失敗時のテスト */
  @Test
  fun loginTest_NOT_FOUND() {
    val expectedValue = ...
    // エンドポイントに対してリクエストを送る
    userControllerMock.perform(MockMvcRequestBuilders.post("/login"))
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .content(ReqestBody(userName = "Shoki", password = "invalid_password")
    // ステータスコードが404 NOT FOUNDかの確認
   ).andExpect(status().isNotFound)
    // Content-Typeが想定通りの型かどうかの確認
    .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) 
    // レスポンスの内容が期待通りの値かどうかの確認
    .andExpect(content().json(convertToJsonString(expectedValue)))          
  }
}
userName password
"Shoki" "invalid_password"
"invalid_userName" "valid_password"
"invalid_userName" "invalid_password"

←同じ結果で複数の入力

を試したい時

全部404の入力

Parameterized Test

class UserControllerTests {
   ...
   
  /* loginで404を返すパラメータ群 */  
  @JvmStatic
  fun notFoundLoginParams(): Stream<Arguments>  = Stream.of(
           arguments("Shoki", "invalid_password"),
           arguments("invalid_user", "valid_password"),
           arguments("invalid_user", "invalid_password")           
       )
  }

  /* ログイン失敗時のテスト 上記3通りを順番に試してくれる*/
  @ParameterizedTest
  @MethodSource("notFoundLoginParams")
  fun loginTest_NOT_FOUND(inputUserName: String, inputPassword: String) {
    val expectedValue = ...
    // エンドポイントに対してリクエストを送る
    userControllerMock.perform(MockMvcRequestBuilders.post("/login"))
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .content(ReqestBody(userName = inputUserName, password = inputPassword)
    // ステータスコードが404 NOT FOUNDかの確認
   ).andExpect(status().isNotFound)
    // Content-Typeが想定通りの型かどうかの確認
    .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) 
    // レスポンスの内容が期待通りの値かどうかの確認
    .andExpect(content().json(convertToJsonString(expectedValue)))          
  }
}

外部サービスクライアントはMock化の検討

外部サービスを叩くAPIのテスト

  • 外部サービスが稼働している必要がある
  • インターネットに接続している必要がある

  • テストに関係のない外部要因が入り込む
  • リクエストにお金がかかる場合もある

外部サービスクライアントはMock化の検討

  • ダミーオブジェクト
  • インスタンスの挙動をテスト用に書き換える
  • 呼び出しをMock内に記録しておくことができる
  • 外部サービスクライアントの挙動を書き換える
  • リクエストが成功/失敗した前提で処理を継続する
    • 実サービスにはリクエストはしない
    • インターネットも必要ない
  • 呼び出したかどうかの確認をする

Mock ...

テスト駆動開発(TDD)

TDDの流れ

  1. テストを1つ書く

  2. 全てのテストを実行し、新規追加分は失敗することを確認する

  3. 実装を追加する

  4. 全てのテストが通ることを確認する

  5. リファクタリングを行う

TDDの目的

  • 途中までの実装を壊す心配がなくなる
    • リファクタリングしやすい
    • 設計の見直しを常にしながら開発できる
  • テストを通すことに集中できる
    • 頭の中の余計なノイズを遮断できる

TDDで電卓を作ろう

仕様

  • 逆ポーランド記法の1桁2値加算しかできない電卓

 

通常の電卓: "1+2 =" で'3'が表示

逆ポーランド記法電卓:

1 ENTER 2 ENTER + ENTERで'3'が表示

 

 

TDDで電卓を作ろう

TODOリスト

  • 数値を1つ入力できる
  • 数値を2つ入力できる
  • 状態(結果)を確認できる
  • 加算が行える

 

TDDで電卓を作ろう

class CalcTest {
    @Test
    fun testInputSingleValue() {
        val calc = Calc()
        
        calc.inputValue(1)
        assertEquals(1, calc.result()) 
    }
}

テストは失敗!

そもそもコンパイルも通らない

1. テストを1つ書く

2. 全てのテストを実行&新規追加分は失敗

3. 実装を追加する

4. 全てのテストが通ることを確認する

5. リファクタリングを行う

TDDで電卓を作ろう

class Calc {
    val stack = mutableListOf<Int>()
    
    fun inputValue(value: Int) {
        stack.add(value)
    }
    fun result() : Int? {
        return stack.firstOrNull
    }
}

実装を追加してテストを通そう

1. テストを1つ書く

2. 全てのテストを実行&新規追加分は失敗

3. 実装を追加する

4. 全てのテストが通ることを確認する

5. リファクタリングを行う

class CalcTest {
    @Test
    fun testInputSingleValue() {
        val calc = Calc()
        
        calc.inputValues(1)
        assertEquals(1, calc.result())
    }
}

TDDで電卓を作ろう

class Calc {
    val stack = mutableListOf<Int>()
    
    fun inputValue(value: Int) {
        stack.add(value)
    }
    fun result() : Int? {
        return stack.firstOrNull
    }
}

リファクタできる箇所は特になさそう

1. テストを1つ書く

2. 全てのテストを実行&新規追加分は失敗

3. 実装を追加する

4. 全てのテストが通ることを確認する

5. リファクタリングを行う

class CalcTest {
    @Test
    fun testInputSingleValue() {
        val calc = Calc()
        
        calc.inputValues(1)
        assertEquals(1, calc.result())
    }
}

TDDで電卓を作ろう

class Calc {
    val stack = mutableListOf<Int>()
    
    fun inputValue(value: Int) {
        stack.add(value)
    }
    fun result() : Int? {
        return stack.firstOrNull
    }
}

新規追加分のテストは失敗!

1. テストを1つ書く

2. 全てのテストを実行&新規追加分は失敗

3. 実装を追加する

4. 全てのテストが通ることを確認する

5. リファクタリングを行う

class CalcTest {
    @Test
    fun testInputSingleValue() {
        val calc = Calc()
        
        calc.inputValues(1)
        assertEquals(1, calc.result())
    }
    
    @Test
    fun testInputTwoValues() {
        val calc = Calc
        
        calc.inputValues(1)
        calc.inputValues(2)
        assertEquals(2, calc.result())
        // actual -> 1
    }
}

TDDで電卓を作ろう

class Calc {
    val stack = mutableListOf<Int>()
    
    fun inputValue(value: Int) {
        stack.add(value)
    }
    fun result() : Int? {
        return stack.lastOrNull
    }
}

実装を変えてテストが通ることを確認

1. テストを1つ書く

2. 全てのテストを実行&新規追加分は失敗

3. 実装を追加する

4. 全てのテストが通ることを確認する

5. リファクタリングを行う

class CalcTest {
    @Test
    fun testInputSingleValue() {
        val calc = Calc()
        
        calc.inputValues(1)
        assertEquals(1, calc.result())
    }
    
    @Test
    fun testInputTwoValues() {
        val calc = Calc()
        
        calc.inputValues(1)
        assertEquals(1, calc.result())
        calc.inputValues(2)
        assertEquals(2, calc.result())
        // actual -> 1
    }
}

TDDで電卓を作ろう

class Calc {
    val stack = mutableListOf<Int>()
    
    fun inputValue(value: Int) {
        stack.add(value)
    }
    fun result() : Int? {
        return stack.lastOrNull
    }
}

共通化できそうなところはする

1. テストを1つ書く

2. 全てのテストを実行&新規追加分は失敗

3. 実装を追加する

4. 全てのテストが通ることを確認する

5. リファクタリングを行う

class CalcTest {

    lateinit var calc: Calc
    
    @BeforeEach
    fun beforeEachTest() {
        calc = Calc()
    }
    
    @Test
    fun testInputSingleValue() {
        calc.inputValues(1)
        assertEquals(1, calc.result())
    }
    
    @Test
    fun testInputTwoValues() {
        calc.inputValues(1)
        assertEquals(1, calc.result())
        calc.inputValues(2)
        assertEquals(2, calc.result())
    }
}

TDDで電卓を作ろう

class Calc {
    val stack = mutableListOf<Int>()
    
    fun inputValue(value: Int) {
        stack.add(value)
    }
    fun result() : Int? {
        return stack.lastOrNull
    }
}

1. テストを1つ書く

2. 全てのテストを実行&新規追加分は失敗

3. 実装を追加する

4. 全てのテストが通ることを確認する

5. リファクタリングを行う

class CalcTest {

    lateinit var calc: Calc
    
    @BeforeEach
    fun beforeEachTest() {
        calc = Calc()
    }
    
    @Test
    fun testInputSingleValue() {
    <中略>
    }
    
    @Test
    fun testInputTwoValues() {
    <中略>
    }
    @Test
    fun testAddTwoValues() {
        calc.inputValues(1)
        calc.inputValues(2)
        calc.add()
        assertEquals(3, calc.result())
    }
}

新規追加分のテストは失敗!

TDDで電卓を作ろう

1. テストを1つ書く

2. 全てのテストを実行&新規追加分は失敗

3. 実装を追加する

4. 全てのテストが通ることを確認する

5. リファクタリングを行う

class CalcTest {

    lateinit var calc: Calc
    
    @BeforeEach
    fun beforeEachTest() {
        calc = Calc()
    }
    
    @Test
    fun testInputSingleValue() {
    <中略>
    }
    
    @Test
    fun testInputTwoValues() {
    <中略>
    }
    @Test
    fun testAddTwoValues() {
        calc.inputValues(1)
        calc.inputValues(2)
        calc.add()
        assertEquals(3, calc.result())
    }
}

テストが通ることを確認!

class Calc {
    val stack = mutableListOf<Int>()
    
    fun inputValue(value: Int) {
        stack.add(value)
    }
    fun result() : Int? {
        return stack.lastOrNull
    }
    fun add() {
        val first = stack.removeFirst()// pop
        val secont = stack.removeFirst()
        val result = first + second
        stack.add(result)
    }
}

TDDで電卓を作ろう

class Calc {
    val stack = mutableListOf<Int>()
    
    fun inputValue(value: Int) {
        stack.add(value)
    }
    fun result() : Int? {
        return stack.lastOrNull
    }
    fun add() {
        val first = stack.removeFirst()// pop
        val secont = stack.removeFirst()
        val result = first + second
        stack.add(result)
    }
}

1. テストを1つ書く

2. 全てのテストを実行&新規追加分は失敗

3. 実装を追加する

4. 全てのテストが通ることを確認する

5. リファクタリングを行う

class CalcTest {

    lateinit var calc: Calc
    
    @BeforeEach
    fun beforeEachTest() {
        calc = Calc()
    }
    
    @Test
    fun testInputSingleValue() {
    <中略>
    }
    
    @Test
    fun testInputTwoValues() {
    <中略>
    }
    @Test
    fun testAddTwoValues() {
        calc.inputValues(1)
        calc.inputValues(2)
        calc.add()
        assertEquals(3, calc.result())
    }
}

リファクタリングするところは無さそう

TDDの目的(再掲)

  • 途中までの実装を壊す心配がなくなる
    • リファクタリングしやすい
    • 設計の見直しを常にしながら開発できる
  • テストを通すことに集中できる
    • 頭の中の余計なノイズを遮断できる

個人的には最初からTDDやらずに

途中からやります

最後に

テスト関連おすすめ書籍

  • 自動テスト入門/コアとなる考え方の本
  • テスト駆動開発の本
  • あとは必要に応じて各testing frameworkのリファレンスや本

安心するために自動テストを書きましょう

おわり

9/24サポーターズイベント 単体テスト

By shoki

9/24サポーターズイベント 単体テスト

  • 644