Testing Strategies for Modular Java Applications with JUnit and TestNG

Illustration for Testing Strategies for Modular Java Applications with JUnit and TestNG
By Last updated:

When developers first adopt the Java Platform Module System (JPMS), they often face a frustrating issue: tests fail to run after adding module-info.java. Common errors include “package not visible” or “module not found.” This happens because test frameworks like JUnit and TestNG rely heavily on reflection, which is restricted by JPMS.

Testing strategies for modular applications are critical in real-world projects. Large enterprise applications, microservices, and modularized legacy systems all depend on reliable testing pipelines to ensure quality and maintainability. Without proper test configurations, developers waste time debugging tooling rather than validating business logic.

Think of JPMS like an office building with locked departments. Tests are inspectors—they need temporary keys (opens) to verify everything inside. Without those keys, inspections fail. Designing modular applications requires testing strategies that provide inspectors access without compromising overall security.


Challenges in Testing Modular Applications

  1. Reflection Restrictions
    JUnit and TestNG use reflection to discover and invoke tests. If packages aren’t opens to them, tests fail.

  2. Module Boundaries
    Tests may require access to non-exported implementation details.

  3. Build Tool Integration
    Maven and Gradle need explicit configuration to pass the module path to test runners.


Strategies for JUnit

Example module-info.java

module com.example.app {
    exports com.example.app.api;
    opens com.example.app.internal to org.junit.platform.commons;
}

Here, the internal package is opened only to JUnit for testing purposes.

Maven Surefire Plugin for JUnit

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-surefire-plugin</artifactId>
  <version>3.0.0</version>
  <configuration>
    <argLine>
      --add-opens com.example.app/com.example.app.internal=org.junit.platform.commons
    </argLine>
  </configuration>
</plugin>

Strategies for TestNG

Example module-info.java

module com.example.app {
    exports com.example.app.api;
    opens com.example.app.internal to org.testng;
}

Gradle Configuration for TestNG

test {
    useTestNG()
    jvmArgs += [
        '--add-opens', 'com.example.app/com.example.app.internal=org.testng'
    ]
}

Best Practices & Pitfalls

Best Practices

  • Use opens for test frameworks instead of open (granular access)
  • Keep tests in separate modules (e.g., com.example.app.test)
  • Use build tool configs (--add-opens) for fine-grained testing access
  • Automate testing in CI/CD pipelines with modular awareness

Pitfalls

  • Exporting internal packages just for testing (breaks encapsulation)
  • Using open on entire modules (unnecessary exposure)
  • Ignoring reflective access issues (leads to brittle tests)
  • Mixing test and production code in the same module

Example: Dedicated Test Module

Production module-info.java

module com.example.library {
    exports com.example.library.api;
}

Test module-info.java

open module com.example.library.test {
    requires com.example.library;
    requires org.junit.jupiter.api;
}
  • Production module exports only APIs
  • Test module is open to allow reflection for test frameworks

What's New in Java Versions?

  • Java 5–8 → N/A (no modules)
  • Java 9 → JPMS introduced, created challenges for reflective test frameworks
  • Java 11 → Tooling improved for JUnit/TestNG with modules
  • Java 17 → Stable ecosystem for modular testing
  • Java 21 → No significant updates across Java versions for this feature

Real-World Analogy

Testing modular apps is like health inspections in secure buildings. Inspectors (tests) don’t need permanent keys to every office; they get temporary passes (opens) to check specific areas. This balance ensures thorough inspections without sacrificing security.


Summary & Key Takeaways

  • JPMS restricts reflection, making modular test configuration essential
  • Use opens or --add-opens for JUnit/TestNG access
  • Keep tests in dedicated modules for better separation
  • Avoid overexposing internals just for testing convenience
  • Modern tooling in Maven and Gradle supports modular testing with minor setup

FAQ: Testing Modular Applications

1. What is the difference between the classpath and module path for tests?
Classpath loads everything blindly, module path enforces visibility rules.

2. Why do I get “package not visible” errors when running tests?
Because the package isn’t exported or opened to the test framework.

3. What’s the purpose of requires transitive in testing?
It allows test modules to inherit dependencies automatically.

4. How do open and opens differ for testing?
open opens the entire module, while opens restricts reflection to specific packages.

5. Do automatic modules affect testing?
Yes, they may cause inconsistent visibility issues. Use explicit modules instead.

6. How does JPMS improve security while testing?
It ensures only explicitly opened packages are accessible, limiting unintended exposure.

7. Should I use jlink or jmod in testing pipelines?
Use jlink for runtime images, jmod for packaging dependencies. Testing usually runs before packaging.

8. Can I modularize incrementally and still test?
Yes, use automatic modules temporarily but transition to explicit modules for stability.

9. How do I handle third-party non-modular libraries in tests?
Place them on the classpath or use automatic modules cautiously.

10. Do frameworks like Spring and Hibernate work with modular tests?
Yes, but they often need opens for reflective access to entities and beans.