学習記録

たまには誰かの役にたったらいいな

Javaによる抽象クラスとprotectedメソッドのユニットテスト

Introduction

JunitJavaのテストコードを書きました。流れは参考文献のパクリで、記事は自分の頭で整理する用です。普通のクラスのテストはそれほど大変ではなないけども、抽象クラスってどうやってテストするのかな?ってことでこの記事を参考にしました。後々、Kotlinでも書きたい…。開発環境はIntelliJ IDEA CEです。ビルドツールはGradleを使いました。Eclipseは重たいってのと、個人的な趣味でIntelliJを使ってます。

References

Description

今回のコードはGithubこのリポジトリにあげてあります。 プロジェクトの構成で、

  • 実装用コードのディレクトリのパスは Example/src/main/java/
  • テスト用コードのディレクトリのパスはExample/src/test/java/ です。

リポジトリを落としてきて、gradle testを打てば、テストが実行できます。Gradleの使い方はとかは、公式とかを参考にしてください。今回に関しては↓だけ覚えておけばいいかもしれません。

  • ビルド gradle build
  • テスト実行 gradle test
  • ビルド結果の削除 gradle clean

Description of Main Class

テスト対象のクラスの説明です。クラスは3つあります。

  • 抽象クラス AbstractHello
  • 実装クラス Hello
  • 実装クラスを実行するためのクラス Main

各クラスの詳細は、後述します。HelloAbstractHelloを継承しています。Mainでは、Helloのメソッドを使って、処理を行なっていきます。 各クラスのメソッドは基本的に、Hello,{名前}!!という文字列を返すだけです。

Abstract Helloクラス

抽象クラスです。

  • 3つの抽象メソッドと、

    • abstractPublicSayHello
    • abstractProtectedSayHello
    • abstractSayHello
  • 名前を引数として取るアクセス修飾子が異なるメソッドが4つと、

    • publicSayHello
    • protectedSayHello
    • privateSayHello
    • sayHello
  • 引数を取らないメソッドが1つ、

    • publicSayHelloVoid
  • 自身の抽象メソッドを呼び出すメソッドが1つ、

    • callAbstractPublicSayHello

あります。 各メソッドでやっていることやりたいことは基本的にこれです。

public String publicSayHello(String name) {
  if (Objects.isNull(name)) return "Hello!!";
  return "Hello," + name + "!!";
}

引数として、Stringのnameを受け取り、nameがnullの時は挨拶だけし、そうでない時は名前付きで挨拶を返します。引数を取らないメソッドは挨拶だけ返します。抽象メソッドを呼び出すメソッドは、結果をStringへ一度格納して、それを返します。

// 引数を取らないメソッド
public String publicSayHelloVoid() {
  return "Hello!!";
}
//抽象メソッドを呼びだすメソッド
public String callAbstractPublicSayHello(String name){
  String str = abstractPublicSayHello(name);
  return str;
}

Helloクラス

AbstractHelloを継承しています。各メソッドは抽象クラスと同じことをしています。抽象メソッドをオーバーライドして、中身を同じように実装しています。

  • abstractPublicSayHello
  • abstractProtectedSayHello
  • abstractSayHello
@Override
public String abstractPublicSayHello(String name) {
  if (Objects.isNull(name)) return "Hello!!";
  return "Hello," + name + "!!";
}

テスト用に同じ処理をするプライベートメソッドも定義してあります。

  • privateSayHello
  • privateSayHello2

1と2の違いは、1には引数があり、2にはないということです。 メソッドの処理の説明は割愛します。

Mainクラス

このクラスは別に作る必要はないです。mainメソッドで、Helloクラスを使って処理行なっています。mainメソッドでは次のことをも行います。Helloクラスのインスタンスを作って、各メソッドを呼び出し、その戻り値をリストへ挿入していきます。最後に、リストの中身を標準出力します。

public static void main(String[] args) {
  // Helloクラスのインスタンス化
  Hello hello = new Hello();
  // 挨拶を格納するリストをインスタンス化
  List<String> greetings = new ArrayList<String>();
  //挨拶をリストへ格納する
  greetings.add(hello.各メソッド);
  // 挨拶をする(※forEachとメソッド参照を使う)
  greetings.forEach(System.out::println);
}

Description of Test Class

今回の目的のテスト用のクラスです。クラスは3つあります。

  • AbstractHelloTest
  • AbstractHelloTest2
  • HelloTest

テスト対象のクラスにTestをつけたクラスを作成します。IntelliJだと、クラス名の上にマウスオーバーすると黄色の電球が出てきて、それをクリックするとメニューが出てくるので 、そのメニューからCreate Testをクリックして作成できます。詳細は公式のこちらを確認ください。

