安心して生きるための
自動テスト
株式会社ゆめみ
浜名将輝
自己紹介
自己紹介
所属: 株式会社ゆめみ(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つ書く
-
全てのテストを実行し、新規追加分は失敗することを確認する
-
実装を追加する
-
全てのテストが通ることを確認する
-
リファクタリングを行う
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サポーターズイベント 単体テスト
- 753