かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

Spring Boot 1.4.x の Web アプリを 1.5.x へバージョンアップする ( 番外編 )( static メソッドをモック化してテストするには? )

概要

記事一覧こちらです。

Spring Framework の DI コンテナが管理するクラスのインスタンスのメソッドから DI コンテナで管理していないクラスの static メソッドが呼び出されている場合に、static メソッドをモック化して、Spring Framework の DI コンテナが管理するクラスのテストを書く方法があるのか知りたくなったので、いろいろ調べてみました。今回はそのメモ書きです。

モック化しないテストは Groovy + Spock で書きたいので、モック化する場合のテストも可能なら Groovy + Spock で、Spock が無理なら Groovy + JUnit4 で書く方法を調べています。

参照したサイト・書籍

  1. Spock Framework Reference Documentation - Interaction Based Testing
    http://spockframework.org/spock/docs/1.1/interaction_based_testing.html

  2. PowerMock
    https://github.com/powermock/powermock

  3. PowerMockとJUnitのRuleを使うときのメモ
    http://irof.hateblo.jp/entry/20130517/p1

  4. Mockito + PowerMock LinkageError while mocking system class
    https://stackoverflow.com/questions/16520699/mockito-powermock-linkageerror-while-mocking-system-class

目次

  1. テストで使用するクラスを書く
  2. Spock で正常処理のテストを書く
  3. static メソッドをモック化して実施したいテストとは?
  4. Groovy + Spock + GroovyMock で static メソッドをモック化してテストを書いてみる
  5. Groovy + JUnit4 + PowerMock で static メソッドをモック化してテストを書いてみる
  6. Groovy + Spock + PowerMock で static メソッドをモック化してテストを書いてみる
  7. Spock ではうまく動作しないので JUnit4 でテストを書いてみる
  8. まとめ
  9. PowerMock で void のメソッドをモック化するには?

手順

テストで使用するクラスを書く

今回テストで使用するクラスを2つ書きます。SampleHelper クラスと BrowfishUtils クラスです。

最終的に static メソッドのモックを作成してテストを書きたいのは SampleHelper クラスです。以下の仕様です。

  • SampleHelper クラスは @Component アノテーションを付加して Spring Framework の DIコンテナでインスタンス化します。
  • SampleHelper#encrypt メソッドは BrowfishUtils#encrypt メソッドを呼び出して渡された文字列を Browfish で暗号化します。
  • BrowfishUtils#encrypt メソッドは static メソッドです。
  • BrowfishUtils#encrypt メソッドは処理の中で発生する例外をそのまま throw しますが、SampleHelper#encrypt メソッドは RuntimeException に置き換えます。
  • 余談ですが、今回は static メソッドのテストの方法を調べるのが目的なので BrowfishUtils クラスでは暗号化モードには ECB を使用したのですが、ECB を使うと Error Prone が InsecureCryptoUsage というエラーを出力します(ECB で暗号化した文字列は常に同じになるので暗号化としてふさわしくない、というエラーのようです)。何となく作ったサンプルでしたが、Error Prone はこんな所までチェックするのか、と驚きました。
package ksbysample.webapp.lending;

import org.springframework.stereotype.Component;

import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;

@Component
public class SampleHelper {

    public String encrypt(String str) {
        try {
            return BrowfishUtils.encrypt(str);
        } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException
                | BadPaddingException | IllegalBlockSizeException e) {
            throw new RuntimeException(e);
        }
    }

}
package ksbysample.webapp.lending;

import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;

public class BrowfishUtils {

    private static final String KEY = "sample";
    private static final String ALGORITHM = "Blowfish";
    private static final String TRANSFORMATION = "Blowfish/ECB/PKCS5Padding";

