第 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。