强曰为道

与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

第 16 章:测试

第 16 章:测试

掌握单元测试、MockBukkit 和集成测试,确保插件质量。


16.1 测试的重要性

测试类型说明何时使用
单元测试测试独立的方法/类每次提交前
集成测试测试多组件协作功能完成后
MockBukkit 测试模拟 Bukkit 环境涉及 Bukkit API 的代码
手动测试在实际服务器中测试发布前最终验证

16.2 测试框架设置

Maven 依赖

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.0</version>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>5.12.0</version>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>

目录结构

src/
├── main/
│   └── java/
│       └── com/example/myplugin/
│           ├── MyPlugin.java
│           └── utils/
│               └── MathUtil.java
└── test/
    └── java/
        └── com/example/myplugin/
            ├── utils/
            │   └── MathUtilTest.java
            └── commands/
                └── HealCommandTest.java

16.3 单元测试基础

基本测试

package com.example.myplugin.utils;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;

import static org.assertj.core.api.Assertions.*;

class MathUtilTest {

    @Test
    @DisplayName("计算两数之和")
    void testAdd() {
        int result = MathUtil.add(2, 3);
        assertThat(result).isEqualTo(5);
    }

    @Test
    @DisplayName("计算百分比")
    void testPercentage() {
        double result = MathUtil.percentage(75, 100);
        assertThat(result).isCloseTo(75.0, within(0.01));
    }

    @Test
    @DisplayName("限制数值范围")
    void testClamp() {
        assertThat(MathUtil.clamp(5, 0, 10)).isEqualTo(5);
        assertThat(MathUtil.clamp(-1, 0, 10)).isEqualTo(0);
        assertThat(MathUtil.clamp(15, 0, 10)).isEqualTo(10);
    }

    @Test
    @DisplayName("空值处理")
    void testNullHandling() {
        assertThatThrownBy(() -> MathUtil.add(null, 3))
            .isInstanceOf(NullPointerException.class);
    }
}

参数化测试

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;

class MathUtilTest {

    @ParameterizedTest
    @CsvSource({
        "100, 200, 300",
        "0, 0, 0",
        "-1, 1, 0",
        "1000000, 2000000, 3000000"
    })
    void testAdd(int a, int b, int expected) {
        assertThat(MathUtil.add(a, b)).isEqualTo(expected);
    }

    @ParameterizedTest
    @ValueSource(ints = {0, 1, 10, 100, 999})
    void testNonNegative(int value) {
        assertThat(MathUtil.clamp(value, 0, Integer.MAX_VALUE))
            .isGreaterThanOrEqualTo(0);
    }
}

16.4 MockBukkit

MockBukkit 模拟了 Bukkit 服务器环境,可以在不启动真实服务器的情况下测试插件代码。

Maven 依赖

<dependency>
    <groupId>org.mockbukkit.mockbukkit</groupId>
    <artifactId>mockbukkit-v1.21</artifactId>
    <version>4.24.0</version>
    <scope>test</scope>
</dependency>

基本设置

package com.example.myplugin;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import be.seeseemelk.mockbukkit.MockBukkit;
import be.seeseemelk.mockbukkit.ServerMock;
import be.seeseemelk.mockbukkit.entity.PlayerMock;

class MyPluginTest {

    private ServerMock server;
    private MyPlugin plugin;

    @BeforeEach
    void setUp() {
        // 初始化 MockBukkit
        server = MockBukkit.mock();

        // 加载插件
        plugin = MockBukkit.load(MyPlugin.class);
    }

    @AfterEach
    void tearDown() {
        // 清理
        MockBukkit.unmock();
    }

    @Test
    void testPluginLoaded() {
        // 验证插件已加载
        assertThat(plugin.isEnabled()).isTrue();
    }
}

模拟玩家

@Test
void testPlayerHeal() {
    // 创建模拟玩家
    PlayerMock player = server.addPlayer("TestPlayer");

    // 模拟受伤
    player.setHealth(10.0);

    // 执行治疗命令
    player.performCommand("heal");

    // 验证结果
    assertThat(player.getHealth()).isEqualTo(player.getMaxHealth());
}