    public static String encrypt(String str)
            throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException
            , BadPaddingException, IllegalBlockSizeException {
        SecretKeySpec secretKeySpec = new SecretKeySpec(KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM);
        Cipher cipher = Cipher.getInstance(TRANSFORMATION);
        cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec);
        byte[] encryptedBytes = cipher.doFinal(str.getBytes(StandardCharsets.UTF_8));
        return Base64.getEncoder().encodeToString(encryptedBytes);
    }

}

Spock で正常処理のテストを書く

以下のテストを書きます。

package ksbysample.webapp.lending

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import spock.lang.Specification
import spock.lang.Unroll

@SpringBootTest
class SampleHelperTest extends Specification {

    @Autowired
    private SampleHelper sampleHelper

    @Unroll
    def "SampleHelper.encrypt(#str) --> #result"() {
        expect:
        sampleHelper.encrypt(str) == result

        where:
        str        || result
        "test"     || "bjMKKlhqu/c="
        "xxxxxxxx" || "ERkXmOeArBKwGbCh+M6aHw=="
    }

}

実行して成功することを確認します。

f:id:ksby:20170609013513p:plain

static メソッドをモック化して実施したいテストとは?

最終的に書きたいのは以下の内容のテストです。

  • BrowfishUtils#encrypt メソッドをモック化して、呼び出されたら NoSuchPaddingException が throw されるようにします。
  • SampleHelper#encrypt メソッドを呼び出したら RuntimeException が throw されることを確認します。

Groovy + Spock + GroovyMock で static メソッドをモック化してテストを書いてみる

Interaction Based Testing の中の「Mocking Static Methods」を見ると Spock でも static メソッドをモック化できるようです。

最初は SampleHelper#encrypt メソッドではなく BrowfishUtils#encrypt メソッドでテストを書いてみます。

モックを使ったテストは正常処理のテストとは別にしたいので、SampleHelperTest クラスは @RunWith(Enclosed.class) を付加して extends Specification を削除し、「正常処理のテスト」static class と「異常処理のテスト」static class の2つに分けます。

package ksbysample.webapp.lending

import org.junit.experimental.runners.Enclosed
import org.junit.runner.RunWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import spock.lang.Specification
import spock.lang.Unroll

import javax.crypto.NoSuchPaddingException

@RunWith(Enclosed.class)
class SampleHelperTest {

    @SpringBootTest
    static class 正常処理のテスト extends Specification {

        @Autowired
        private SampleHelper sampleHelper

        @Unroll
        def "SampleHelper.encrypt(#str) --> #result"() {
            expect:
            sampleHelper.encrypt(str) == result

            where:
            str        || result
            "test"     || "bjMKKlhqu/c="
            "xxxxxxxx" || "ERkXmOeArBKwGbCh+M6aHw=="
        }

    }

    static class 異常処理のテスト extends Specification {

        def "BrowfishUtils.encryptを呼ぶとNoSuchPaddingExceptionをthrowする"() {
            given:
            GroovyMock(BrowfishUtils, global: true)
            BrowfishUtils.encrypt(_) >> { throw new NoSuchPaddingException() }

            when:
            BrowfishUtils.encrypt("test")

            then:
            thrown(NoSuchPaddingException.class)
        }

    }

}

テストを実行すると成功しました。

f:id:ksby:20170613005146p:plain

SampleHelper#encrypt メソッドを呼び出すテストに変更してみます。

    @SpringBootTest
    static class GroovyMockを使用した異常処理のテスト extends Specification {

        @Autowired
        private SampleHelper sampleHelper

        def "SampleHelper.encryptを呼ぶとRuntimeExceptionをthrowする"() {
            given:
            GroovyMock(BrowfishUtils, global: true)
            BrowfishUtils.encrypt(_) >> { throw new NoSuchPaddingException() }

            when:
            sampleHelper.encrypt("test")

            then:
            thrown(RuntimeException.class)
        }

    }

テストを実行すると Expected exception of type 'java.lang.RuntimeException', but no exception was thrown というメッセージが出力されて失敗しました。例外を throw するようモックにした BrowfishUtils#encrypt メソッドが SampleHelper#encrypt メソッドからは呼び出されていないようです。

