Integration Tests
An integration test is a broader type of test. It allows to exercise the source code in the same way the application runs in production. When we talk about integration tests, we mean a test, that starts the complete Spring context and also interacts with a real database.
In the following example, we will look at the OrderAmountCalculation
again. But this time, we don’t test the class as a unit test in isolation (as described in the previous chapter), but we test it as part of the bigger context where it is used in the application. In this case, there is an event listener, that listens to EntityChangedEvent
of OrderLine
entities. As part of the persistence logic, the listener recalculates the order amount of the order, the order line belongs to with the help of the OrderAmountCalculation
class:
@EventListener
public void recalculateOrderAmount(EntityChangedEvent<OrderLine> event) {
Order order = findOrderFromEvent(event);
BigDecimal amount = new OrderAmountCalculation().calculateTotalAmount(order.getLines());
order.setAmount(amount);
dataManager.save(order);
}
In the integration test, we can now test the OrderLineEventListener
and the OrderAmountCalculation
together. The DataManager
is Jmix central entry point to the persistence layer of the application. During the save operation, it is executing the event listeners, that are registered in the application. We will create an order and an order line and store them in the database via the DataManager
API. This will trigger the event listener and the order amount will be calculated.
Dependency Injection in Tests
In a Spring Integration test, we can use the same dependency injection mechanism as in the application. We can use the @Autowired
annotation to inject beans into our test class. In this example, we inject the DataManager
into the test class to use it to transitively trigger the OrderLineEventListener
logic:
@SpringBootTest
public class OrderLineEventListenerTest {
@Autowired
DataManager dataManager;
// ...
}
In case we want to test a custom bean directly, we can also directly inject the code under test into the test class. In this example, we inject the CustomerService
to directly execute the method we want to test:
@SpringBootTest
public class CustomerServiceTest {
@Autowired
CustomerService customerService;
// ...
}
Database Interactions
In an integration test there are two main reasons to interact with the database. The first reason is that we want to set up test data. This is oftentimes required as a prerequisite for the execution of the test case. For this we can use the regular Jmix functionalities like DataManager
to interact with the database as we would in production code.
The second reason is that by executing the business logic that we want to test, it will access the database. Let’s look at an example for both of those scenarios:
@Test
void given_customerWithEmailExists_when_findByEmail_then_customerFound() {
// given
Customer customer = dataManager.create(Customer.class);
customer.setEmail("customer@test.com");
dataManager.save(customer); (1)
// when
Optional<Customer> foundCustomer = customerService.findByEmail("customer@test.com"); (2)
// then
assertThat(foundCustomer)
.isPresent();
}
1 | The DataManager is used in the test, to store a test customer in the database. |
2 | The CustomerService is used to perform a database lookup of customers by Email. |
Test Data Cleanup
In the example above, we use the DataManager
to store a test customer in the database. Since all tests share the same database instance by default, it means that this test data will also be available for the next test. This is not a problem in this example, but in other cases, it might be. Let’s look at an example for this: assuming we have a unique constraint on the email address field of the Customer entity. If we have a test that creates a customer with a specific email address, and another test that searches for a customer by email address - assuming it is not there, the second test would fail, because it would find the customer created by the first test.
There are several ways on how to clean up the test data. The first is to keep references of entities that were created during the test. In the example above, we could keep a reference to the customer that was created in the test and delete it after the test has finished via dataManager.remove(customer)
. This is a valid approach, but it requires some additional code in the test. Additionally, it is not always possible to keep a reference to the data that was created during the test. For example, if we create a new entity in the production code, we don’t have a reference to it in the test. Furthermore, in case of an exception during the test, the cleanup code might not be executed.
Due to this the second alternative would be to perform more general database cleanup. In the following example we use JdbcTemplate
to perform a SQL statement DELETE FROM CUSTOMER
to delete all customers from the database:
@Autowired (1)
DataSource dataSource;
@AfterEach (2)
void tearDown() {
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
JdbcTestUtils.deleteFromTables(jdbc, "CUSTOMER"); (3)
}
1 | The DataSource is injected to instantiate the JdbcTemplate . |
2 | @AfterEach from JUnit is used to execute the data cleanup after each test case. |
3 | The JdbcTestUtils from Spring is used as a convenience method to delete all data from a database table. More information on JdbcTestUtils can be found in the Spring testing documentation. |
Security Context in Tests
Jmix allows running code under a specific user’s authority, which is often necessary when testing functionalities that depend on user roles and permissions. This can be achieved by using the SystemAuthenticator.
With the SystemAuthenticator bean, you can execute of some business logic as a particular user. This is useful in testing scenarios where your code has security constraints and behaves differently depending on the user’s role.
Let’s consider an example where we need to test a CustomerService method that behaves differently depending on the role of the user who executes it:
@Component
public class CustomerService {
@Autowired
private DataManager dataManager;
public Optional<Customer> findByEmail(String email) {
return dataManager.load(Customer.class)
.query("select c from sample_Customer c where c.email = :email")
.parameter("email", email)
.optional();
}
}
In this example, the CustomerService
has a method findCustomerByEmail
that returns a customer entity if found. The underlying permissions restrict customer data to only particular roles. We can test this behaviour by using the SystemAuthenticator
to execute the method as a particular user:
@Test
void given_noPermissionsToReadCustomerData_when_findByEmail_then_nothingFound() {
// given
Customer customer = dataManager.create(Customer.class);
customer.setEmail("customer@test.com");
dataManager.save(customer);
// and
User userWithoutPermissions = dataManager.create(User.class);
userWithoutPermissions.setUsername(USERNAME);
dataManager.save(userWithoutPermissions); (1)
// when
Optional<Customer> foundCustomer = systemAuthenticator.withUser( (2)
USERNAME,
() -> customerService.findByEmail("customer@test.com") (3)
);
// then
assertThat(foundCustomer)
.isNotPresent();
}
1 | For the test case, we create a new user without any role assignment |
2 | SystemAuthenticator is used to execute the code under test as the newly created user. |
3 | The CustomerService is used to perform a database lookup of customers by Email with the security context of that user. |
As we did not assign any roles to the user, the customer service returns an empty optional.
AuthenticatedAsAdmin
When you are writing integration test that requires a security context (like reading and writing to the database, e.g. through the Jmix DataManager
facility), it is required to always set up a security context. This is also true for all test data that is written to the database via DataManager
. But oftentimes, the security context is not relevant for the test case itself, but only for the test data setup.
In this case, you can use the JUnit extension AuthenticatedAsAdmin
, which is automatically present in a Jmix project.
It creates a security context before each test and sets the authenticated user to the admin user. This allows you to write tests that require a security context without having to set up the security context yourself.
@SpringBootTest
@ExtendWith(AuthenticatedAsAdmin.class)
public class CustomerServiceTest {
private final String USERNAME = "userWithoutPermissions";
// ...
It is also possible to combine the AuthenticatedAsAdmin
extension with the SystemAuthenticator
to execute the test code as a particular user. By annotating the test class the default security context is set to the admin. But within the test case, we can use the SystemAuthenticator
to execute the code as a particular user. This way, we don’t have to manually wrap all test data generation code into by a SystemAuthenticator
call.
Overriding Behaviour of the Application
Sometimes even for integration tests it is required to mock out certain parts of the application. In those cases, it is possible to combine the functionalities of a @SpringBooTest
with Mockito and mock out particular beans, but still use the overall Spring context.
Assuming we have a NotificationService
that as part of its business logic uses the Emailer
API from the Jmix Email Sending Add-on. In our integration test we don’t want to actually send out emails. Instead, we would like to mock this functionality.
@MockBean
To mock a bean in a Spring Integration test, we can use the @MockBean
annotation. In the following example, we mock out the Emailer
bean as described above:
@MockBean
Emailer emailer;
By using the @MockBean
annotation, we can replace the bean in the application context and replace it with a mock. This enables two things:
-
it prevents the email from actually being sent
-
it allows to simulate failure scenario where email sending did not work
@TestConfiguration
In the example above, we use the @MockBean
annotation to replace the Emailer
bean with a mock. But this is not the only way to replace a bean in the application context. Another way is to use the @TestConfiguration
annotation. This annotation can be used to create a new configuration class that is only used for the test. In the following example, we create a new configuration class that replaces the Emailer
bean with a mock:
package testing.ex1.customer;
import io.jmix.email.EmailException;
import io.jmix.email.EmailInfo;
import io.jmix.email.Emailer;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import testing.ex1.app.customer.NotificationService;
import testing.ex1.entity.Customer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
@SpringBootTest
class NotificationServiceWithTestConfigurationTest {
@Autowired
NotificationService notificationService;
@TestConfiguration (1)
public static class EmailerTestConfiguration {
@Bean
public Emailer emailer() throws EmailException { (2)
Emailer emailer = mock(Emailer.class); (3)
doThrow(EmailException.class).when(emailer) (4)
.sendEmail(any(EmailInfo.class));
return emailer;
}
}
@Test
void given_emailNotDelivered_when_sendNotification_then_noSuccess() throws EmailException {
// given:
Customer customer = new Customer();
customer.setEmail("customer@company.com");
// when:
boolean success = notificationService.notifyCustomerAboutOrderDelivered(customer);
// then:
assertThat(success).isFalse();
}
}
1 | The inner static class annotated with @TestConfiguration will be picked up by Spring when executing the test case. |
2 | A bean with the name emailer of type Emailer will be manually declared. It overrides the production definition of that bean. |
3 | A mock instance is manually created |
4 | The behaviour of the mock is specified and the configured mock is returned. |
For the production code, when interacting with the Emailer bean, it will now use the mock instead of the actual implementation.