抽象クラスのテスト

抽象クラス用のテストを2つ作ってあります。それぞれの違いは次の通りです。

  • AbstractHelloTest
    • Mockito によるモック化
  • AbstractHelloTest2
    • 抽象クラスを継承したインナークラスを作成

まず思いついたのが、二つ目のインナークラスを作成する方です。特に新しいこともなく、問題もなさそうです。正直、これのデメリットがいまいちわかってないです。ただ、せっかくなのでMockitoを使いなあと思いました。それで作成したテストが一つ目です。二つ目はおまけです。

インナークラスのテストはこう書きました。

public class AbstractHelloTest2 {
  class MockAbstractHello extends AbstractHello{
    @Override
    いろんなメソッド{}
  }
  @Test
  public void テストメソッド() throws Exception {
  // インナークラスのインスタンス化
  MockAbstractHello mockAbstractHello = new MockAbstractHello();
  //実行結果の比較
  assertEquals(期待値, mockAbstractHello.メソッド());
  }
}

これをmockitoを使って、書き換えていきます。公式ドキュメント 3.1.0 に導入方法や使い方があるので、ちゃんとやりたい人はそちらを読んでください。 mockitoを使う場合は、インナークラスはいらないです。代わりに、Mockito.mock()を使います。そうするとこんな感じになります。

public class AbstractHelloTest {
  @Test
  public void テストメソッド() throws Exception {
    //抽象クラスのモック化
    AbstractHello abstractHelloMock = Mockito.mock(AbstractHello.class, Mockito.CALLS_REAL_METHODS);
    //実行結果の比較
    assertEquals(期待値, abstractHelloMock.メソッド());
  }
}

だいたいこれだけ知っておけば、もうテストコード書くときに困りそうにないんじゃないかと思っています。メソッドの説明は後述します。

あとは、抽象メソッドを具象メソッドが呼び出しているcallAbstractPublicSayHello()のテストです。この場合、呼び出している抽象メソッド何かしらでモック化してあげる必要があります。今回はこのようにしてあります。whenというメソッドで、メソッドのスタブを作っています。そのメソッドが呼ばれたときに何を返すかはthenReturnメソッドで指定しています。ただし、今回の実装だと「Kevin」が与えられた時のスタブを作っているので、他の引数(例えば、「Sara」)を与えたときにnullが返ってきます。

@Test
public void callAbstractPublicSayHelloTest() throws Exception{
  //抽象クラスのモック化
  AbstractHello abastractHello = Mockito.mock(AbstractHello.class, Mockito.CALLS_REAL_METHODS);
  //テスト対象のメソッドが呼び出す抽象メソッドのモック化
  Mockito.when(abastractHello.abstractPublicSayHello("Kevin")).thenReturn("Hi,Kevin!!");

  //引数にKevinを与えた場合のテスト
  String name = "Kevin";
  assertEquals("Hi,Kevin!!", abastractHello.callAbstractPublicSayHello(name));

  //引数にKevin以外を与えた場合のテスト
  name = "Sara";
  assertNull(abastractHello.callAbstractPublicSayHello(name));
}

mockメソッド

公式 から

public static T mock(Class classToMock, Answer defaultAnswer)
Parameters:
classToMock - class or interface to mock
defaultAnswer - default answer for unstubbed methods
Returns:
mock object

第1引数に、モック化したいクラスを指定します。第2引数には、Answerを指定します。アンサーの定義は Interface Answer<T>です。アンサーで、モックに対してメソッドを実行した時の返答を指定します。 今回は、CALLS_REAL_METHODS を指定しています。これを指定することで抽象メソッドの実際のメソッドを使うことができます。

whenメソッドとthenReturnメソッド

公式

public static OngoingStubbing when(T methodCall)
Parameters:
methodCall - method to be stubbed
Returns:
OngoingStubbing object used to stub fluently. Do not create a reference to this returned object.

公式

OngoingStubbing thenReturn(T value)
Parameters:
value - return value
Returns:
object that allows stubbing consecutive calls

whenメソッドの引数にスタブを作りたいメソッドを指定(許可)します。そのまま、Interface OngoingStubbing < T >の一つであるthenReturnメソッドを繋げて、書きます。thenReturnメソッドの引数には、スタブしたメソッドの戻り値を渡します。これで、メソッドのスタブが作られました。

余談: メソッドの部分モック化

mockメソッドで、Answerを指定せずに部分的にモック化する方法です。それはdoCallRealMethodを使います。調べたので書きますが、使いどころはなさそうです。 公式も

However, there are rare cases when partial mocks come handy: dealing with code you cannot change easily.