@Test
void testPlayerJoin() {
    PlayerMock player = server.addPlayer();

    // 模拟玩家加入
    player.join();

    // 验证加入事件被触发
    // ...
}

模拟事件

@Test
void testBlockBreakEvent() {
    PlayerMock player = server.addPlayer();

    // 模拟方块破坏
    BlockBreakEvent event = new BlockBreakEvent(
        player.getLocation().getBlock(), player);

    // 触发事件
    server.getPluginManager().callEvent(event);

    // 验证事件处理
    assertThat(event.isCancelled()).isFalse();
}

16.5 测试命令

class HealCommandTest {

    private ServerMock server;
    private MyPlugin plugin;
    private PlayerMock player;

    @BeforeEach
    void setUp() {
        server = MockBukkit.mock();
        plugin = MockBukkit.load(MyPlugin.class);
        player = server.addPlayer("TestPlayer");
    }

    @AfterEach
    void tearDown() {
        MockBukkit.unmock();
    }

    @Test
    @DisplayName("治疗命令 - 有权限")
    void testHealWithPermission() {
        player.setHealth(5.0);

        // 执行命令
        boolean result = player.performCommand("heal");

        assertThat(result).isTrue();
        assertThat(player.getHealth()).isEqualTo(player.getMaxHealth());
    }

    @Test
    @DisplayName("治疗命令 - 无权限")
    void testHealWithoutPermission() {
        // 移除权限
        player.addAttachment(plugin)
            .unsetPermission("myplugin.heal");

        boolean result = player.performCommand("heal");

        // 命令应该返回 false(显示用法)或提示无权限
        assertThat(player.nextMessage())
            .contains("没有权限");
    }

    @Test
    @DisplayName("治疗其他玩家")
    void testHealOther() {
        PlayerMock target = server.addPlayer("TargetPlayer");
        target.setHealth(5.0);

        player.addAttachment(plugin)
            .setPermission("myplugin.heal.others", true);

        boolean result = player.performCommand("heal TargetPlayer");

        assertThat(result).isTrue();
        assertThat(target.getHealth()).isEqualTo(target.getMaxHealth());
    }
}

16.6 测试经济系统

class EconomyManagerTest {

    private ServerMock server;
    private MyPlugin plugin;
    private PlayerMock player;

    @BeforeEach
    void setUp() {
        server = MockBukkit.mock();
        plugin = MockBukkit.load(MyPlugin.class);
        player = server.addPlayer("TestPlayer");
    }

    @AfterEach
    void tearDown() {
        MockBukkit.unmock();
    }

    @Test
    void testGetInitialBalance() {
        double balance = EconomyManager.getBalance(player);
        assertThat(balance).isEqualTo(0.0);
    }

    @Test
    void testAddBalance() {
        EconomyManager.add(player, 100.0);
        assertThat(EconomyManager.getBalance(player))
            .isEqualTo(100.0);
    }

    @Test
    void testDeductBalance() {
        EconomyManager.add(player, 100.0);
        boolean success = EconomyManager.deduct(player, 50.0);

        assertThat(success).isTrue();
        assertThat(EconomyManager.getBalance(player))
            .isEqualTo(50.0);
    }

    @Test
    void testDeductInsufficientBalance() {
        EconomyManager.add(player, 10.0);
        boolean success = EconomyManager.deduct(player, 50.0);

        assertThat(success).isFalse();
        assertThat(EconomyManager.getBalance(player))
            .isEqualTo(10.0);
    }
}

16.7 测试 GUI

class ShopMenuTest {

    private ServerMock server;
    private MyPlugin plugin;
    private PlayerMock player;

    @BeforeEach
    void setUp() {
        server = MockBukkit.mock();
        plugin = MockBukkit.load(MyPlugin.class);
        player = server.addPlayer();
    }

    @AfterEach
    void tearDown() {
        MockBukkit.unmock();
    }

    @Test
    void testShopOpens() {
        ShopMenu.open(player);

        assertThat(player.getOpenInventory())
            .isNotNull();
        assertThat(player.getOpenInventory().getSize())
            .isEqualTo(54);
    }

