My team and me are working on a API rest using Spring boot. It's composed by controllers, services and JpaRepo interfaces. Shamefully, is a monolitic app. "There's no time for it" is the answer then I suggest to change to microservices, so we have to work with what we have.
The API is growing thunderous, that includes the unit tests. Right know, we have 1267 individual test cases and more than 300 test classes (service and controllers included).
The service test classes are like this:
@SpringBootTest(classes = Project.class)
@TestPropertySource(locations = "classpath:test.properties")
@DirtiesContext(classMode=ClassMode.AFTER_CLASS)
public class Services_CategoryTBTest {
@TestConfiguration
static class Service_CategoryTbTestContextConfiguration{
@Bean
public GenericService<CategoryModifDTO, CategoryIdDTO, String> categoryService(){
return new Service_CategoryTB_impl();
}
}
@Autowired
Service_CategoryTB_impl service;
@MockBean
CategoryRepository repository;
private void setMock(String id) {
Optional<CategoryTb> categoryTB = Optional.of(new CategoryTb());
categoryTB.get().setCodcat(id);
Mockito.when(repository.existsById(id)).thenReturn(true);
Mockito.when(repository.findById(id)).thenReturn(categoryTB);
}
private void setSeveralMock() {
CategoryTb categoryTB = new CategoryTb();
categoryTB.setCodcat("1");
Mockito.when(repository.findAll()).thenReturn(Arrays.asList(categoryTB));
}
@Test
void getSpecificSuccessfully() {
//Given
String id = "1";
setMock(id);
//When
CategoryIdDTO category = service.findById(id);
//Then
assertEquals(category.getCodcat(), id);
}
@Test
void getAllSuccessfully() {
//Given
setSeveralsMock();
//When
List<CategoryIdDTO> categorys = service.findAll();
//Then
assertEquals(categorys.size(), 1);
}
@Test
void createSuccessfully() {
//Given
CategoryIdDTO category = new CategoryIdDTO();
category.setCodcat("1");
category.setDescat("Desc");
//When
service.create(category);
//Then
Mockito.verify(repository).save(Mockito.any(CategoryTb.class));
}
@Test
void updateSuccessfully() {
//Given
String id = "1";
setMock(id);
CategoryModifDTO category = new CategoryModifDTO();
category.setDescat("Desc");
//When
service.update(id, category);
//Then
Mockito.verify(repository).save(Mockito.any(CategoryTb.class));
}
@Test
void deleteSuccessfully() {
//Given
String id = "1";
setMock(id);
//When
service.delete(id);
//Then
Mockito.verify(repository).delete(Mockito.any(CategoryTb.class));
}
@Test
void testFailIfExistsByIdCheckSuccessfully() {
//Given
Mockito.when(repository.existsById(null)).thenReturn(true);
assertThrows(HttpClientErrorException.class, () -> service.failIfExistsById(null));
}
@Test
void testFailIfNotExistsByIdCheckSuccessfully() {
//Given
Mockito.when(repository.existsById(null)).thenReturn(false);
assertThrows(ResourcesNotFoundException.class, () -> service.failIfNotExistsById(null));
}
}
And controller test classes are like this:
@WebMvcTest(controllers = {
CategoryController.class }, excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class), excludeAutoConfiguration = {
SecurityAutoConfiguration.class })
@TestPropertySource(locations = "classpath:test.properties")
@DirtiesContext(classMode=ClassMode.AFTER_CLASS)
public class CategoryControllerTest {
@Autowired
private MockMvc mvc;
@MockBean
private Service_CategoryTB_impl service;
private final String baseUri = "/api/categories";
private final id = "1"
@Test
void getSpecificSuccessfully() throws Exception {
// Given
setMock(id);
// When
mvc.perform(get(baseUri "/{id}", id)
.contentType(MediaType.APPLICATION_JSON))
.andExpectAll(
status().isOk(),
content().contentType(MediaType.APPLICATION_JSON),
jsonPath("$.codcat").value(id));
}
@Test
void getAllSuccessfully() throws Exception {
// Given
setSeveralMock();
// When and then
mvc.perform(get(baseUri)
.contentType(MediaType.APPLICATION_JSON))
.andExpectAll(
status().isOk(),
content().contentType(MediaType.APPLICATION_JSON),
jsonPath("$").isArray());
}
@Test
void createSuccessfully() throws Exception {
// Given
JSONObject cfDTO = createAsJson();
// When and then
mvc.perform(post(baseUri)
.contentType(MediaType.APPLICATION_JSON)
.content(cfDTO.toString())).andExpectAll(
status().isCreated(),
header().string("Location", baseUri "/" id),
jsonPath("$.message").value("created."));
}
@Test
void updateSuccessfully() throws Exception {
// Given
JSONObject cfDTO = createAsJson();
// When and then
mvc.perform(put(baseUri "/{id}", cfDTO.getString("codcat"))
.contentType(MediaType.APPLICATION_JSON)
.content(cfDTO.toString())).andExpectAll(
status().isOk(),
header().string("Location", baseUri "/" id),
jsonPath("$.message").value("modified."));
}
@Test
void deleteSuccessfully() throws Exception {
mvc.perform(delete(baseUri "/{id}", id))
.andExpectAll(
status().isOk(),
jsonPath("$.message").value("deleted."));
}
private JSONObject createAsJson() throws JSONException {
JSONObject categoryJson = new JSONObject();
categoryJson.put("codcat", id);
categoryJson.put("descat", "desc");
return categoryJson;
}
private void setSeveralMock() {
CategoryIdDTO categoryTB = new CategoryIdDTO();
categoryTB.setCodcat(id);
when(service.findAll()).thenReturn(Arrays.asList(categoryTB));
}
private void setMock(String id) {
CategoryIdDTO category = new CategoryIdDTO();
category.setCodcat(id);
when(service.findById(id)).thenReturn(category);
}
}
And test.properties
spring.profiles.active=test
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=sa
logging.pattern.console=
spring.autoconfigure.exclude= \
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration, \
org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration, \
org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration
The rest of the tests have practically the same structure. There's only one exception where a base64 image is managed during a test, but its size is minimun exactly for not making these tests too heavy.
I've followed this, this, an this questions. Even I found that there was a bug that seems to have been my very same problem. But following Spring Boot doc, it redirects to the latest version of junit, so I suppose that if I'm using the latest Spring Boot version, I should have the latest version of junit (or >5.3 at least). So the bug should be already solved.
Here is my POM
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.7</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<groupId>com.project</groupId>
<artifactId>project</artifactId>
<version>1.0</version>
<packaging>war</packaging>
<name>project</name>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!-- https://mvnrepository.com/artifact/org.modelmapper/modelmapper -->
<dependency>
<groupId>org.modelmapper</groupId>
<artifactId>modelmapper</artifactId>
<version>3.0.0</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.28.0-GA</version>
</dependency>
<dependency>
<groupId>org.codehaus.jackson</groupId>
<artifactId>jackson-core-asl</artifactId>
<version>1.9.2</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.codehaus.jackson</groupId>
<artifactId>jackson-mapper-asl</artifactId>
<version>1.9.13</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.oracle.database.jdbc</groupId>
<artifactId>ojdbc8</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20220320</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.6.4</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
<configuration>
<properties>
<configurationParameters>
junit.jupiter.conditions.deactivate = *
junit.jupiter.extensions.autodetection.enabled = true
junit.jupiter.testinstance.lifecycle.default = per_class
junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = concurrent
junit.jupiter.execution.parallel.mode.classes.default = concurrent
junit.jupiter.execution.parallel.config.strategy = dynamic
junit.jupiter.execution.parallel.config.dynamic.factor = 2
</configurationParameters>
</properties>
</configuration>
</plugin>
</plugins>
</build>
</project>
I've tried every solution that I've found. Even solutions proposed by the comments of the questions mentioned. If someone can help me. Thanks in advance.
Note 1: The code is an aproximation of the original code, but it represents it close enough.
Note 2: If you can help me to speed up testing, it would be a bonus. The guy in the bug report claims that it executes ~2600 tests in 3 minutes and my tests take 20 minutes to execute. But in one of the questions, an answer suggets it's normal that a test suite takes several hours to complete.
CodePudding user response:
One of the problems I see is that that you are abusing @SpringBootTest
for things that should be a basic unit test. You should only use @SpringBootTest
for full integration tests not for unit tests.
The problem with this is that for each permuation of @TestPropertySource
, @MockBean
and others (see documentation). It will load a full application and it will be cached (see the aforementioned documentation). This is not only slow but will also lead to memory issues with large applications.
Instead you should write a basic unit test using Mockito (which you already use through @MockBean
).
So rewrite those tests that follow said pattern to use plain Mockito instead of @SpringBootTest
. This will greatly speed up your test runs and reduce the amount of memory needed.
@ExtendWith(MockitoExtension.class)
public class Services_CategoryTBTest {
private static final id = "1"
@InjectMocks
privateService_CategoryTB_impl service;
@Mock
private CategoryRepository repository;
private void setMock(String id) {
Optional<CategoryTb> categoryTB = Optional.of(new CategoryTb());
categoryTB.get().setCodcat(id);
Mockito.when(repository.existsById(id)).thenReturn(true);
Mockito.when(repository.findById(id)).thenReturn(categoryTB);
}
private void setSeveralMock() {
CategoryTb categoryTB = new CategoryTb();
categoryTB.setCodcat("1");
Mockito.when(repository.findAll()).thenReturn(Arrays.asList(categoryTB));
}
@Test
void getSpecificSuccessfully() {
//Given
String id = "1";
setMock(id);
//When
CategoryIdDTO category = service.findById(id);
//Then
assertEquals(category.getCodcat(), id);
}
@Test
void getAllSuccessfully() {
//Given
setSeveralsMock();
//When
List<CategoryIdDTO> categorys = service.findAll();
//Then
assertEquals(categorys.size(), 1);
}
@Test
void createSuccessfully() {
//Given
CategoryIdDTO category = new CategoryIdDTO();
category.setCodcat("1");
category.setDescat("Desc");
//When
service.create(category);
//Then
Mockito.verify(repository).save(Mockito.any(CategoryTb.class));
}
@Test
void updateSuccessfully() {
//Given
String id = "1";
setMock(id);
CategoryModifDTO category = new CategoryModifDTO();
category.setDescat("Desc");
//When
service.update(id, category);
//Then
Mockito.verify(repository).save(Mockito.any(CategoryTb.class));
}
@Test
void deleteSuccessfully() {
//Given
String id = "1";
setMock(id);
//When
service.delete(id);
//Then
Mockito.verify(repository).delete(Mockito.any(CategoryTb.class));
}
@Test
void testFailIfExistsByIdCheckSuccessfully() {
//Given
Mockito.when(repository.existsById(null)).thenReturn(true);
assertThrows(HttpClientErrorException.class, () -> service.failIfExistsById(null));
}
@Test
void testFailIfNotExistsByIdCheckSuccessfully() {
//Given
Mockito.when(repository.existsById(null)).thenReturn(false);
assertThrows(ResourcesNotFoundException.class, () -> service.failIfNotExistsById(null));
}
}
The above test, using plain Mockito, will run much faster and tests exactly the same.
Next you also seem to be really keen on using @DirtiesContext
don't as that will also slow down your tests as it will re-load the context each time it needs. As you are using mocks nothing will stick and thus no need to do a reload. So ditch them (and if something really needs it be very careful with it or even modify your test so you don't need it, like do cleanup after the test instead of reload the whole application).
CodePudding user response:
You could specify a JVM arg for your tests allowing for a higher memory usage like this in your pom.xml
:
</project>
[...]
<build>
[...]
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
<configuration>
<argLine>-Xmx1024M</argLine> // You can provide comma separated values if you have more than one
</configuration>
</plugin>
</plugins>
[...]
</build>
[...]
</project>
If you specify enough memory, you shouldn't run into java.lang.OutOfMemoryError
s anymore.