GeekFactory

int128.hatenablog.com

Groovyのmeta classによるメソッド置き換えとテスト

テスト対象コードにスリープが含まれていると、テストに時間が掛かってしまいます。いわゆるスローテストの問題です。そのため、スリープを無害なモックに置き換えることでテストの時間を短くする工夫がよく行われます。

Groovyでは meta class でスリープメソッドを置き換えることが可能です。

こんなメソッドがあるとします。

class CommandLifecycleManager {
    def commands = [] as List<Command>

    void waitForPending(Closure closedCommandHandler) {
        def pendingCommands = new ArrayList<Command>(commands)
        while (!pendingCommands.empty) {
            def closedCommands = pendingCommands.findAll { it.closed }
            closedCommands.each(closedCommandHandler)
            pendingCommands.removeAll(closedCommands)
            sleep(500)
        }
    }
}

このメソッドは、すべてのコマンドが完了するまでループで待ちます。また、コマンドが完了するごとにクロージャを呼びます。

このメソッドのテストはこんな感じになるでしょう。

class CommandLifecycleManagerSpec extends Specification {
    CommandLifecycleManager manager

    def setup() {
        manager = new CommandLifecycleManager()
    }

    def "wait for pending, one pending channel that closes"() {
        given:
        def command = Mock(Command)
        manager.commands << command

        // 最初のポーリングでは未完了を返す
        1 * command.closed >> false
        // 次のポーリングでは完了を返す
        1 * command.closed >> true

        def closedCommandHandler = Mock(Closure)

        when:
        manager.waitForPending(closedCommandHandler)

        then:
        1 * closedCommandHandler.call(command)
    }
}

このテストを実行すると少なくとも1秒はかかります。また、スリープが本当に実行されているかどうかも分かりません。

スリープメソッドの置き換え

そこで、スリープのモックを作って meta class で置き換えてみます。テストクラスに下記を書きます。

    @Shared
    Closure sleepMock

    def setupSpec() {
        CommandLifecycleManager.metaClass.static.sleep = { long ms ->
            sleepMock.call(ms)
        }
    }

    def teardownSpec() {
        CommandLifecycleManager.metaClass.static.sleep = null
    }

    def setup() {
        sleepMock = Mock(Closure)
        manager = new CommandLifecycleManager()    // さっきと同じ
    }

正確には、モックを呼び出すクロージャでスリープを置き換えています。モックの検証が必要なく、単にテストの時間を短縮するのであれば println とかでも構いません。

これで CommandLifecycleManager#sleep() を実行するとモックが呼ばれるようになります。ポイントは Thread#sleep() ではなく、 DefaultGroovyStaticMethods#sleep() を使うことです。

(10/22 追記) 下記のように @ConfineMetaClassChanges を使えば meta class を元に戻すコードを書かなくてすみます。id:yamkazu さん、ありがとうございます。

@ConfineMetaClassChanges(CommandLifecycleManager)
class CommandLifecycleManagerSpec extends Specification {

    @Shared
    Closure sleepMock

    def setupSpec() {
        CommandLifecycleManager.metaClass.static.sleep = { long ms ->
            sleepMock.call(ms)
        }
    }

    def setup() {
        sleepMock = Mock(Closure)
        manager = new CommandLifecycleManager()    // さっきと同じ
    }

テストコードを書き換えてみましょう。

    def "wait for pending, one pending channel that closes"() {
        given:
        def command = Mock(Command)
        manager.commands << command

        1 * command.closed >> false
        1 * command.closed >> true

        def closedCommandHandler = Mock(Closure)

        when:
        manager.waitForPending(closedCommandHandler)

        then:
        2 * sleepMock.call(_)
        1 * closedCommandHandler.call(command)
    }

これでテストが一瞬で終わるようになりました。しかも、スリープが呼ばれた回数も検証できます。

では、スリープが呼ばれる順序も検証してみましょう。Spockでは then を連ねて書くことでインタラクションの順序も検証できます。

    def "wait for pending, one pending channel that closes"() {
        given:
        def command = Mock(Command)
        manager.commands << command

        def closedCommandHandler = Mock(Closure)

        when:
        manager.waitForPending(closedCommandHandler)

        then: 1 * command.closed >> false
        then: 1 * sleepMock.call(_)
        then: 1 * command.closed >> true
        then: 1 * closedCommandHandler.call(command)
        then: 1 * sleepMock.call(_)
    }

これで、コマンドの状態を確認するごとにスリープするという振る舞いを検証できます。

meta class によるメソッドの置き換え

スリープに限らず、クラスメソッドやインスタンスメソッドを置き換えることが可能です。これにより、テスト対象クラスのメソッドを一部だけ置き換えてテストを実行することもできます。多くの場合は設計を改善すべきですが、スリープのようにやむを得ない場合には使えるテクニックです。

ちなみに、テスト対象クラスに @Singleton が指定されている場合は instance.metaClass にする必要があります。

    @Shared
    Closure sleepMock

    def setupSpec() {
        CommandLifecycleManager.instance.metaClass.static.sleep = { long ms ->
            sleepMock.call(ms)
        }
    }

    def teardownSpec() {
        CommandLifecycleManager.instance.metaClass.static.sleep = null
    }

なお、@Slf4j アノテーションで生成される log フィールドは置き換えられませんでした。最終手段の GroovySpy も使えないし、ロガーはどうやってテストすればいいんだろう・・・。

おまけ

meta class のスリープメソッドが元に戻っているかどうかは下記で調べられます。

class CommandLifecycleManagerSpec2 extends Specification {

    def "check if meta class is restored"() {
        given:
        def t = System.currentTimeMillis()

        when:
        CommandLifecycleManager.sleep(1000)

        then:
        def e = System.currentTimeMillis() - t
        900 < e
        e < 1100
    }

}


(10/22 追記) シングルトンの場合を追記しました。あと、metaClassを使うコードがシングルトンの場合になっていたので修正しました。