    @Test
    void testClickBuyButton() {
        ShopMenu.open(player);
        InventoryView view = player.getOpenInventory();

        // 模拟点击购买按钮
        InventoryClickEvent event = new InventoryClickEvent(
            view, InventoryType.SlotType.CONTAINER, 10,
            ClickType.LEFT, InventoryAction.PICKUP_ALL);

        server.getPluginManager().callEvent(event);

        // 验证购买逻辑
        // ...
    }
}

16.8 测试异步操作

class AsyncDataTest {

    private ServerMock server;
    private MyPlugin plugin;

    @BeforeEach
    void setUp() {
        server = MockBukkit.mock();
        plugin = MockBukkit.load(MyPlugin.class);
    }

    @AfterEach
    void tearDown() {
        MockBukkit.unmock();
    }

    @Test
    void testAsyncLoad() throws Exception {
        CompletableFuture<PlayerData> future = new CompletableFuture<>();

        // 异步加载
        plugin.getAsyncDB().loadPlayerAsync(UUID.randomUUID(), data -> {
            future.complete(data);
        });

        // 等待异步操作完成
        PlayerData data = future.get(5, TimeUnit.SECONDS);

        assertThat(data).isNotNull();
    }
}

16.9 集成测试

测试脚本

#!/usr/bin/env python3
# tests/integration/test_server.py

import subprocess
import time
import json

def run_rcon_command(command):
    """通过 RCON 执行命令"""
    result = subprocess.run(
        ["mcrcon", "-H", "localhost", "-P", "25575", "-p", "password", command],
        capture_output=True, text=True
    )
    return result.stdout.strip()

def test_plugin_loaded():
    """测试插件是否加载"""
    output = run_rcon_command("plugins")
    assert "MyPlugin" in output, "插件未加载!"
    print("✓ 插件已加载")

def test_heal_command():
    """测试治疗命令"""
    output = run_rcon_command("heal TestPlayer")
    assert "治愈" in output, "治疗命令失败!"
    print("✓ 治疗命令正常")

def test_economy():
    """测试经济系统"""
    run_rcon_command("eco give TestPlayer 100")
    output = run_rcon_command("bal TestPlayer")
    assert "100" in output, "经济系统异常!"
    print("✓ 经济系统正常")

if __name__ == "__main__":
    print("开始集成测试...")
    time.sleep(10)  # 等待服务器启动

    test_plugin_loaded()
    test_heal_command()
    test_economy()

    print("\n所有测试通过!")

16.10 测试覆盖率

Maven 配置(JaCoCo)

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.12</version>
    <executions>
        <execution>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
    </executions>
</plugin>

运行测试并生成报告

# 运行测试
mvn clean test

# 生成覆盖率报告
mvn jacoco:report

# 报告位置: target/site/jacoco/index.html

16.11 测试最佳实践

实践说明
测试命名清晰testXxx_WhenCondition_ThenExpected
一个测试一个断言聚焦于单一行为
使用 @BeforeEach减少重复代码
Mock 外部依赖数据库、网络等用 Mock
测试边界条件null、空集合、极大值
测试异常情况预期的异常应被验证
测试驱动开发先写测试,再写实现

16.12 常见问题排查

问题原因解决方案
MockBukkit 初始化失败JDK 版本不匹配使用 JDK 17+
事件未触发未注册监听器手动 callEvent()
玩家操作无效模拟环境限制使用 PlayerMock 方法
测试超时异步操作未等待使用 CompletableFuture
CI 测试失败缺少测试环境配置 testcontainers

16.13 扩展阅读


16.14 本章小结

要点内容
单元测试测试独立方法,使用 JUnit 5
MockBukkit模拟 Bukkit 环境,测试插件逻辑
集成测试在真实/容器环境中测试完整流程
覆盖率使用 JaCoCo 生成覆盖率报告
CI 集成GitHub Actions 自动运行测试

下一章: 第 17 章:发布与分发 — 学习将插件发布到 SpigotMC 和 Hangar。