f:id:ksby:20170613005635p:plain

GroovyMock を使う方法だと Spring Framework の DI コンテナが絡んだ時にうまく動作しないようなので、他の方法を探します。

Groovy + JUnit4 + PowerMock で static メソッドをモック化してテストを書いてみる

Spring Boot がサポートしている Mockito で static メソッドをモック化できないか調べてみたところ、Mocito 自体は static メソッドのモック化には対応していませんが、PowerMock を使用するとモック化できるようです。

ライブラリを依存関係に追加します。Mockito_maven のページを参考に build.gradle を以下のように変更した後、Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

dependencies {
    ..........

    // PowerMock
    testCompile("org.powermock:powermock-module-junit4:1.6.6")
    testCompile("org.powermock:powermock-api-mockito:1.6.6")
}

SampleHelper#encrypt メソッドではなく、BrowfishUtils#encrypt メソッドをモックにして BrowfishUtils#encrypt メソッド自体を呼ぶテストを書いてみます。最初は Groovy + JUnit4 + PowerMock で書きます。

例外が throw されているかチェックするのに AssertJ の assertThatExceptionOfType メソッドを使用したのですが、lambda 式のところで赤波線が表示されてエラーになりました。なぜ?と思って Web で調べてみると、Groovy は lambda 式がサポートされていないことが原因でした。知りませんでした。。。

f:id:ksby:20170609021232p:plain

lambda 式ではなく匿名クラスで書くことにします。isThrownBy メソッドの引数が ThrowableAssert.ThrowingCallable インターフェースであることを確認した後、テストを以下のように書きます。

    @RunWith(PowerMockRunner.class)
    @PowerMockRunnerDelegate(JUnit4.class)
    @PrepareForTest(BrowfishUtils.class)
    static class 異常処理のテスト {

        @Test
        void "BrowfishUtil_encryptを呼ぶとNoSuchPaddingExceptionをthrowする"() {
            // setup:
            PowerMockito.mockStatic(BrowfishUtils.class)
            PowerMockito.when(BrowfishUtils.encrypt(Mockito.any()))
                    .thenThrow(new NoSuchPaddingException())

            // expect:
            assertThatExceptionOfType(NoSuchPaddingException.class).isThrownBy(
                    new ThrowableAssert.ThrowingCallable() {
                        @Override
                        void call() throws Throwable {
                            BrowfishUtils.encrypt("test")
                        }
                    })
        }

    }

テストを実行すると成功しました。

f:id:ksby:20170613011138p:plain

Groovy + Spock + PowerMock で static メソッドをモック化してテストを書いてみる

書いたテストを Spock で書き直します。Web で調べると Spock で PowerMock を使いたい場合には PowerMockRule を使うよう書かれている記事を見かけるのですが、PowerMockとJUnitのRuleを使うときのメモ には複数の @Rule が指定されている場合、PowerMockRule は動かなくなる、とも書かれていました。PowerMockRule って使えないのでは?。。。と思いましたが、まずは試してみます。

PowerMockRule を見て build.gradle を以下のように変更します。変更後、Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

dependencies {
    ..........

    // PowerMock
    testCompile("org.powermock:powermock-module-junit4:1.6.6")
    testCompile("org.powermock:powermock-api-mockito:1.6.6")
    testCompile("org.powermock:powermock-module-junit4-rule:1.6.6")
    testCompile("org.powermock:powermock-classloading-xstream:1.6.6")
}

