Testing Approaches for Java Enterprise Applications With Jakarta NoSQL and Jakarta Data
Learn how testing strategies like mutation and data-driven testing improve reliability and quality in Jakarta EE apps, with practical tools and examples.
Join the DZone community and get the full member experience.
Join For FreeWhen discussing software development and its history, we often hear quotes emphasizing the importance of testing; however, in practice, we usually prioritize it as the last step, perhaps ahead of documentation. Without proper testing, ensuring the quality of your software is nearly impossible. Tests work as a safety certification, catching issues early and ensuring that your software behaves as expected. Despite their clear advantages — improved code quality, easier refactoring, and better adaptability for future changes — tests are often neglected.
The reality is that testing is a long-term investment, and many software engineers and tech leaders tend to underestimate its importance. This article aims to highlight why testing should be an integral part of your development workflow, particularly when working with Jakarta EE.
What and Why?
The test works as both a code guarantee and as documentation of the behavior. There are many types of tests in software development, each serving a unique purpose, including unit tests, integration tests, and system tests. Those are vital in ensuring that your application delivers the expected behavior under all conditions, while also making it easier to refactor and adapt your code. As systems grow, the complexity increases, and refactoring becomes necessary. Without a robust testing strategy, the risk of introducing bugs while refactoring is high.
When discussing metrics on tests, test coverage is often viewed as the first line of defense in this process. It refers to the percentage of code that is executed while running your tests. However, it’s important to note that good test coverage does not necessarily equate to strong tests.
Be careful with this metric alone: a test suite with 100% does not guarantee that you are testing the business properly. Therefore, while test coverage is useful in ensuring that a significant portion of the code is exercised, it doesn’t guarantee that your tests are thoroughly validating all aspects of your application.
This is where mutation testing comes in; it involves small changes, or "mutations," in the code to check if your tests can detect the changes. If the tests fail when the code is mutated, it means they are effectively validating the functionality. Mutation testing helps identify weaknesses in the tests themselves, ensuring that they not only run the code but also verify its correctness under various scenarios.
Aspect | Test Coverage | Mutation Testing |
---|---|---|
Definition | Measures the percentage of code executed during tests. | Introduces small changes (mutations) to the code to check if tests detect these changes. |
Focus | Focuses on how much of the code is being tested. | Focuses on the quality of tests by ensuring they catch faults in the code. |
Goal | To ensure that a significant portion of the code is exercised during testing. | To ensure that the tests are robust and capable of detecting errors. |
Key Metric | Percentage of code covered by tests (e.g., 80% coverage). | Mutation score: the percentage of mutants (code changes) that are detected by tests. |
Strengths | Provides a quick indication of testing comprehensiveness. | Identifies weaknesses in the tests themselves by ensuring they catch introduced faults. |
Limitations | High coverage doesn’t guarantee effective validation (tests may miss edge cases or logical errors). | May require additional resources to run due to the nature of mutation creation and testing. |
Complementary Role | Ensures that tests run over all parts of the code. | Ensures that the tests actually validate the correctness of the code. |
Best Use Case | For measuring how much of the code is tested and ensuring minimal gaps. | For verifying the effectiveness of your tests and ensuring they are strong enough to catch faults. |
Combining high test coverage with mutation testing gives you a stronger guarantee of quality. While test coverage ensures that a significant portion of your code is being tested, mutation testing ensures that your tests are strong and actually validate the functionality correctly. By combining both, you can significantly improve the reliability and robustness of your tests, providing a solid foundation for maintaining high-quality software.
For Jakarta EE developers, testing becomes even more important as the platform involves multiple layers, ranging from.
Tools and Best Practices
When we talk about the Jakarta EE platform, we have several test stacks where we can work on:
- Arquillian: One of the most well-known testing frameworks in the Jakarta EE ecosystem, Arquillian works on integration testing by managing the lifecycle of a container for you. But, while Arquillian is powerful, I’m not a huge fan due to its complexity and the overhead it introduces to write a single test case. It’s often seen as overkill for smaller projects.
- TestContainers: TestContainers, on the other hand, provides a simpler and more flexible approach to testing by allowing you to run real containers for integration testing. We can check this even in several open-source projects, such as Eclipse JNoSQL, which uses TestContainers to check the integration with NoSQL databases. This solution is fantastic for ensuring that your code interacts correctly with external dependencies, such as databases or messaging queues, without the need to manually configure those services on your local machine.
- JUnit Jupiter and AssertJ: JUnit Jupiter is the foundation for writing unit tests with JUnit 5, while AssertJ provides powerful assertions that make tests more readable and expressive. These two libraries are a great combination when writing unit and integration tests in Jakarta EE.
- WeldTest: As for my personal favorite, WeldTest is a powerful testing solution that makes it easy to test Jakarta EE CDI beans in isolation. You can define it using only annotations; furthermore, you can define the classes to be detected by the container, making it lighter than the CDI that goes on production. Furthermore, you can isolate the injection and select the extensions that will work for each test.
Additionally, platforms like Quarkus have streamlined testing. With just a few annotations, you can run tests in a native container environment, offering a simpler approach for Jakarta EE developers.
Test-Driven Development (TDD): A Deeper Dive
Test-driven development (TDD) is a methodology where tests are written before the code itself. The TDD is defined in three steps:
- Write a failing test
- Make the test pass
- Refactor
TDD offers a clear path to building reliable, maintainable software. It forces developers to think about the behavior of the system before implementation, which leads to more thoughtful and well-designed code. Additionally, since you write tests first, you can easily spot edge cases and requirements before they become problems.
An important point of TDD is that the start is hard: it can slow down development, especially in the beginning. The need to write tests for every feature may feel burdensome, and the process can be more time-consuming compared to just jumping straight into coding. Furthermore, while TDD encourages clean code, it can lead to overly simplistic tests or, in some cases, the risk of over-testing. Still, the long-term benefits — such as improved code quality and easier refactoring—make it a worthwhile investment for many teams.
Data-Driven Testing (DDT): A Practical Approach
Data-driven testing (DDT) focuses on testing the same piece of functionality with a variety of input data. DDT allows you to run the same test multiple times with different data, ensuring that your code works under different conditions. This approach is particularly useful when dealing with applications that need to support a wide range of inputs, such as forms or APIs.
For instance, a simple test case might verify the validation of user input. By using DDT, you can execute the same validation logic with different sets of data, ensuring comprehensive test coverage.
Approach | Test Driven Development | Data Driven Testing |
---|---|---|
Focus | Code structure and design | Input/output validation |
Test Design | Write tests first | Test with multiple datasets |
Speed | Slower development due to upfront test writing | Can be faster for multiple data sets but might result in more complex test cases |
Flexibility | Highly structured | Flexible for various inputs |
Coverage | Good for logic-heavy code | Good for input-heavy scenarios |
Both TDD and DDT have their place in modern software development, depending on the nature of your project. For highly structured code with clear design goals, TDD is ideal. For projects with complex input scenarios, DDT might be more appropriate. Each methodology offers distinct advantages, and when used together, they can provide comprehensive test coverage.
Show Me the Code
In this section, we will walk through a practical example of integrating Jakarta Data and Jakarta NoSQL with an Oracle NoSQL database, using Eclipse JNoSQL. We’ll build a basic Hotel Management System to manage Room
entities and their associated behaviors. The focus will be on setting up the Room
entity, defining the repository, and preparing the test environment to verify functionality.
We begin by defining a Room
entity that represents a room in a hotel. This entity will include various attributes like the room number, type, status, cleanliness, and whether smoking is allowed or not.
@Entity
public class Room {
@Id
private String id;
@Column
private int number;
@Column
private RoomType type;
@Column
private RoomStatus status;
@Column
private CleanStatus cleanStatus;
@Column
private boolean smokingAllowed;
@Column
private boolean underMaintenance;
}
RoomType
,RoomStatus
, andCleanStatus
are enumerations that define the type, status, and cleanliness of the room, respectively.- The
@Id
annotation marks the unique identifier for the room entity. - The
@Column
annotations define the database columns that correspond to the attributes.
Next, we define a RoomRepository
interface, which will handle the data access logic for the Room
entity. We use Jakarta Data annotations like @Query
and @Save
to define specific queries and persistence methods.
@Query("WHERE type <> 'VIP_SUITE' AND status = 'AVAILABLE' AND cleanStatus = 'CLEAN'")
List<Room> findAvailableStandardRooms();
@Query("WHERE cleanStatus <> 'CLEAN' AND status <> 'OUT_OF_SERVICE'")
List<Room> findRoomsNeedingCleaning();
@Query("WHERE smokingAllowed = true AND status = 'AVAILABLE'")
List<Room> findAvailableSmokingRooms();
@Save
void save(List<Room> rooms);
@Save
Room newRoom(Room room);
void deleteBy();
@Query("WHERE type = :type")
List<Room> findByType(@Param("type") String type);
}
To facilitate testing in a Dockerized environment, we define a ManagerSupplier
class that provides a DatabaseManager
. This ensures that we connect to the Oracle NoSQL instance running in a Docker container during tests.
@ApplicationScoped
@Alternative
@Priority(Interceptor.Priority.APPLICATION)
public class ManagerSupplier implements Supplier<DatabaseManager> {
@Produces
@Database(DatabaseType.DOCUMENT)
@Default
public DatabaseManager get() {
return DatabaseContainer.INSTANCE.get("hotel");
}
}
The ManagerSupplier
produces a DatabaseManager
that connects to the Oracle NoSQL database, allowing us to run tests on the Room
entity and repository.
The DatabaseContainer
class is responsible for managing the lifecycle of the Oracle NoSQL container. We use TestContainers
to spin up the container for our tests, ensuring that the database is available for interaction during the test phase.
public enum DatabaseContainer {
INSTANCE;
private final GenericContainer<?> container = new GenericContainer<>
(DockerImageName.parse("ghcr.io/oracle/nosql:latest-ce"))
.withExposedPorts(8080);
{
container.start();
}
public DatabaseManager get(String database) {
DatabaseManagerFactory factory = managerFactory();
return factory.apply(database);
}
public DatabaseManagerFactory managerFactory() {
var configuration = DatabaseConfiguration.getConfiguration();
Settings settings = Settings.builder()
.put(OracleNoSQLConfigurations.HOST, host())
.build();
return configuration.apply(settings);
}
public String host() {
return "http://" + container.getHost() + ":" + container.getFirstMappedPort();
}
}
- The container is configured to run the Oracle NoSQL Docker image, exposing port 8080.
DatabaseContainer.INSTANCE.get("hotel")
retrieves theDatabaseManager
configured for the hotel database, ensuring the connection string points to the Docker container.
The test setup involves Weld for dependency injection (DI), JUnit 5 for testing, and SoftAssertions
from AssertJ for flexible test validation. Let's review the main components.
The first one is the Weld Test that enables CDI on a test scenario, where we can check the basic annotations:
@EnableAutoWeld
: This annotation initializes Weld's DI container for the test class, allowing us to inject dependencies into the test class automatically.@AddPackages
: This annotation is used to specify the packages that contain the beans and other components (such asRoom
,RoomRepository
,ManagerSupplier
) that need to be injected into the test class.@AddExtensions
: The@AddExtensions
annotation includes extensions required for Weld to supportEntityConverter
andDocumentTemplate
.
This setup ensures that the test class is managed by the Weld container, meaning the dependencies (like RoomRepository
) are injected properly without needing manual initialization.
Here is how it is used:
@EnableAutoWeld
@AddPackages(value = {Database.class, EntityConverter.class, DocumentTemplate.class})
@AddPackages(Room.class)
@AddPackages(ManagerSupplier.class)
@AddPackages(Reflections.class)
@AddPackages(Converters.class)
@AddExtensions({ReflectionEntityMetadataExtension.class, DocumentExtension.class})
class AppTest {
}
The annotations specify which classes and packages Weld should manage and load during the test lifecycle, ensuring the correct beans are injected. This allows you to run the test in an environment that mimics a real Jakarta EE container.
@EnableAutoWeld
@AddPackages(value = {Database.class, EntityConverter.class, DocumentTemplate.class})
@AddPackages(Room.class)
@AddPackages(ManagerSupplier.class)
@AddPackages(Reflections.class)
@AddPackages(Converters.class)
@AddExtensions({ReflectionEntityMetadataExtension.class, DocumentExtension.class})
class AppTest {
@Inject
private DocumentTemplate template;
@Test
void shouldTest() {
Room room = new RoomBuilder()
.id("room-1")
.roomNumber(101)
.type(RoomType.SUITE)
.status(RoomStatus.AVAILABLE)
.cleanStatus(CleanStatus.CLEAN)
.smokingAllowed(false)
.underMaintenance(false)
.build();
Room insert = template.insert(room);
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(room.getId()).isEqualTo(insert.getId());
softly.assertThat(room.getNumber()).isEqualTo(insert.getNumber());
softly.assertThat(room.getType()).isEqualTo(insert.getType());
softly.assertThat(room.getStatus()).isEqualTo(insert.getStatus());
softly.assertThat(room.getCleanStatus()).isEqualTo(insert.getCleanStatus());
softly.assertThat(room.isSmokingAllowed()).isEqualTo(insert.isSmokingAllowed());
softly.assertThat(room.isUnderMaintenance()).isEqualTo(insert.isUnderMaintenance());
softly.assertThat(insert.getId()).isNotNull();
});
}
}
@EnableAutoWeld
initializes Weld for dependency injection in the test.@Inject
ensures that theDocumentTemplate
is injected, which provides methods for interacting with the database.- The
shouldTest()
method tests inserting aRoom
object into the database and verifies that the entity was correctly persisted and retrieved usingSoftAssertions
.
The RoomServiceTest
class is a more comprehensive test suite that focuses on different room query scenarios using RoomRepository
.
@EnableAutoWeld
@AddPackages(value = {Database.class, EntityConverter.class, DocumentTemplate.class})
@AddPackages(Room.class)
@AddPackages(ManagerSupplier.class)
@AddPackages(Reflections.class)
@AddPackages(Converters.class)
@AddExtensions({ReflectionEntityMetadataExtension.class, DocumentExtension.class})
class RoomServiceTest {
@Inject
private RoomRepository repository;
private static final Faker FAKER = new Faker();
@BeforeEach
void setUP() {
// Populate database with various Room entities
}
@AfterEach
void cleanUp() {
repository.deleteBy(); // Ensures the database is reset after each test
}
@ParameterizedTest(name = "should find rooms by type {0}")
@EnumSource(RoomType.class)
void shouldFindRoomByType(RoomType type) {
List<Room> rooms = this.repository.findByType(type.name());
SoftAssertions.assertSoftly(softly -> softly.assertThat(rooms).allMatch(room -> room.getType().equals(type)));
}
@ParameterizedTest
@MethodSource("room")
void shouldSaveRoom(Room room) {
Room updateRoom = this.repository.newRoom(room);
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(updateRoom).isNotNull();
softly.assertThat(updateRoom.getId()).isNotNull();
softly.assertThat(updateRoom.getNumber()).isEqualTo(room.getNumber());
softly.assertThat(updateRoom.getType()).isEqualTo(room.getType());
softly.assertThat(updateRoom.getStatus()).isEqualTo(room.getStatus());
softly.assertThat(updateRoom.getCleanStatus()).isEqualTo(room.getCleanStatus());
softly.assertThat(updateRoom.isSmokingAllowed()).isEqualTo(room.isSmokingAllowed());
});
}
@Test
void shouldFindRoomReadyToGuest() {
List<Room> rooms = this.repository.findAvailableStandardRooms();
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(rooms).hasSize(3);
softly.assertThat(rooms).allMatch(room -> room.getStatus().equals(RoomStatus.AVAILABLE));
softly.assertThat(rooms).allMatch(room -> !room.isUnderMaintenance());
});
}
static Stream<Arguments> room() {
return Stream.of(Arguments.of(getRoom(), Arguments.of(getRoom(), Arguments.of(getRoom()))));
}
private static Room getRoom() {
return new RoomBuilder()
.id(UUID.randomUUID().toString())
.roomNumber(FAKER.number().numberBetween(100, 999))
.type(randomEnum(RoomType.class))
.status(randomEnum(RoomStatus.class))
.cleanStatus(randomEnum(CleanStatus.class))
.smokingAllowed(FAKER.bool().bool())
.build();
}
private static <T extends Enum<?>> T randomEnum(Class<T> enumClass) {
T[] constants = enumClass.getEnumConstants();
int index = ThreadLocalRandom.current().nextInt(constants.length);
return constants[index];
}
}
RoomServiceTest
includes tests for various scenarios like saving rooms, finding rooms by type, and checking room availability and cleanliness.@ParameterizedTest
with@EnumSource
and@MethodSource
helps run the same tests for multiple inputs, ensuring better test coverage and validation.SoftAssertions
is used to validate the entity’s properties flexibly, allowing all assertions to run even if one fails.
Conclusion
The test classes you've set up make use of modern testing techniques such as Weld for dependency injection, JUnit 5 Parameterized Tests for running tests with various inputs, and SoftAssertions for comprehensive error reporting. The combination of these tools, along with data-driven testing, ensures your application logic is well-tested and resilient to a variety of conditions.
By using Weld for DI, you are mimicking a real Jakarta EE environment in your tests, making sure that your RoomRepository
and RoomService
behave correctly in a containerized environment. Furthermore, leveraging data-driven tests with ParameterizedTest
and MethodSource
enables comprehensive test coverage, while SoftAssertions
provides insightful feedback without prematurely terminating the tests.
Opinions expressed by DZone contributors are their own.
Comments