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

自動生成でさくさく実装するユニットテスト / quickly implement unit tests with automatic generation

tkmnzm
March 16, 2020

自動生成でさくさく実装するユニットテスト / quickly implement unit tests with automatic generation

tkmnzm

March 16, 2020
Tweet

More Decks by tkmnzm

Other Decks in Technology

Transcript

  1. 自動生成でさくさく
    実装するユニットテスト
    Nozomi Takuma @DroidKaigi 2020

    View Slide

  2. 自己紹介
    ● Nozomi Takuma
    ● DeNA SWETグループ
    ● Androidとテストが好き

    View Slide

  3. 今日のゴール

    View Slide

  4. 本日のゴール
    ● テストコードをさくさく書くのをサポート
    するツールについて知ってもらう
    ● それらのツールを活用することでテストを
    書くのが楽しくなりそう!と思ってもらう

    View Slide

  5. アジェンダ
    ● テストコード自動生成のモチベーション
    ● テストコード自動生成を実現する道具
    ● ライブコーディング
    ● 生成されるテストコードのカスタマイズ

    View Slide

  6. テストコード自動生成のモチベーション

    View Slide

  7. テストコードを書く
    のが好きな人?

    View Slide

  8. 人によって得意なことはさまざま
    ● アプリの設計考えるのが得意
    ● 自動テストを整備するのが得意
    ● UIをスマートに構築するのが得意

    View Slide

  9. 自動テストのモチベーションもさまざま
    ● 複雑なところだから書いておこうかな
    ● 実装と一緒に書いて安心したいな
    ● 書く時間ないしあとでまとめてやるぞ

    View Slide

  10. 自動テストを書き始めるハードル
    ● テストフレームワークの使い方
    ● テストライブラリの使い方
    ● Androidアプリ固有のテスト方法
    ● 採用している技術固有のテスト方法

    View Slide

  11. いざ書き始めようとしたとき
    テスト書くぞ
    テストコードの書き方忘れた、調べるぞ
    テストライブラリの使い方忘れた、調べるぞ
    AACのテストの書き方忘れた、調べるぞ

    View Slide

  12. 実装完了が遠い
    ちょっとめんどくさい

    View Slide

  13. テストコード自動生成で解決したいこと
    ● テストコードの書き方を完全に記憶して
    いなくても書き始められるようにしたい
    ● テストコードを書く億劫さを少なくして、
    書くハードルを下げたい

    View Slide

  14. テストコード自動生成を実現する道具

    View Slide

  15. 方針
    ● IntelliJ IDEAの力を借りる
    ● さくさくテストコード実装のサポートを目
    指す
    ● テストパラメータやテストケースの自動生
    成は目指さない

    View Slide

  16. 今回登場するもの
    ● Live Template
    ● File Template
    ● Postfix Completion
    ● Parameterizedテスト生成Plugin

    View Slide

  17. Live Template
    ● スニペット
    ● キーワードをタイプしてテンプレートの
    コードを生成する

    View Slide

  18. キーワードをタイプしてEnter

    View Slide

  19. テンプレートからコードが生成される

    View Slide

  20. File Template
    ● ファイル作成時のテンプレート
    ● File > Newをしたときに選択できる

    View Slide

  21. View Slide

  22. テンプレートから
    ファイルが生成される

    View Slide

  23. Postfix Completion
    ● 式の後に .キーワードをタイプして
    テンプレートの形式にコードを置き換える

    View Slide

  24. 変数.キーワードを
    タイプしてenter

    View Slide

  25. テンプレートからコード生成
    変数を任意の位置における

    View Slide

  26. Intellij IDEA便利

    View Slide

  27. Parameterizedテストも
    さくさく実装したい

    View Slide

  28. Parameterizedテスト
    ● テストをパラメータ化することで入力値や
    期待値を動的に受け取れるようにする
    ● 組み合わせテストや境界値テスト等、
    1つのテスト対象を複数のパターンでテス
    トしたいときに便利

    View Slide

  29. Parameterizedテスト生成Plugin
    ● テスト対象Functionのシグネチャから
    Parameterizedテストの雛形を自動生成
    ● github.com/tkmnzm/test-gen

    View Slide

  30. Inspired by gotests
    ● github.com/cweill/gotests
    ● Goのテーブル駆動テストを生成してくれる
    ● Goに入門したときに助けられたので、
    Kotlinでも同じことを実現できたらいいな
    と思って作った

    View Slide

  31. 生成したいテストの構造
    ● Junit5のDynamic Test(@TestFactory)
    ● テストパラメーターを表現するdata class
    ● そのdata classのリストを持ち、
    1data classが1テストとなるように変換
    する

    View Slide

  32. @TestFactory
    fun length(): Collection {
    data class TestCase(
    val text: String,
    val expect: Int
    )
    return listOf(
    TestCase("test", 4),
    TestCase("cat", 3)
    ).map { case ->
    dynamicTest(case.toString()) {
    assertThat(case.text.length, equalTo(case.expect))
    }
    }
    }
    作りたいコードの例

    View Slide

  33. @TestFactory
    fun length(): Collection {
    data class TestCase(
    val text: String,
    val expect: Int
    )
    return listOf(
    TestCase("test", 4),
    TestCase("cat", 3)
    ).map { case ->
    dynamicTest(case.toString()) {
    assertThat(case.text.length, equalTo(case.expect))
    }
    }
    }
    DynamicTestのシグネチャ

    View Slide

  34. @TestFactory
    fun length(): Collection {
    data class TestCase(
    val text: String,
    val expect: Int
    )
    return listOf(
    TestCase("test", 4),
    TestCase("cat", 3)
    ).map { case ->
    dynamicTest(case.toString()) {
    assertThat(case.text.length, equalTo(case.expect))
    }
    }
    }
    スコープがFunction内の
    テストパラメータを保持
    するdata class

    View Slide

  35. @TestFactory
    fun length(): Collection {
    data class TestCase(
    val text: String,
    val expect: Int
    )
    return listOf(
    TestCase("test", 4),
    TestCase("cat", 3)
    ).map { case ->
    dynamicTest(case.toString()) {
    assertThat(case.text.length, equalTo(case.expect))
    }
    }
    }
    テストパラメータを
    保持するdata classのリスト

    View Slide

  36. @TestFactory
    fun length(): Collection {
    data class TestCase(
    val text: String,
    val expect: Int
    )
    return listOf(
    TestCase("test", 4),
    TestCase("cat", 3)
    ).map { case ->
    dynamicTest(case.toString()) {
    assertThat(case.text.length, equalTo(case.expect))
    }
    }
    }
    DynamicTest型にmap

    View Slide

  37. @TestFactory
    fun length(): Collection {
    data class TestCase(
    val text: String,
    val expect: Int
    )
    return listOf(
    TestCase("test", 4),
    TestCase("cat", 3)
    ).map { case ->
    dynamicTest(case.toString()) {
    assertThat(case.text.length, equalTo(case.expect))
    }
    }
    }
    1DynamicTestが
    1テストケースとして
    実行される

    View Slide

  38. @TestFactory
    fun length(): Collection {
    data class TestCase(
    val text: String,
    val expect: Int
    )
    return listOf(
    TestCase("test", 4),
    TestCase("cat", 3)
    ).map { case ->
    dynamicTest(case.toString()) {
    assertThat(case.text.length, equalTo(case.expect))
    }
    }
    }
    data classのtoStringを
    テストケース名に指定

    View Slide

  39. @TestFactory
    fun length(): Collection {
    data class TestCase(
    val text: String,
    val expect: Int
    )
    return listOf(
    TestCase("test", 4),
    TestCase("cat", 3)
    ).map { case ->
    dynamicTest(case.toString()) {
    assertThat(case.text.length, equalTo(case.expect))
    }
    }
    }

    View Slide

  40. @ParameterizedTestとの違いは?
    ● DynamicTestはテストに必要なものを全部
    1つのFunctionのスコープに閉じ込めてお
    けるところが個人的に好き

    View Slide

  41. これらを使って実際にテストを書く
    ● Live Template
    ● File Template
    ● Postfix Completion
    ● Parameterizedテスト生成Plugin

    View Slide

  42. ライブコーディング

    View Slide

  43. 生成されるテストコードのカスタマイズ

    View Slide

  44. 自分のプロダクトに適したものにする
    ● 使っているテストライブラリやテストフ
    レームワークにあわせてもっと便利にする
    ● 既存のテストコードを見てみて、どこが自
    動生成されたら嬉しいか考えてみる

    View Slide

  45. 登場したものおさらい
    ● Live Template
    ● File Template
    ● Postfix Completion
    ● Parameterizedテスト生成Plugin

    View Slide

  46. Live Template
    ● Intellij IDEA上で自作Templateを追加化
    ● Preferences > Editor > Live Templates

    View Slide

  47. ここから追加
    Template Groupで
    グルーピングもできる

    View Slide

  48. テンプレートを呼び出すキーワード

    View Slide

  49. どの言語のときに有効になるか
    設定しないと使えない

    View Slide

  50. テンプレートをかくところ

    View Slide

  51. androidx.work.testing.WorkManagerTestInitHelper
    .initializeTestWorkManager(
    $context$,
    androidx.work.Configuration.Builder().build())
    テンプレート例

    View Slide

  52. androidx.work.testing.WorkManagerTestInitHelper
    .initializeTestWorkManager(
    $context$,
    androidx.work.Configuration.Builder().build())
    完全修飾名で指定すると
    import文も自動で追加してくれる

    View Slide

  53. androidx.work.testing.WorkManagerTestInitHelper
    .initializeTestWorkManager(
    $context$,
    androidx.work.Configuration.Builder().build())
    動的に変わる部分を変数として設定

    View Slide

  54. variables($context$)の設定

    View Slide

  55. View Slide

  56. コード生成時にvariablesに対して
    どのようなアクションをするかを設定できる
    プルダウンになっていてプリセットを選べる

    View Slide

  57. File Template
    ● Intellij IDEA上で自作Templateを追加化
    ● Preferences > Editor > File and Code
    Templates

    View Slide

  58. package ${PACKAGE_NAME}
    import androidx.room.Room ~以下省略~
    @RunWith(AndroidJUnit4::class)
    class ${NAME}Test {
    private lateinit var context: Context
    private lateinit var db: ${DBNAME}
    @Before
    fun setUp() {
    context = ApplicationProvider.getApplicationContext()
    db = Room.inMemoryDatabaseBuilder(
    context, ${DBNAME}::class.java).build()
    }
    @After
    @Throws(IOException::class)
    fun tearDown() { db.close() }
    }
    テンプレート例

    View Slide

  59. package ${PACKAGE_NAME}
    import androidx.room.Room ~以下省略~
    @RunWith(AndroidJUnit4::class)
    class ${NAME}Test {
    private lateinit var context: Context
    private lateinit var db: ${DBNAME}
    @Before
    fun setUp() {
    context = ApplicationProvider.getApplicationContext()
    db = Room.inMemoryDatabaseBuilder(
    context, ${DBNAME}::class.java).build()
    }
    @After
    @Throws(IOException::class)
    fun tearDown() { db.close() }
    }
    完全修飾名ではなく
    Import文を設定しておく

    View Slide

  60. package ${PACKAGE_NAME}
    import androidx.room.Room ~以下省略~
    @RunWith(AndroidJUnit4::class)
    class ${NAME}Test {
    private lateinit var context: Context
    private lateinit var db: ${DBNAME}
    @Before
    fun setUp() {
    context = ApplicationProvider.getApplicationContext()
    db = Room.inMemoryDatabaseBuilder(
    context, ${DBNAME}::class.java).build()
    }
    @After
    @Throws(IOException::class)
    fun tearDown() { db.close() }
    }
    ファイル作成時に値を入力できる

    View Slide

  61. Postfix Completion
    ● 3rd partyのIntellij Pluginをインストー
    ルすることで自作テンプレートを追加可能
    ● github.com/xylo/intellij-postfix-templ
    ates
    ● Preferences > Pluginsからインストール

    View Slide

  62. intellij-postfix-templates
    ● Preferences > Custom Postfix Templates
    からテンプレートを書くファイルを追加
    ● 編集はTools > Custom Postfix Templates
    > Edit Templates of Current Language

    View Slide

  63. View Slide

  64. .assertNull : assertNull
    ANY → junit.framework.TestCase.assertNull($expr$)
    .assertNonNull : assertNotNull
    ANY → junit.framework.TestCase.assertNotNull($expr$)
    テンプレート例

    View Slide

  65. .assertNull : assertNull
    ANY → junit.framework.TestCase.assertNull($expr$)
    .assertNonNull : assertNotNull
    ANY → junit.framework.TestCase.assertNotNull($expr$)
    Postfix completionを呼び出す
    キーワード

    View Slide

  66. .assertNull : assertNull
    ANY → junit.framework.TestCase.assertNull($expr$)
    .assertNonNull : assertNotNull
    ANY → junit.framework.TestCase.assertNotNull($expr$)
    どの式で有効になるか
    KotlinはANYのみ

    View Slide

  67. .assertNull : assertNull
    ANY → junit.framework.TestCase.assertNull($expr$)
    .assertNonNull : assertNotNull
    ANY → junit.framework.TestCase.assertNotNull($expr$)
    ここにタイプ中の式がはいる

    View Slide

  68. .assertNull : assertNull
    ANY → junit.framework.TestCase.assertNull($expr$)
    .assertNonNull : assertNotNull
    ANY → junit.framework.TestCase.assertNotNull($expr$)
    完全修飾名でimport文も追加されるが、
    Kotlinのstatic importは非対応

    View Slide

  69. Intellij IDEA便利

    View Slide

  70. Parameterizedテスト生成Plugin
    ● 静的なテンプレートでカスタマイズする
    仕組みは用意していない
    ● ここではPluginの仕組みと、カスタマイズ
    時に必要な自動生成を実現する技術につい
    て紹介

    View Slide

  71. Parameterizedテスト生成を実現する技術

    View Slide

  72. ① PSIからFunctionの情報を取得
    ② Functionの情報からDynamic Testのコードを生成
    フォーカスしているのFunctionのPSIを取得
    テストディレクトリにKtファイルを書き出し

    View Slide

  73. Intellij Plugin Actions
    ● Pluginの振る舞いを実装する
    ● AnAction(com.intellij.openapi.actionSystem)を継承
    ● Generate(⌘N)時に選択できるように設定
    (Generate Group)

    View Slide

  74. PSI
    ● Program Structure Interface
    ● IntelliJ Platform上のプログラムの構造
    をツリーで表現するインターフェース
    ● 特定の言語構造を操作するためのセマン
    ティクスとメソッドを持つ

    View Slide

  75. Sample.kt
    class Sample {
    val text = "Hi"
    fun hoge () {
    }
    }
    これをPsiで表現すると

    View Slide

  76. Sample.kt
    KtFile
    KtClass
    KtProperty KtNamedFunction
    class Sample {
    val text = "Hi"
    fun hoge () {
    }
    }

    View Slide

  77. KtFile
    KtProperty KtNamedFunction
    KtClass
    class
    Identifer
    (Sample)
    KtClassBody
    LBRACE({ ) RBRACE( })
    もう少し詳細にすると

    View Slide

  78. PSI Viewer Plugin
    ● Preferences > Pluginsからインストール
    ● ファイルのPSI構造がどうなっているかが
    わかる

    View Slide

  79. フォーカスしているFunctionのPSI取得
    ● Actionは現在どのファイルのどの位置に
    カーソルが当たっているか等が取得できる
    ● 現在カーソルが当たっているPSIのelement
    を取得し、それが所属するFunctionのPSI
    を取得する

    View Slide

  80. val editor = anActionEvent.getData(CommonDataKeys.EDITOR)
    val offset = editor.caretModel.offset
    val psiFile = anActionEvent.getData(CommonDataKeys.PSI_FILE)
    val element = psiFile.findElementAt(offset)
    val namedFunctionPsi
    = PsiTreeUtil.getParentOfType(element, KtNamedFunction::class.java)
    こんな感じ

    View Slide

  81. val editor = anActionEvent.getData(CommonDataKeys.EDITOR)
    val offset = editor.caretModel.offset
    val psiFile = anActionEvent.getData(CommonDataKeys.PSI_FILE)
    val element = psiFile.findElementAt(offset)
    val namedFunctionPsi
    = PsiTreeUtil.getParentOfType(element, KtNamedFunction::class.java)
    現在のカーソル位置を取得

    View Slide

  82. val editor = anActionEvent.getData(CommonDataKeys.EDITOR)
    val offset = editor.caretModel.offset
    val psiFile = anActionEvent.getData(CommonDataKeys.PSI_FILE)
    val element = psiFile.findElementAt(offset)
    val namedFunctionPsi
    = PsiTreeUtil.getParentOfType(element, KtNamedFunction::class.java)
    カーソルがあたっているelementを取得

    View Slide

  83. val editor = anActionEvent.getData(CommonDataKeys.EDITOR)
    val offset = editor.caretModel.offset
    val psiFile = anActionEvent.getData(CommonDataKeys.PSI_FILE)
    val element = psiFile.findElementAt(offset)
    val namedFunctionPsi
    = PsiTreeUtil.getParentOfType(element, KtNamedFunction::class.java)
    カーソルがあたっているElementが
    関数の一部だったらその関数の情報を取得

    View Slide

  84. PSIからFunctionの情報を取得

    View Slide

  85. Functionのパース
    KtNamedFunction Function名
    戻り値の型
    引数
    クラス
    ほしい

    View Slide

  86. Functionのパース
    KtNamedFunction name
    typeReference
    valueParamters
    Function名
    戻り値の型
    引数
    ほしい
    containingClass クラス

    View Slide

  87. Functionのパース
    KtNamedFunction name
    typeReference
    valueParamters
    Function名
    戻り値の型
    引数
    ほしい
    containingClass クラス
    TypeReference型で
    型の情報を持っている

    View Slide

  88. Functionのパース
    KtNamedFunction name
    typeReference
    valueParamters
    Function名
    戻り値の型
    引数
    ほしい
    containingClass クラス
    KtParameter型のリストで、
    KtParameterはTypeRerefenceを持って
    いる

    View Slide

  89. Classのパース
    KtClass クラス名
    コンストラクタ
    ほしい
    パッケージ名

    View Slide

  90. Classのパース
    KtClass name
    primaryConstructor.
    valueParameters
    クラス名
    コンストラクタ
    ほしい
    KtPsiUtil#getPacakgeName パッケージ名

    View Slide

  91. 型情報の取得
    ● PSIはソースコードを見たまんまツリー構
    造で表現したもので、PSIの要素から型の
    情報を直接とることはできない
    ● KtElement#analyzeをすると型情報を
    もつBindingContextが取得できる

    View Slide

  92. 型情報の取得
    val context: BindingContext = typeReference.analyze()
    val type: KotlinType? = context[BindingContext.TYPE, typeReference]
    KotlinType型
    所属するpackage等、様々な情報を取得
    できる

    View Slide

  93. PSIのパース
    ● Kotlin用のドキュメントはあまりない
    ● が、PSIViewerという強い味方がいるので
    そこまで辛くない
    ● KtPsiUtilといったUtil系クラスも強い味

    View Slide

  94. ① PSIからFunctionの情報を取得
    ② Functionの情報からDynamic Testのコードを生成
    フォーカスしているのFunctionのPSIを取得
    テストディレクトリにKtファイルを書き出し

    View Slide

  95. Dynamic Testのコードを生成

    View Slide

  96. Kotlin Poet
    ● github.com/square/kotlinpoet
    ● Aptによるコード生成などでおなじみ
    ● BuilderパターンでKotlinのコードを生成
    する

    View Slide

  97. @TestFactory
    fun length(): Collection {
    data class TestCase(
    val text: String,
    val expect: Int
    )
    return listOf(
    TestCase("test", 4),
    TestCase("cat", 3)
    ).map { case ->
    dynamicTest(case.toString()) {
    assertThat(case.text.length, equalTo(case.expect))
    }
    }
    }
    作りたいコードの例

    View Slide

  98. テストメソッドの生成
    val collection = ClassName("kotlin.collections", "Collection")
    val dynamicTest = ClassName("org.junit.jupiter.api", "DynamicTest")
    val testFunctionBuilder =
    FunSpec.builder("test${function.name.capitalize()}")
    .returns(collection.parameterizedBy(dynamicTest))
    .addAnnotation(TestFactory::class.java)
    .addStatement("%L", testCaseClass.build())
    .addStatement("%L", dynamicTestStatement.build())

    View Slide

  99. テストメソッドの生成
    val collection = ClassName("kotlin.collections", "Collection")
    val dynamicTest = ClassName("org.junit.jupiter.api", "DynamicTest")
    val testFunctionBuilder =
    FunSpec.builder("test${function.name.capitalize()}")
    .returns(collection.parameterizedBy(dynamicTest))
    .addAnnotation(TestFactory::class.java)
    .addStatement("%L", testCaseClass.build())
    .addStatement("%L", dynamicTestStatement.build())
    テストメソッド内に定義したいものを
    Builderパターンで追加していく

    View Slide

  100. テストクラスの生成
    val fileSpec = FileSpec.builder(packageName, "${parentName}Test")
    .addType(
    TypeSpec.classBuilder("${parentName}Test")
    .addFunction(
    testFunctionBuilder.build()
    )
    .build()
    )
    .build()

    View Slide

  101. テストクラスの生成
    val fileSpec = FileSpec.builder(packageName, "${parentName}Test")
    .addType(
    TypeSpec.classBuilder("${parentName}Test")
    .addFunction(
    testFunctionBuilder.build()
    )
    .build()
    )
    .build()
    生成するファイルの設定

    View Slide

  102. テストクラスの生成
    val fileSpec = FileSpec.builder(packageName, "${parentName}Test")
    .addType(
    TypeSpec.classBuilder("${parentName}Test")
    .addFunction(
    testFunctionBuilder.build()
    )
    .build()
    )
    .build()
    テストクラスにさきほどの
    テストメソッドbuilderを追加

    View Slide

  103. コードの生成
    ● 生成したいコードが明確になっていれば、
    あとはKotlinPoetのAPIとの戦い
    ● ドキュメントは充実しているので、

    View Slide

  104. テストファイルの書き出し

    View Slide

  105. テストsrcにファイルを書き出す
    ● テストsrcのディレクトリ(PsiDirectory)
    を取得
    ● テスト対象と同じパッケージに生成するよ
    うにPsiDirectoryにディレクトリを追加
    ● 生成したコードをPsiFileにして追加

    View Slide

  106. テストファイル書き出し時の工夫
    ● DirectoryChooserUtilでテストsrcを選択
    させる
    ● CodeStyleManagerでコードのフォーマット
    ● 同名のファイルがあるときは、
    PsiFile.addで新しいFunctionのみ追加

    View Slide

  107. テストファイルの書き出し
    ● IntelliJの便利な機能たちをコードから呼
    び出すことができる
    ● やりたいことと似ている機能を調べてみる
    と使えるものが出てくる
    ● ドキュメントはあまりない

    View Slide

  108. ① PSIからFunctionの情報を取得
    ② Functionの情報からDynamic Testのコードを生成
    フォーカスしているのFunctionのPSIを取得
    テストディレクトリにKtファイルを書き出し

    View Slide

  109. まとめ

    View Slide

  110. テストコード自動生成を実現する道具
    ● Live Template
    ● File Template
    ● Postfix Completion
    ● Parameterizedテスト生成Plugin

    View Slide

  111. テストコード自動生成を実現する道具
    ● Live Template
    ● File Template
    ● Postfix Completion
    ● Parameterizedテスト生成Plugin
    Android Studioから
    カスタマイズ可能

    View Slide

  112. テストコード自動生成を実現する道具
    ● Live Template
    ● File Template
    ● Postfix Completion
    ● Parameterizedテスト生成Plugin
    自動生成の仕組みを紹介
    実装を変更することで
    カスタマイズ可能

    View Slide

  113. さくさくテスト実装をはじめよう
    ● 自分のプロジェクトのテストコードを見返
    して適したものにカスタマイズしてみよう
    ● PSIとKotlinPoetを使うと自動生成できる
    テストコードの幅が広がる

    View Slide

  114. ご清聴ありがとうございました!

    View Slide