まずは JUnit4 のまま PowerMockRule を使う方式へ変えてみます。先程作成した 「異常処理のテスト」static class を以下のように変更します。

    @RunWith(JUnit4.class)
    @PrepareForTest(BrowfishUtils.class)
    static class 異常処理のテスト {

        @Rule
        public PowerMockRule powerMock = new PowerMockRule()

        @Test
        void "BrowfishUtil_encryptを呼ぶとNoSuchPaddingExceptionをthrowする"() {
            // setup:
            PowerMockito.mockStatic(BrowfishUtils.class)
            PowerMockito.when(BrowfishUtils.encrypt(Mockito.any()))
                    .thenThrow(new NoSuchPaddingException())

            // expect:
            assertThatExceptionOfType(NoSuchPaddingException.class).isThrownBy(
                    new ThrowableAssert.ThrowingCallable() {
                        @Override
                        void call() throws Throwable {
                            BrowfishUtils.encrypt("test")
                        }
                    })
        }

    }

テストを実行すると成功します。

f:id:ksby:20170613013606p:plain

「異常処理のテスト」static class を Spock で書き直してみます。

    @PrepareForTest(BrowfishUtils.class)
    static class 異常処理のテスト extends Specification {

        @Rule
        public PowerMockRule powerMock = new PowerMockRule()

        def "BrowfishUtil_encryptを呼ぶとNoSuchPaddingExceptionをthrowする"() {
            given:
            PowerMockito.mockStatic(BrowfishUtils.class)
            PowerMockito.when(BrowfishUtils.encrypt(Mockito.any()))
                    .thenThrow(new NoSuchPaddingException())

            when:
            BrowfishUtils.encrypt("test")

            then:
            thrown(NoSuchPaddingException.class)
        }

    }

テストを実行すると、PowerMockRule 内の処理で NullPointerException が発生して失敗しました。JUnit4 だと動きますが Spock に変えると動きませんね。。。

f:id:ksby:20170613013848p:plain

PowerMockRule ではなく PowerMockRunner を使用する方法に変えてみます。spock.lang.Specification を見ると @RunWith(Sputnik.class) と記述されていました。Spock の Runner は Sputnik.class のようなので、以下のように書いてみます。

    @RunWith(PowerMockRunner.class)
    @PowerMockRunnerDelegate(Sputnik.class)
    @PrepareForTest(BrowfishUtils.class)
    static class 異常処理のテスト extends Specification {

        def "BrowfishUtil_encryptを呼ぶとNoSuchPaddingExceptionをthrowする"() {
            given:
            PowerMockito.mockStatic(BrowfishUtils.class)
            PowerMockito.when(BrowfishUtils.encrypt(Mockito.any()))
                    .thenThrow(new NoSuchPaddingException())

            when:
            BrowfishUtils.encrypt("test")

            then:
            thrown(NoSuchPaddingException.class)
        }

    }

テストを実行すると、成功はしますが Notifications are not supported for behaviour ALL_TESTINSTANCES_ARE_CREATED_FIRST というメッセージが出力されています。動きはするようなので、このまま進めます。

f:id:ksby:20170613014306p:plain

今度は @SpringBootTest アノテーションを付けて、SampleHelper#encrypt メソッドのテストを書いてみます。

    @RunWith(PowerMockRunner.class)
    @PowerMockRunnerDelegate(Sputnik.class)
    @SpringBootTest
    @PrepareForTest(BrowfishUtils.class)
    static class 異常処理のテスト extends Specification {

        @Autowired
        private SampleHelper sampleHelper

        def "SampleHelper_encryptを呼ぶとRuntimeExceptionをthrowする"() {
            given:
            PowerMockito.mockStatic(BrowfishUtils.class)
            PowerMockito.when(BrowfishUtils.encrypt(Mockito.any()))
                    .thenThrow(new NoSuchPaddingException())

            when:
            sampleHelper.encrypt("test")

            then:
            thrown(RuntimeException.class)
        }

    }

テストを実行すると、java.lang.IllegalStateException: Failed to load ApplicationContext のメッセージが出力されて失敗しました。Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.beans.factory.support.BeanNameGenerator]: Specified class is an interface というメッセージも出力されています。

f:id:ksby:20170613014511p:plain

