Kotlin
minecraft
JUnit
gradle
Mockito

Spigot-Plugin のユニットテスト①

More than 1 year has passed since last update.

筆者は現在、Spigot-Plugin のユニットテストに挑戦しています。ユニットテスト自体全く触ったことがなかったので基本から勉強しました。そして、とりあえずプラグインのメインクラスをテストで動かすことができたので、一旦ここで記事にしておこうと思います。

課題は JavaPlugin や Interface 系のテスト

まず、プラグインのメインクラスをインスタンス化させ、onEnable() を実行し、期待する結果が得られるかテストをしたい。しかし、JavaPlugin を継承したクラスをそのままインスタンス化(new JavaPlugin())してもエラーを吐くため、その先には進めません。また、JavaPlugin の getDataFolder() なんかも動いてくれません。なので、これらを先に解決する必要がありそうです。

解決策は Mockito にあった

上記の問題は、Mockito というライブラリで解決できそうです。海外のフォーラムをまわったり、有名どころの GitHub を眺めて見ると、いくつかのプラグインがこれでテストを書いていました。

Mockito とは

ただ、Mockito だけでは、static や private のメソッド・コンストラクタなどモックを作成するのが難しいものや、どうしても実行できないようなメソッドもあり、さらなる課題に・・・。

何でもテストできる最終兵器 PowerMock

PowerMock とは

もう、これだけでいいんじゃないかと思いながら、とりあえずこの PowerMock と Mockito を使ってテストコードを書きました。

依存関係として追加 (Gradle)

dependencies に以下を追加して、プロジェクトを更新します。
これで、Mockito と PowerMock を使用する準備が整いました。

build.gradle
    testCompile group: 'junit', name: 'junit', version: '4.12'
    testCompile group: 'org.powermock', name: 'powermock-module-junit4', version: '1.6.5'
    testCompile group: 'org.powermock', name: 'powermock-api-mockito', version: '1.6.6'
    testCompile group: 'org.powermock', name: 'powermock-api-easymock', version: '1.6.6'
    testCompile group: 'org.easymock', name: 'easymock', version: '3.4'

ここからは、筆者が実際に書いたコードを見ながら説明します。

実際に使ったメインクラス

Source Code : GitHub

プラグインのメインクラスをインスタンス化するために、コンストラクトを追加する必要がありました。引数に、JavaPluginLoader PluginDescriptionFile File File を受け取り、 JavaPlugin のコンストラクタにそのまま打ち込みます。このコンストラクタは protected されていたので、セカンダリコンストラクタとして用意ました。しかし、テストのためにしか使用しないため、別の書き方を模索中です。それ以外のコードは、今回の記事には直接は関係しないです。

Homes.kt
package com.masahirosaito.spigot.homes

import com.masahirosaito.spigot.homes.commands.HomeCommand
import com.masahirosaito.spigot.homes.homedata.HomeData
import com.masahirosaito.spigot.homes.homedata.PlayerHome
import com.masahirosaito.spigot.homes.listeners.PlayerRespawnListener
import com.masahirosaito.spigot.homes.oldhomedata.OldHomeData
import org.bukkit.plugin.PluginDescriptionFile
import org.bukkit.plugin.java.JavaPlugin
import org.bukkit.plugin.java.JavaPluginLoader
import java.io.File

class Homes : JavaPlugin {
    lateinit var configs: Configs
    lateinit var messenger: Messenger
    lateinit var homeManager: HomeManager
    lateinit var playerHomeDataFile: File

    constructor() : super()

    constructor(loader: JavaPluginLoader, description: PluginDescriptionFile, dataFolder: File, file: File) :
            super(loader, description, dataFolder, file)

    override fun onEnable() {
        configs = Configs.load(File(dataFolder, "configs.json").load())
        messenger = Messenger(this, configs.onDebug)
        loadData()
        getCommand("home").executor = HomeCommand(this)
        PlayerRespawnListener(this).register()
    }

    override fun onDisable() {
        homeManager.save(playerHomeDataFile)
    }

    private fun File.load(): File = this.apply {
        if (!parentFile.exists()) parentFile.mkdirs()
        if (!exists()) createNewFile()
    }

