Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

今日のゴール

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

No content

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

Intellij IDEA便利

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

@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)) } } } 作りたいコードの例

Slide 33

Slide 33 text

@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のシグネチャ

Slide 34

Slide 34 text

@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

Slide 35

Slide 35 text

@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のリスト

Slide 36

Slide 36 text

@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

Slide 37

Slide 37 text

@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テストケースとして 実行される

Slide 38

Slide 38 text

@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を テストケース名に指定

Slide 39

Slide 39 text

@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)) } } }

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

ライブコーディング

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

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

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

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

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

variables($context$)の設定

Slide 55

Slide 55 text

No content

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

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

Slide 58

Slide 58 text

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() } } テンプレート例

Slide 59

Slide 59 text

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文を設定しておく

Slide 60

Slide 60 text

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() } } ファイル作成時に値を入力できる

Slide 61

Slide 61 text

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

Slide 62

Slide 62 text

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

Slide 63

Slide 63 text

No content

Slide 64

Slide 64 text

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

Slide 65

Slide 65 text

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

Slide 66

Slide 66 text

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

Slide 67

Slide 67 text

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

Slide 68

Slide 68 text

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

Slide 69

Slide 69 text

Intellij IDEA便利

Slide 70

Slide 70 text

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

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

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

Slide 74

Slide 74 text

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

Slide 75

Slide 75 text

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

Slide 76

Slide 76 text

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

Slide 77

Slide 77 text

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

Slide 78

Slide 78 text

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

Slide 79

Slide 79 text

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

Slide 80

Slide 80 text

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) こんな感じ

Slide 81

Slide 81 text

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) 現在のカーソル位置を取得

Slide 82

Slide 82 text

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を取得

Slide 83

Slide 83 text

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が 関数の一部だったらその関数の情報を取得

Slide 84

Slide 84 text

PSIからFunctionの情報を取得

Slide 85

Slide 85 text

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

Slide 86

Slide 86 text

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

Slide 87

Slide 87 text

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

Slide 88

Slide 88 text

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

Slide 89

Slide 89 text

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

Slide 90

Slide 90 text

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

Slide 91

Slide 91 text

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

Slide 92

Slide 92 text

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

Slide 93

Slide 93 text

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

Slide 94

Slide 94 text

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

Slide 95

Slide 95 text

Dynamic Testのコードを生成

Slide 96

Slide 96 text

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

Slide 97

Slide 97 text

@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)) } } } 作りたいコードの例

Slide 98

Slide 98 text

テストメソッドの生成 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())

Slide 99

Slide 99 text

テストメソッドの生成 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パターンで追加していく

Slide 100

Slide 100 text

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

Slide 101

Slide 101 text

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

Slide 102

Slide 102 text

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

Slide 103

Slide 103 text

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

Slide 104

Slide 104 text

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

Slide 105

Slide 105 text

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

Slide 106

Slide 106 text

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

Slide 107

Slide 107 text

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

Slide 108

Slide 108 text

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

Slide 109

Slide 109 text

まとめ

Slide 110

Slide 110 text

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

Slide 111

Slide 111 text

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

Slide 112

Slide 112 text

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

Slide 113

Slide 113 text

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

Slide 114

Slide 114 text

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