Pro Yearly is on sale from $80 to $50! »

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

7867fe52a9be4257508a516d4df61578?s=47 tkmnzm
March 16, 2020

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

7867fe52a9be4257508a516d4df61578?s=128

tkmnzm

March 16, 2020
Tweet

Transcript

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

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

  3. 今日のゴール

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

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

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

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

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

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

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

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

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

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

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

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

  16. 今回登場するもの • Live Template • File Template • Postfix Completion

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

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

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

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

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

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

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

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

  26. Intellij IDEA便利

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

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

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

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

    と思って作った
  31. 生成したいテストの構造 • Junit5のDynamic Test(@TestFactory) • テストパラメーターを表現するdata class • そのdata classのリストを持ち、

    1data classが1テストとなるように変換 する
  32. @TestFactory fun length(): Collection<DynamicTest> { data class TestCase( val text:

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

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

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

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

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

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

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

    String, val expect: Int ) return listOf<TestCase>( TestCase("test", 4), TestCase("cat", 3) ).map { case -> dynamicTest(case.toString()) { assertThat(case.text.length, equalTo(case.expect)) } } }
  40. @ParameterizedTestとの違いは? • DynamicTestはテストに必要なものを全部 1つのFunctionのスコープに閉じ込めてお けるところが個人的に好き

  41. これらを使って実際にテストを書く • Live Template • File Template • Postfix Completion

    • Parameterizedテスト生成Plugin
  42. ライブコーディング

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

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

  45. 登場したものおさらい • Live Template • File Template • Postfix Completion

    • Parameterizedテスト生成Plugin
  46. Live Template • Intellij IDEA上で自作Templateを追加化 • Preferences > Editor >

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

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

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

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

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

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

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

  54. variables($context$)の設定

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

  57. File Template • Intellij IDEA上で自作Templateを追加化 • Preferences > Editor >

    File and Code Templates
  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() } } テンプレート例
  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文を設定しておく
  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() } } ファイル作成時に値を入力できる
  61. Postfix Completion • 3rd partyのIntellij Pluginをインストー ルすることで自作テンプレートを追加可能 • github.com/xylo/intellij-postfix-templ ates

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

    > Custom Postfix Templates > Edit Templates of Current Language
  63. None
  64. .assertNull : assertNull ANY → junit.framework.TestCase.assertNull($expr$) .assertNonNull : assertNotNull ANY

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

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

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

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

    → junit.framework.TestCase.assertNotNull($expr$) 完全修飾名でimport文も追加されるが、 Kotlinのstatic importは非対応
  69. Intellij IDEA便利

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

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

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

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

    Group)
  74. PSI • Program Structure Interface • IntelliJ Platform上のプログラムの構造 をツリーで表現するインターフェース •

    特定の言語構造を操作するためのセマン ティクスとメソッドを持つ
  75. Sample.kt class Sample { val text = "Hi" fun hoge

    () { } } これをPsiで表現すると
  76. Sample.kt KtFile KtClass KtProperty KtNamedFunction class Sample { val text

    = "Hi" fun hoge () { } }
  77. KtFile KtProperty KtNamedFunction KtClass class Identifer (Sample) KtClassBody LBRACE({ )

    RBRACE( }) もう少し詳細にすると
  78. PSI Viewer Plugin • Preferences > Pluginsからインストール • ファイルのPSI構造がどうなっているかが わかる

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

  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) こんな感じ
  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) 現在のカーソル位置を取得
  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を取得
  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が 関数の一部だったらその関数の情報を取得
  84. PSIからFunctionの情報を取得

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

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

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

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

    クラス KtParameter型のリストで、 KtParameterはTypeRerefenceを持って いる
  89. Classのパース KtClass クラス名 コンストラクタ ほしい パッケージ名

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

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

  92. 型情報の取得 val context: BindingContext = typeReference.analyze() val type: KotlinType? =

    context[BindingContext.TYPE, typeReference] KotlinType型 所属するpackage等、様々な情報を取得 できる
  93. PSIのパース • Kotlin用のドキュメントはあまりない • が、PSIViewerという強い味方がいるので そこまで辛くない • KtPsiUtilといったUtil系クラスも強い味 方

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

  95. Dynamic Testのコードを生成

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

  97. @TestFactory fun length(): Collection<DynamicTest> { data class TestCase( val text:

    String, val expect: Int ) return listOf<TestCase>( TestCase("test", 4), TestCase("cat", 3) ).map { case -> dynamicTest(case.toString()) { assertThat(case.text.length, equalTo(case.expect)) } } } 作りたいコードの例
  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())
  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パターンで追加していく
  100. テストクラスの生成 val fileSpec = FileSpec.builder(packageName, "${parentName}Test") .addType( TypeSpec.classBuilder("${parentName}Test") .addFunction( testFunctionBuilder.build()

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

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

    ) .build() ) .build() テストクラスにさきほどの テストメソッドbuilderを追加
  103. コードの生成 • 生成したいコードが明確になっていれば、 あとはKotlinPoetのAPIとの戦い • ドキュメントは充実しているので、

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

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

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

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

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

  109. まとめ

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

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

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

    • Parameterizedテスト生成Plugin 自動生成の仕組みを紹介 実装を変更することで カスタマイズ可能
  113. さくさくテスト実装をはじめよう • 自分のプロジェクトのテストコードを見返 して適したものにカスタマイズしてみよう • PSIとKotlinPoetを使うと自動生成できる テストコードの幅が広がる

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