    private fun loadData() {
        playerHomeDataFile = File(dataFolder, "playerhomes.json")

        val oldHomeDataFile = File(dataFolder, "homedata.json")

        if (!oldHomeDataFile.exists() || playerHomeDataFile.exists()) {
            homeManager = HomeManager.load(playerHomeDataFile.load())
            return
        }

        homeManager = HomeManager().apply {
            OldHomeData.load(oldHomeDataFile).playerHomes.forEach {
                val uuid = it.key
                playerHomes.put(uuid, PlayerHome().apply {
                    it.value.defaultHome?.let { defaultHomeData = HomeData(uuid, "default", it) }
                    it.value.namedHomes.forEach { namedHomeData.add(HomeData(uuid, it.key, it.value)) }
                })
            }
            save(playerHomeDataFile)
        }

        oldHomeDataFile.delete()
    }
}

実際に作成したテストコード

とりあえずプラグインのメインクラスをインスタンス化させ、onEnable() を呼び出し、最後に onDisable() するだけのコードです。何もテストはしていません。Kotlin でモックを作ると、when が予約語と重複するので書くのが面倒です。PowerMock の API がわかれば特につまづくところはなかったですが、@PrepareForTest にクラス登録するのを忘れていてずっとエラーに悩まされていた時間があったので気をつけましょう。また、このコードを書くために、いくつか別のクラスも作成しました。こちら から確認できます。

HomesTest.kt
package com.masahirosaito.spigot.homes

import com.masahirosaito.spigot.homes.utils.FileUtil
import com.masahirosaito.spigot.homes.utils.Util
import org.bukkit.Bukkit
import org.bukkit.Server
import org.bukkit.command.PluginCommand
import org.bukkit.plugin.Plugin
import org.bukkit.plugin.PluginDescriptionFile
import org.bukkit.plugin.PluginManager
import org.bukkit.plugin.java.JavaPluginLoader
import org.easymock.ConstructorArgs
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.powermock.api.easymock.PowerMock
import org.powermock.api.mockito.PowerMockito
import org.powermock.api.mockito.PowerMockito.*
import org.powermock.core.classloader.annotations.PrepareForTest
import org.powermock.modules.junit4.PowerMockRunner
import org.powermock.reflect.Whitebox
import java.io.File

@RunWith(PowerMockRunner::class)
@PrepareForTest(Homes::class, JavaPluginLoader::class, PluginDescriptionFile::class, Server::class, PluginCommand::class)
class HomesTest {
    lateinit var mockServer: Server
    lateinit var homes: Homes

    val pluginFolder = File("bin/test/server/plugins/homestest")
    val pluginFile = File(pluginFolder, "testPluginFile")

    @Before
    fun setUp() {
        mockServer = mock(Server::class.java).apply {
            `when`(logger).thenReturn(Util.logger)
            `when`(pluginManager).thenReturn(createPluginManager())
        }
        homes = createHomes(mockServer).apply {
            `when`(name).thenReturn("Homes")
            `when`(dataFolder).thenReturn(pluginFolder)
            `when`(getCommand("home")).thenReturn(createPluginCommand(this))
            `when`(server).thenReturn(mockServer)
        }
        Bukkit.setServer(mockServer)
    }

    @After
    fun tearDown() {
        Bukkit::class.java.getDeclaredField("server").let {
            it.isAccessible = true
            it.set(Class.forName("org.bukkit.Bukkit"), null)
        }
        homes.onDisable()
        FileUtil.delete(pluginFolder)
    }

    @Test
    fun onEnableTest() {
        homes.onEnable()
    }

    fun createHomes(server: Server): Homes {
        return spy(Homes(createJavaPluginLoader(server), createDescriptionFile(), pluginFolder, pluginFile))
    }

    fun createJavaPluginLoader(server: Server): JavaPluginLoader {
        return PowerMock.createMock(JavaPluginLoader::class.java).apply {
            Whitebox.setInternalState(this, "server", server)
        }
    }

    fun createDescriptionFile(): PluginDescriptionFile {
        return spy(PluginDescriptionFile("Homes", "0.6", "com.masahirosaito.spigot.homes.Homes")).apply {
            `when`(commands).thenReturn(mapOf("home" to mapOf()))
            `when`(authors).thenReturn(listOf())
        }
    }

    fun createPluginCommand(homes: Homes): PluginCommand {
        val constructor = PowerMock.constructor(PluginCommand::class.java, String::class.java, Plugin::class.java)
        return PowerMock.createMock(PluginCommand::class.java, ConstructorArgs(constructor, "home", homes))
    }

    fun createPluginManager(): PluginManager {
        return PowerMockito.mock(PluginManager::class.java)
    }
}

最後に

筆者の 作成しているプラグイン が最近やっとバージョン 0.6 をリリースしました。まだ、正式版ではないですがこれから継続的にリリースできたらな〜って思ってます。なので、テストコードをある程度組んで CI まわして少しは楽したいです。