1. 概述

本教程将深入探讨 Java 模块化(JPMS)及其对 Java 应用程序测试的影响。首先简要介绍 JPMS,然后重点分析测试如何与模块协同工作。

2. Java 平台模块化系统

Java 平台模块化系统(JPMS)在 Java 9 中引入,旨在提升大型应用的组织性和可维护性。它提供了一种更高效的机制来定义和管理组件间的依赖关系。

模块是自包含的代码单元,通过封装实现细节并暴露明确定义的 API 来工作。它们显式声明依赖关系,使理解系统各部分间的关联变得更容易。

Java 模块化的主要优势包括:

  • 封装性:模块隐藏内部实现细节,仅通过明确定义的 API 暴露必要功能
  • 提升可维护性:通过清晰的职责分离,简化复杂应用的管理和维护
  • 增强性能:模块可在运行时加载/卸载,使 JVM 能优化内存占用和启动时间

3. 传统类路径测试

在模块化测试之前,先看非模块化 Java 应用的测试场景。假设有一个包含 BookLibrary 两个类的简单应用:

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;

无需导入 LibraryBook 类,因为 JVM 将它们视为同包类。这依赖于类路径和类发现机制。 但这种方式可能导致难以调试的问题,大型项目中甚至会引发JAR hell

4. 模块化测试

将图书管理应用拆分为模块结构:创建 com.baeldung.library.corecom.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;

不再依赖类路径自动发现,必须显式导入 BookLibrary 类。**不同模块导出同名包是不被允许的**,因此测试代码和应用核心必须位于不同名称的包中。

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 获取。


原始标题:Java Modularity and Unit Testing