Spock ではうまく動作しないので JUnit4 でテストを書いてみる

以下のようにテストを書き直します。

    @RunWith(PowerMockRunner.class)
    @PowerMockRunnerDelegate(SpringRunner.class)
    @SpringBootTest
    @PrepareForTest(BrowfishUtils.class)
    static class 異常処理のテスト {

        @Autowired
        private SampleHelper sampleHelper

        @Test
        void "SampleHelper_encryptを呼ぶとRuntimeExceptionをthrowする"() {
            // setup:
            PowerMockito.mockStatic(BrowfishUtils.class)
            PowerMockito.when(BrowfishUtils.encrypt(Mockito.any()))
                    .thenThrow(new NoSuchPaddingException())

            // expect:
            assertThatExceptionOfType(RuntimeException.class).isThrownBy(
                    new ThrowableAssert.ThrowingCallable() {
                        @Override
                        void call() throws Throwable {
                            sampleHelper.encrypt("test")
                        }
                    })
        }

    }

テストを実行すると、こちらも java.lang.IllegalStateException: Failed to load ApplicationContext のメッセージが出力されて失敗しました。ただし、こちらは Caused by: java.lang.LinkageError: loader constraint violation: loader (instance of org/powermock/core/classloader/MockClassLoader) previously initiated loading for a different type with name "javax/management/MBeanServer" というメッセージが出力されます。

f:id:ksby:20170610070939p:plain

stackoverflow を検索してみると Mockito + PowerMock LinkageError while mocking system class の記事が見つかりました。@PowerMockIgnore アノテーションを記述して無視させればよいようです。

テストを以下のように修正します。

    @RunWith(PowerMockRunner.class)
    @PowerMockRunnerDelegate(SpringRunner.class)
    @SpringBootTest
    @PrepareForTest(BrowfishUtils.class)
    @PowerMockIgnore("javax.management.*")
    static class 異常処理のテスト {

        @Autowired
        private SampleHelper sampleHelper

        @Test
        void "SampleHelper_encryptを呼ぶとRuntimeExceptionをthrowする"() {
            // setup:
            PowerMockito.mockStatic(BrowfishUtils.class)
            PowerMockito.when(BrowfishUtils.encrypt(Mockito.any()))
                    .thenThrow(new NoSuchPaddingException())

            // expect:
            assertThatExceptionOfType(RuntimeException.class).isThrownBy(
                    new ThrowableAssert.ThrowingCallable() {
                        @Override
                        void call() throws Throwable {
                            sampleHelper.encrypt("test")
                        }
                    })
        }

    }

テストを実行すると、何もエラー・警告のメッセージは出力されずに成功しました。

f:id:ksby:20170610071445p:plain

まとめ

  • Groovy + Spock でテストを書く方法は分かりませんでした。。。 PowerMockRule を使用する記事をよく見かけるのですが、なぜかうまく動作しません。
  • Groovy + JUnit4 + PowerMock で動作する方法は分かりました。この方法なら Spock で書いたテストと同じファイル内に書けます。
    • build.gradle の dependencies には以下の2行を記述します。バージョン番号は Mockito_maven のページを見て、その時の最新バージョンにします。
      • testCompile("org.powermock:powermock-module-junit4:1.6.6")
      • testCompile("org.powermock:powermock-api-mockito:1.6.6")
    • static メソッドをモック化するテストを書くクラスに以下のアノテーションを付加します。
      • @RunWith(PowerMockRunner.class)
      • @PowerMockRunnerDelegate(SpringRunner.class)
      • @SpringBootTest
      • @PowerMockIgnore("javax.management.*")
    • static メソッドをモック化するテストを書くクラスに、以下のアノテーションでモック化するクラスを記述します。
      • @PrepareForTest(~.class)
    • テストメソッドの最初に PowerMockito.mockStatic(...) でモック化するクラスを宣言し、PowerMockito.when(...).then~(...) でモック化する static メソッドとモック化した時の挙動を記述します。

