1. 概述
本教程将深入探讨 Java 模块化(JPMS)及其对 Java 应用程序测试的影响。首先简要介绍 JPMS,然后重点分析测试如何与模块协同工作。
2. Java 平台模块化系统
Java 平台模块化系统(JPMS)在 Java 9 中引入,旨在提升大型应用的组织性和可维护性。它提供了一种更高效的机制来定义和管理组件间的依赖关系。
模块是自包含的代码单元,通过封装实现细节并暴露明确定义的 API 来工作。它们显式声明依赖关系,使理解系统各部分间的关联变得更容易。
Java 模块化的主要优势包括:
- ✅ 封装性:模块隐藏内部实现细节,仅通过明确定义的 API 暴露必要功能
- ✅ 提升可维护性:通过清晰的职责分离,简化复杂应用的管理和维护
- ✅ 增强性能:模块可在运行时加载/卸载,使 JVM 能优化内存占用和启动时间
3. 传统类路径测试
在模块化测试之前,先看非模块化 Java 应用的测试场景。假设有一个包含 Book
和 Library
两个类的简单应用:
Library
类的 addBook
方法接收 Book
对象并添加到内部书籍列表。我们为该方法编写测试:
class LibraryUnitTest {
@Test
void givenEmptyLibrary_whenAddABook_thenLibraryHasOneBook() {
Library library = new Library();
Book theLordOfTheRings = new Book("The Lord of the Rings", "J.R.R. Tolkien");
library.addBook(theLordOfTheRings);
int expected = 1;
int actual = library.getBooks().size();
assertEquals(expected, actual);
}
}
该测试验证添加书籍后图书馆数量是否正确增加。测试的导入语句很简单:
package com.baeldung.core;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
无需导入 Library
和 Book
类,因为 JVM 将它们视为同包类。这依赖于类路径和类发现机制。 但这种方式可能导致难以调试的问题,大型项目中甚至会引发JAR hell。
4. 模块化测试
将图书管理应用拆分为模块结构:创建 com.baeldung.library.core
和 com.baeldung.library.test
模块。核心模块包含应用代码:
library-core
└── src
└── main
└── java
├── com
│ └── baeldung
│ └── library
│ └── core
│ ├── Book.java
│ └── Library.java
└── module-info.java
测试模块包含测试代码:
library-test
└── src
└── test
└── java
├── com
│ └── baeldung
│ └── library
│ └── test
│ └── LibraryUnitTest.java
└── module-info.java
为简化展示采用 Maven 结构,但模块化应用只需遵循 JPMS 规范即可。
核心模块的 module-info.java
如下:
module com.baeldung.library.core {
exports com.baeldung.library.core;
}
测试模块的描述符包含额外指令:
module com.baeldung.library.test {
requires com.baeldung.library.core;
requires org.junit.jupiter.api;
opens com.baeldung.library.test to org.junit.platform.commons;
}
我们声明测试模块依赖核心模块和 JUnit。同时将测试包开放给 JUnit 平台,使其能通过反射访问测试类。
4.1 测试公共方法
使用模块结构重写之前的测试:
class LibraryUnitTest {
@Test
void givenEmptyLibrary_whenAddABook_thenLibraryHasOneBook() {
Library library = new Library();
Book theLordOfTheRings = new Book("The Lord of the Rings", "J.R.R. Tolkien");
library.addBook(theLordOfTheRings);
int expected = 1;
int actual = library.getBooks().size();
assertEquals(expected, actual);
}
}
测试代码未变,但项目结构已调整。主要区别在于导入语句:
package com.baeldung.library.test;
import com.baeldung.library.core.Book;
import com.baeldung.library.core.Library;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
不再依赖类路径自动发现,必须显式导入 Book
和 Library
类。**不同模块导出同名包是不被允许的**,因此测试代码和应用核心必须位于不同名称的包中。
4.2 测试受保护方法
将应用和测试代码分离到不同模块可能违反“不要重复自己”(DRY)原则。 为测试受保护成员,可能需要在测试模块创建子类或包装类。这种代码重复会增加维护成本,应用代码变更时需同步修改测试模块。
考虑一个受保护方法:
protected void removeBookByAuthor(String author) {
books.removeIf(book -> book.getAuthor().equals(author));
}
可通过继承扩大访问权限:
public class TestLibrary extends Library {
@Override
public void removeBookByAuthor(final String author) {
super.removeBookByAuthor(author);
}
}
在测试中使用该类:
@Test
void givenTheLibraryWithSeveralBook_whenRemoveABookByAuthor_thenLibraryHasNoBooksByTheAuthor() {
TestLibrary library = new TestLibrary();
Book theLordOfTheRings = new Book("The Lord of the Rings", "J.R.R. Tolkien");
Book theHobbit = new Book("The Hobbit", "J.R.R. Tolkien");
Book theSilmarillion = new Book("The Silmarillion", "J.R.R. Tolkien");
Book theHungerGames = new Book("The Hunger Games", "Suzanne Collins");
library.addBook(theLordOfTheRings);
library.addBook(theHobbit);
library.addBook(theSilmarillion);
library.addBook(theHungerGames);
library.removeBookByAuthor("J.R.R. Tolkien");
int expected = 1;
int actual = library.getBooks().size();
assertEquals(expected, actual);
}
4.3 测试包私有方法
访问包私有成员进行测试时,可能需要通过公共 API 暴露内部实现,或修改模块描述符授权访问。
考虑测试 Library
类的 removeBook
方法:
void removeBook(Book book) {
books.remove(book);
}
该方法是包私有的,仅同包类可访问。若测试位于同一模块则无问题:
package com.baeldung.library.core;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class LibraryUnitTest {
// ...
@Test
void givenTheLibraryWithABook_whenRemoveABook_thenLibraryIsEmpty() {
Library library = new Library();
Book theLordOfTheRings = new Book("The Lord of the Rings", "J.R.R. Tolkien");
library.addBook(theLordOfTheRings);
library.removeBook(theLordOfTheRings);
int expected = 0;
int actual = library.getBooks().size();
assertEquals(expected, actual);
}
// ...
}
但模块系统的访问限制下,位于独立模块的测试可能需要反射。 需开放核心模块给测试。可通过两种方式实现:在模块描述符添加指令,或使用 --add-opens
命令:
module com.baeldung.library.core {
exports com.baeldung.library.core;
opens com.baeldung.library.core to com.baeldung.library.test;
}
这种方式要求 com.baeldung.library.test
始终在模块路径上,不够灵活。更好的方案是运行测试时临时开放:
--add-opens com.baeldung.library.core/com.baeldung.library.core=com.baeldung.library.test
之后编写测试:
package com.baeldung.library.test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.baeldung.library.core.Book;
import com.baeldung.library.core.Library;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import org.junit.jupiter.api.Test;
class LibraryUnitTest {
// ...
@Test
void givenTheLibraryWithABook_whenRemoveABook_thenLibraryIsEmpty()
throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
Library library = new Library();
Book theLordOfTheRings = new Book("The Lord of the Rings", "J.R.R. Tolkien");
library.addBook(theLordOfTheRings);
Method removeBook = Library.class.getDeclaredMethod("removeBook", Book.class);
removeBook.setAccessible(true);
removeBook.invoke(library, theLordOfTheRings);
int expected = 0;
int actual = library.getBooks().size();
assertEquals(expected, actual);
}
// ...
}
⚠️ 注意:这可能破坏模块化设计,增加其他模块误用内部 API 的风险。
4.4 运行 JUnit 测试
应用和测试代码分属不同模块时,需要额外配置测试环境。使用 org.junit.platform.console.ConsoleLauncher
运行测试:
java --module-path mods \
--add-modules com.baeldung.library.test \
--add-opens com.baeldung.library.core/com.baeldung.library.core=com.baeldung.library.test \
org.junit.platform.console.ConsoleLauncher --select-class com.baeldung.library.test.LibraryUnitTest
--module-path
指定模块位置。但模块路径上的模块不会自动加入解析图,需显式添加:
--add-modules com.baeldung.library.test
测试模块无权反射访问核心模块,可通过命令行授权:
--add-opens com.baeldung.library.core/com.baeldung.library.core=com.baeldung.library.test
最后指定测试类:
org.junit.platform.console.ConsoleLauncher --select-class com.baeldung.library.test.LibraryUnitTest
这不是唯一运行方式,ConsoleLauncher
文档提供了更多参数说明。
5. 模块外测试
通常更实用的方案是仅将应用代码模块化,测试代码置于模块外。下面探讨实现方式。
5.1 基于类路径运行
最简单方案是完全忽略 module-info.java
,使用传统类路径运行测试:
javac --class-path libs/junit-jupiter-engine-5.9.2.jar:\
libs/junit-platform-engine-1.9.2.jar:\
libs/apiguardian-api-1.1.2.jar:\
libs/junit-jupiter-params-5.9.2.jar:\
libs/junit-jupiter-api-5.9.2.jar:\
libs/opentest4j-1.2.0.jar:\
libs/junit-platform-commons-1.9.2.jar \
-d outDir/library-core \
library-core/src/main/java/com/baeldung/library/core/Book.java \
library-core/src/main/java/com/baeldung/library/core/Library.java \
library-core/src/main/java/com/baeldung/library/core/Main.java \
library-core/src/test/java/com/baeldung/library/core/LibraryUnitTest.java
libs
目录需包含 JUnit 依赖。示例源码提供了自动下载脚本。
使用 ConsoleLauncher
从类路径运行测试:
java --module-path libs \
org.junit.platform.console.ConsoleLauncher \
--classpath ./outDir/library-core \
--select-class com.baeldung.library.core.LibraryUnitTest
这种方式完全绕过模块系统。虽适用于简单项目,但模块间关系复杂的场景可能失效。 此外,可能掩盖模块运行时才会暴露的问题。
5.2 模块补丁
更优方案是运行测试前通过补丁机制注入测试类。这可避免测试模块与应用模块的复杂交互配置。
可在运行前将测试类添加到模块,使测试与应用代码同包,直接访问受保护和包私有成员:
java --module-path mods:/libs \
--add-modules com.baeldung.library.core \
--add-opens com.baeldung.library.core/com.baeldung.library.core=org.junit.platform.commons \
--add-reads com.baeldung.library.core=org.junit.jupiter.api \
--patch-module com.baeldung.library.core=outDir/library-test \
--module org.junit.platform.console --select-class com.baeldung.library.core.LibraryUnitTest
--add-modules
和 --add-opens
前文已介绍。此处 --add-opens
授权 JUnit 反射访问,独立测试模块时通过 opens
指令实现。
--add-reads
指令是必需的,因为测试使用了 JUnit 模块的类:
--add-reads com.baeldung.library.core=org.junit.jupiter.api
关键指令是 --patch-module
:
--patch-module com.baeldung.library.core=outDir/library-test
该指令将编译后的测试类 com.baeldung.library.core.LibraryUnitTest
注入模块。 此后可直接运行测试,无需额外模块配置。
6. 总结
Java 模块化通过提升组织性和可维护性,使复杂应用管理更高效。模块间依赖的显式声明也增强了系统结构的清晰度。
但创建独立测试模块需要额外配置,可能破坏应用模块边界。 因此更常见的做法是将测试置于模块外,使用 --patch-module
运行时注入测试类。
本教程源码可在 GitHub 获取。