とのことです。ただ、引数がvoidのメソッドをテストするときなどには使えそうです。このような感じで使います。

@Test
public void publicSayHelloVoidTest() throws Exception {
  //抽象クラスのモック化
  AbstractHello abstractHelloMock = Mockito.mock(AbstractHello.class);
  //メソッドの部分モック化
  Mockito.doCallRealMethod().when(abstractHelloMock).publicSayHelloVoid();
  // Voidメソッドのテスト
  assertEquals("Hello!!", abstractHelloMock.publicSayHelloVoid());
  // 別のメソッドpublicSayHelloの引数を「Tom」としてテスト
  assertNull(abstractHelloMock.publicSayHello("Tom"));
}

具象クラスのテスト

普通のテストです。インナークラスを用いた抽象クラスのテストからインナークラスを定義する部分を抜けばいいです。

@Test
public void テストメソッド() {
  // テストするクラスのインスタンス化
  Hello hello = new Hello();
  // 引数有りのテスト
  assertEquals(期待値, hello.メソッド(引数));
  //引数がnullのテスト
  assertEquals(期待値), hello.メソッド(null));
}

プロテクトメソッドやプライベートメソッドのテスト

プライベートならそれを読んでいるメソッドをテストすればいいので、テストする必要はなさそうですが、一応やってみます。テスト対象のメソッドにアクセスできるように設定して、実行してあげればいいです。

@Test
public void プライベートメソッドのテスト() throws Exception {
  // テストするクラスのインスタンス化
  Hello hello = new Hello();
  //メソッドの取得
  Method method = Hello.class.getDeclaredMethod("メソッド名",引数のクラス名.class);
  //アクセス権の付与
  method.setAccessible(true);

  // 期待値と比較
  assertEquals(期待値, method.invoke(hello,引数));
}

getDeclaredMethodメソッド

公式 JavaSE13 & JDK13

public Method getDeclaredMethod​(String name, Class<?>... parameterTypes) throws NoSuchMethodException, SecurityException
パラメータ:
name - メソッドの名前
parameterTypes - パラメータ配列
戻り値:
このクラスの指定された名前とパラメータと一致するメソッドのMethodオブジェクト

Classオブジェクトで定義されているこのメソッドで、メソッドのオブジェクトを取得します。

setAccessibleメソッド

公式 JavaSE13 & JDK13

public void setAccessible​(boolean flag)
パラメータ:
flag - accessibleフラグの新しい値
例外:
InaccessibleObjectException - アクセスを有効にできない場合
SecurityException - リクエストがセキュリティ・マネージャによって拒否された場合

getDeclaredMethodメソッドで取得したMethodオブジェクトに対して、アクセス権を設定できます。戻り値はvoidのようです。

invokeメソッド

公式 JavaSE13 & JDK13

public Object invoke​(Object obj, Object... args)
パラメータ:
obj - 基本となるメソッドの呼出し元のオブジェクト
args - メソッド呼出しに使用される引数
戻り値:
このオブジェクトが表すメソッドを、パラメータargsを使用してobjにディスパッチした結果

Methodオブジェクトを実行するときに使います。第1引数にインスタンス化したオブジェクトを、第2引数以降にメソッドに渡す引数を、invokeメソッドに渡してあげます。

強制失敗

テストコードを自動生成した際に、だいたいこんな感じのコードが生成されます。

@Test
public void failTest(){
  fail("Not yet implemented!!");
}

failメソッドでテストが失敗したことを通知させています。gradle testをすると、結果として次のような物をGradleが返します。どこで失敗しているのかとか、わかりやすいですね。Gradleはテスト結果をHTMLとして、出力してくれるのですがいつもパスを忘れます。テストを強制的に失敗させると、「失敗したテストがあるよ。このレポートをみてね。パス」というメッセージを出してくれるので、わかりやすいです。ブラウザで、file:///任意/Example/build/reports/tests/test/index.htmlとかを開いてください。 ここでテスト結果を確認できます。

> Task :test FAILED

HelloTest > failTest FAILED
    java.lang.AssertionError at HelloTest.java:12

13 tests completed, 1 failed

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':test'.
> There were failing tests. See the report at: file:///任意/Example/build/reports/tests/test/index.html

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.

* Get more help at https://help.gradle.org

BUILD FAILED in 2s
5 actionable tasks: 5 executed

Conclusion

抽象クラスのテスト方法とアクセス権のないメソッドのテスト方法を試してみました。今後は、H2DBでの、DBを使ったテストを書いてみようと思います。あとはKotlinでも書きたい…。
TDD is dead. Long live testing. (DHH)」そんなことはないと思っています。どれくらいコストかけるかは考える必要はあると思いますが。