テストクラスは最終的に以下のようになりました。

package ksbysample.webapp.lending

import org.assertj.core.api.ThrowableAssert
import org.junit.Test
import org.junit.experimental.runners.Enclosed
import org.junit.runner.RunWith
import org.mockito.Mockito
import org.powermock.api.mockito.PowerMockito
import org.powermock.core.classloader.annotations.PowerMockIgnore
import org.powermock.core.classloader.annotations.PrepareForTest
import org.powermock.modules.junit4.PowerMockRunner
import org.powermock.modules.junit4.PowerMockRunnerDelegate
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.junit4.SpringRunner
import spock.lang.Specification
import spock.lang.Unroll

import javax.crypto.NoSuchPaddingException

import static org.assertj.core.api.Assertions.assertThatExceptionOfType

@RunWith(Enclosed.class)
class SampleHelperTest {

    @SpringBootTest
    static class 正常処理のテスト extends Specification {

        @Autowired
        private SampleHelper sampleHelper

        @Unroll
        def "SampleHelper.encrypt(#str) --> #result"() {
            expect:
            sampleHelper.encrypt(str) == result

            where:
            str        || result
            "test"     || "bjMKKlhqu/c="
            "xxxxxxxx" || "ERkXmOeArBKwGbCh+M6aHw=="
        }

    }

    @RunWith(PowerMockRunner.class)
    @PowerMockRunnerDelegate(SpringRunner.class)
    @SpringBootTest
    @PrepareForTest(BrowfishUtils.class)
    @PowerMockIgnore("javax.management.*")
    static class 異常処理のテスト {

        @Autowired
        private SampleHelper sampleHelper

        @Test
        void "SampleHelper_encryptを呼ぶとRuntimeExceptionをthrowする"() {
            // setup:
            PowerMockito.mockStatic(BrowfishUtils.class)
            PowerMockito.when(BrowfishUtils.encrypt(Mockito.any()))
                    .thenThrow(new NoSuchPaddingException())

            // expect:
            assertThatExceptionOfType(RuntimeException.class).isThrownBy(
                    new ThrowableAssert.ThrowingCallable() {
                        @Override
                        void call() throws Throwable {
                            sampleHelper.encrypt("test")
                        }
                    })
        }

    }

}

PowerMock で void のメソッドをモック化するには?

戻り値が void の場合、モック化の書き方が上とは変わります。SampleHelper クラス、BrowfishUtils クラスに以下のように void のメソッドを追加して、

@Component
public class SampleHelper {

    ..........

    public void noReturn(String arg) {
        BrowfishUtils.noReturn(arg);
    }

}
public class BrowfishUtils {

    ..........

    public static void noReturn(String arg) {

    }

}

BrowfishUtils#noReturn が RuntimeException を throw するようモック化してテストを書くと以下のようになります。

    @RunWith(PowerMockRunner.class)
    @PowerMockRunnerDelegate(SpringRunner.class)
    @SpringBootTest
    @PrepareForTest(BrowfishUtils.class)
    @PowerMockIgnore("javax.management.*")
    static class voidメソッドをモック化するテスト {

        @Autowired
        private SampleHelper sampleHelper

        @Test
        void "SampleHelper_noReturnを呼ぶとRuntimeExceptionをthrowする"() {
            // setup:
            PowerMockito.mockStatic(BrowfishUtils.class)
            PowerMockito.doThrow(new RuntimeException())
                    .when(BrowfishUtils.class, "noReturn", Mockito.any())

            // expect:
            assertThatExceptionOfType(RuntimeException.class).isThrownBy(
                    new ThrowableAssert.ThrowingCallable() {
                        @Override
                        void call() throws Throwable {
                            sampleHelper.noReturn("test")
                        }
                    })
        }

    }

このテストを実行すると成功します。

f:id:ksby:20170615001407p:plain

履歴

2017/06/15
初版発行。