JavaRush /Java Blog /Random EN /What is Mapstruct and how to properly set it up for unit ...

What is Mapstruct and how to properly set it up for unit testing in SpringBoot applications

Published in the Random EN group

background

Hello, my dear friends and readers! Before we write an article, a little background ... Recently I encountered one problem in working with the Mapstruct library , which I briefly described in my telegram channel here . In the comments, the problem for the record was solved, my colleague from the previous project helped with this. What is Mapstruct and how to properly set it up for unit testing in SpringBoot applications.  Part 1 - 1After that, I decided to write an article on this topic, but of course we will not look narrowly and will first try to get in the know, understand what Mapstruct is and why it is needed, and already on a real example we will analyze the situation that arose earlier and how to solve it. Therefore, I strongly recommend doing all the calculations in parallel with reading the article in order to experience everything in practice. Before starting - subscribe to my telegram channel, I collect my activities there, write thoughts about development in Java and IT in general. Subscribed? Great! Well, now let's go!

Mapstruct, faq?

A code generator for fast type-safe bean mappings. Our first task is to figure out what Mapstruct is and why we need it. In general, you can read about it on the official website. On the main page of the site there are three answers to the questions: what is it? For what? How? Let's try to do this:

What it is?

Mapstruct is a library that helps to map (map, in general, they always say: map, map, etc.) objects of some entities into objects of other entities using generated code based on configurations that are described through interfaces.

For what?

For the most part, we develop multi-layer applications (the database layer, the business logic layer, the application interaction layer with the outside world) and each layer has its own objects for storing and processing data. And this data needs to be transferred from layer to layer by transferring from one entity to another. For those who have not worked with this approach, it may seem somewhat complicated. For example, we have an entity for the Student database. When the data of this entity goes into the business logic (services) layer, we need to translate the data from the Student class to the StudentModel class. Further, after all the manipulations with the business logic, the data must be given out. And for this we have the StudentDto class. Of course, we need to pass data from the StudentModel class to StudentDto. Writing by hand every time methods that will be transferred is laborious. Plus, this is extra code in the codebase that needs to be maintained. You can make a mistake. And Mapstruct generates such methods at the compilation stage and stores them in generated-sources.

How?

With the help of annotations. We just need to create an annotation that will have the main Mapper annotation, which will tell the library that the methods in this interface can be used to translate from one object to another. As I said earlier about students, in our case it will be the StudentMapper interface, which will have several methods for transferring data from one layer to another:
public class Student {
   private Long id;
   private String firstName;
   private String lastName;
   private Integer age;
}

public class StudentDTO {
   private Long id;
   private String firstName;
   private String lastName;
   private Integer age;
}

public class StudentModel {
   private Long id;
   private String firstName;
   private String lastName;
   private Integer age;
}
For these classes, we create a mapper (hereinafter, this is how we will call the interface that describes what we want to translate and where):
@Mapper
public interface StudentMapper {
   StudentModel toModel(StudentDTO dto);
   Student toEntity(StudentModel model);
   StudentModel toModel(Student entity);
   StudentDTO toDto(StudentModel model);
}
The beauty of this approach is that if the names and types of fields are the same in different classes (as in our case), then the settings for Mapstruct are enough to generate the necessary implementation based on the StudentMapper interface at the compilation stage, which will translate. It's clearer now, right? Let's go further and use a real example to analyze the work in a Spring Boot application.

An example of how Spring Boot and Mapstruct work

The first thing we need is to create a Spring Boot project and add Mapstruct to it. For this case, I have an organization in GitHub with templates for repositories and start for Spring Boot is one of them. Based on it, we create a new project: What is Mapstruct and how to properly set it up for unit testing in SpringBoot applications.  Part 1 - 2Next, we get the project . Yes, friends, put a star on the project if you found it useful, so I will know that I'm doing it for a reason. In this project, we will reveal the situation that I received at work and described in a post on my telegram channel. I will briefly outline the situation for those who are not in the know: when we write tests for mappers (that is, for those interface implementations that we talked about earlier), we want the tests to pass as quickly as possible. The simplest option with mappers is to use the SpringBootTest annotation during test startup, which will raise the entire ApplicationContext of the Spring Boot application and inject the mapper needed for the test inside the test. But this option is resource-intensive and takes much more time, so it is not suitable for us. We need to write a unit test that simply creates the desired mapper and checks that its methods work exactly as we expect. Why do you need to run tests faster? If the tests take a long time, then it slows down the entire development process. Until the tests pass on the new code, this code cannot be considered correct and it will not be taken into testing, which means it will not be taken into production, which means that the developer did not complete the work. It would seem, why write a test for a library whose work is beyond doubt? And yet, we need to write a test, because we are testing how correctly the mapper was described and whether it does what we expect. First of all, to make things easier for us, let's add Lombok to our project by adding another dependency to pom.xml:
<dependency>
   <groupId>org.projectlombok</groupId>
   <artifactId>lombok</artifactId>
   <scope>provided</scope>
</dependency>
In our project, we will need to translate from model classes (which are used to work with business logic) to DTO classes that we use to communicate with the outside world. In our simplified version, we will assume that the fields do not change and our mappers will be simple. But, if there is a desire, it will be possible to write a more detailed article on how to work with Mapstruct, how to configure it, how to use its advantages. But then, since this article will come out rather big. Let's say we have a student with a list of lectures and lecturers that he attends. Let's create a model package . Based on this, we will create a simple model:
package com.github.romankh3.templaterepository.springboot.dto;

import lombok.Data;

import java.util.List;

@Data
public class StudentDTO {

   private Long id;

   private String name;

   private List<LectureDTO> lectures;

   private List<LecturerDTO> lecturers;
}
his lectures
package com.github.romankh3.templaterepository.springboot.dto;

import lombok.Data;

@Data
public class LectureDTO {

   private Long id;

   private String name;
}
and lecturers
package com.github.romankh3.templaterepository.springboot.dto;

import lombok.Data;

@Data
public class LecturerDTO {

   private Long id;

   private String name;
}
And create a dto package next to the model package :
package com.github.romankh3.templaterepository.springboot.dto;

import lombok.Data;

import java.util.List;

@Data
public class StudentDTO {

   private Long id;

   private String name;

   private List<LectureDTO> lectures;

   private List<LecturerDTO> lecturers;
}
lectures
package com.github.romankh3.templaterepository.springboot.dto;

import lombok.Data;

@Data
public class LectureDTO {

   private Long id;

   private String name;
}
and lecturers
package com.github.romankh3.templaterepository.springboot.dto;

import lombok.Data;

@Data
public class LecturerDTO {

   private Long id;

   private String name;
}
Now let's create a mapper that will translate the collection of lecture models into a collection of DTO lectures. The first thing to do is to add Mapstruct to the project. To do this, use their official website , everything is described there. That is, we need to add one dependency and a plugin to our pomnik (if you have questions about what a pomnik is, here you go Article1 and Article2 ):
<dependency>
   <groupId>org.mapstruct</groupId>
   <artifactId>mapstruct</artifactId>
   <version>1.4.2.Final</version>
</dependency>
and in the memo in the <build/> block. which we didn't have yet:
<build>
   <plugins>
       <plugin>
           <groupId>org.apache.maven.plugins</groupId>
           <artifactId>maven-compiler-plugin</artifactId>
           <version>3.5.1</version>
           <configuration>
               <source>1.8</source>
               <target>1.8</target>
               <annotationProcessorPaths>
                   <path>
                       <groupId>org.mapstruct</groupId>
                       <artifactId>mapstruct-processor</artifactId>
                       <version>1.4.2.Final</version>
                   </path>
               </annotationProcessorPaths>
           </configuration>
       </plugin>
   </plugins>
</build>
Next, let's create a mapper package next to dto and model . Based on the classes that we showed earlier, we will need to create five more mappers:
  • Mapper LectureModel <-> LectureDTO
  • Mapper List<LectureModel> <-> List<LectureDTO>
  • Mapper LecturerModel <-> LecturerDTO
  • Mapper List<LecturerModel> <-> List<LecturerDTO>
  • Mapper StudentModel <-> StudentDTO
Go:

LectureMapper

package com.github.romankh3.templaterepository.springboot.mapper;

import com.github.romankh3.templaterepository.springboot.dto.LectureDTO;
import com.github.romankh3.templaterepository.springboot.dto.LecturerDTO;
import com.github.romankh3.templaterepository.springboot.model.LectureModel;
import org.mapstruct.Mapper;

@Mapper(componentModel = "spring")
public interface LectureMapper {
   LectureDTO toDTO(LectureModel model);

   LectureModel toModel(LecturerDTO dto);
}

LectureListMapper

package com.github.romankh3.templaterepository.springboot.mapper;

import com.github.romankh3.templaterepository.springboot.dto.LectureDTO;
import com.github.romankh3.templaterepository.springboot.dto.LectureDTO;
import com.github.romankh3.templaterepository.springboot.model.LectureModel;
import org.mapstruct.Mapper;

import java.util.List;

@Mapper(componentModel = "spring", uses = LectureMapper.class)
public interface LectureListMapper {
   List<LectureModel> toModelList(List<LectureDTO> dtos);
   List<LectureDTO> toDTOList(List<LectureModel> models);
}

LecturerMapper

package com.github.romankh3.templaterepository.springboot.mapper;

import com.github.romankh3.templaterepository.springboot.dto.LectureDTO;
import com.github.romankh3.templaterepository.springboot.model.LectureModel;
import org.mapstruct.Mapper;

@Mapper(componentModel = "spring")
public interface LectureMapper {
   LectureDTO toDTO(LectureModel model);

   LectureModel toModel(LectureDTO dto);
}

LecturerListMapper

package com.github.romankh3.templaterepository.springboot.mapper;

import com.github.romankh3.templaterepository.springboot.dto.LecturerDTO;
import com.github.romankh3.templaterepository.springboot.model.LecturerModel;
import org.mapstruct.Mapper;

import java.util.List;

@Mapper(componentModel = "spring", uses = LecturerMapper.class)
public interface LecturerListMapper {
   List<LecturerModel> toModelList(List<LecturerDTO> dloList);
   List<LecturerDTO> toDTOList(List<LecturerModel> modelList);
}

StudentMapper

package com.github.romankh3.templaterepository.springboot.mapper;

import com.github.romankh3.templaterepository.springboot.dto.StudentDTO;
import com.github.romankh3.templaterepository.springboot.model.StudentModel;
import org.mapstruct.Mapper;

@Mapper(componentModel = "spring", uses = {LectureListMapper.class, LecturerListMapper.class})
public interface StudentMapper {
   StudentDTO toDTO(StudentModel model);
   StudentModel toModel(StudentDTO dto);
}
It should be noted separately that in mappers we refer to other mappers. This is done through the uses field in the Mapper annotation, as is done in StudentMapper:
@Mapper(componentModel = "spring", uses = {LectureListMapper.class, LecturerListMapper.class})
Here we use two mappers to correctly map the list of lectures and the list of lecturers. Now we need to compile our code and see what's there and how. You can do this with the mvn clean compile command . But, as it turned out, when creating implementations for the Mapstruct of our mappers, the mapper implementations did not overwrite the fields. Why? It turned out that it was not possible to pick up the Data annotation from Lombok. And something had to be done ... Therefore, we have a new section in the article.

Connecting Lombok and Mapstruct

After several minutes of searching, it turned out that Lombok and Mapstruct needed to be connected in a certain way. The Mapstruct documentation has information on this . After examining the example that the developers from Mapstruct offered, let's update our pom.xml: Let's add versions separately:
​​<properties>
   <org.mapstruct.version>1.4.2.Final</org.mapstruct.version>
   <lombok-mapstruct-binding.version>0.2.0</lombok-mapstruct-binding.version>
</properties>
Let's add the missing dependency:
<dependency>
   <groupId>org.projectlombok</groupId>
   <artifactId>lombok-mapstruct-binding</artifactId>
   <version>${lombok-mapstruct-binding.version}</version>
</dependency>
And let's update our compiler plugin so that it can still connect Lombok and Mapstruct:
<plugin>
   <groupId>org.apache.maven.plugins</groupId>
   <artifactId>maven-compiler-plugin</artifactId>
   <version>3.5.1</version>
   <configuration>
       <source>1.8</source>
       <target>1.8</target>
       <annotationProcessorPaths>
           <path>
               <groupId>org.mapstruct</groupId>
               <artifactId>mapstruct-processor</artifactId>
               <version>${org.mapstruct.version}</version>
           </path>
           <path>
               <groupId>org.projectlombok</groupId>
               <artifactId>lombok</artifactId>
               <version>${lombok.version}</version>
           </path>
           <path>
               <groupId>org.projectlombok</groupId>
               <artifactId>lombok-mapstruct-binding</artifactId>
               <version>${lombok-mapstruct-binding.version}</version>
           </path>
       </annotationProcessorPaths>
   </configuration>
</plugin>
After that, everything should work out. Let's compile our project again. But where to look for the classes generated by Mapstruct? They're in generated-sources: ${projectDir}/target/generated-sources/annotations/ What is Mapstruct and how to properly set it up for unit testing in SpringBoot applications.  Part 1 - 3 Now that we're prepared to deal with my frustration in the Mapstruct post, let's try to create tests for mappers.

Writing tests for our mappers

I'll create a quick and simple test that would test one of the mappers in case we create an integration test and don't care about the time it takes:

LectureMapperTest

package com.github.romankh3.templaterepository.springboot.mapper;

import com.github.romankh3.templaterepository.springboot.dto.LectureDTO;
import com.github.romankh3.templaterepository.springboot.model.LectureModel;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class LectureMapperTest {

   @Autowired
   private LectureMapper mapperUnderTest;

   @Test
   void shouldProperlyMapModelToDto() {
       //given
       LectureModel model = new LectureModel();
       model.setId(11L);
       model.setName("lecture name");

       //when
       LectureDTO dto = mapperUnderTest.toDTO(model);

       //then
       Assertions.assertNotNull(dto);
       Assertions.assertEquals(model.getId(), dto.getId());
       Assertions.assertEquals(model.getName(), dto.getName());
   }

   @Test
   void shouldProperlyMapDtoToModel() {
       //given
       LectureDTO dto = new LectureDTO();
       dto.setId(11L);
       dto.setName("lecture name");

       //when
       LectureModel model = mapperUnderTest.toModel(dto);

       //then
       Assertions.assertNotNull(model);
       Assertions.assertEquals(dto.getId(), model.getId());
       Assertions.assertEquals(dto.getName(), model.getName());
   }
}
Here, with the SpringBootTest annotation, we launch the entire applicationContext and extract the class we need for testing from it using the Autowired annotation. From the point of view of speed and ease of writing a test, this is very good. The test passed successfully, all is well. But we will go the other way and write a unit test for a mapper, for example, on LectureListMapper ...
package com.github.romankh3.templaterepository.springboot.mapper;

import com.github.romankh3.templaterepository.springboot.dto.LectureDTO;
import com.github.romankh3.templaterepository.springboot.model.LectureModel;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import java.util.Collections;
import java.util.List;

class LectureListMapperTest {

   private final LectureListMapper lectureListMapper = new LectureListMapperImpl();

   @Test
   void shouldProperlyMapListDtosToListModels() {
       //given
       LectureDTO dto = new LectureDTO();
       dto.setId(12L);
       dto.setName("I'm BATMAN!");

       List<LectureDTO> dtos = Collections.singletonList(dto);

       //when
       List<LectureModel> models = lectureListMapper.toModelList(dtos);

       //then
       Assertions.assertNotNull(models);
       Assertions.assertEquals(1, models.size());
       Assertions.assertEquals(dto.getId(), models.get(0).getId());
       Assertions.assertEquals(dto.getName(), models.get(0).getName());
   }
}
Since the implementations that Mapstruct generates are in the same class as our project, we can safely use them in our tests. Everything looks great - no annotations, we create the class that we need in the simplest way and that's it. But when we run the test, we will understand that it will fail and there will be a NullPointerException in the console ... This is because the implementation of the LectureListMapper mapper looks like this:
package com.github.romankh3.templaterepository.springboot.mapper;

import com.github.romankh3.templaterepository.springboot.dto.LectureDTO;
import com.github.romankh3.templaterepository.springboot.model.LectureModel;
import java.util.ArrayList;
import java.util.List;
import javax.annotation.Generated;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Generated(
   value = "org.mapstruct.ap.MappingProcessor",
   date = "2021-12-09T21:46:12+0300",
   comments = "version: 1.4.2.Final, compiler: javac, environment: Java 15.0.2 (N/A)"
)
@Component
public class LectureListMapperImpl implements LectureListMapper {

   @Autowired
   private LectureMapper lectureMapper;

   @Override
   public List<LectureModel> toModelList(List<LectureDTO> dtos) {
       if ( dtos == null ) {
           return null;
       }

       List<LectureModel> list = new ArrayList<LectureModel>( dtos.size() );
       for ( LectureDTO lectureDTO : dtos ) {
           list.add( lectureMapper.toModel( lectureDTO ) );
       }

       return list;
   }

   @Override
   public List<LectureDTO> toDTOList(List<LectureModel> models) {
       if ( models == null ) {
           return null;
       }

       List<LectureDTO> list = new ArrayList<LectureDTO>( models.size() );
       for ( LectureModel lectureModel : models ) {
           list.add( lectureMapper.toDTO( lectureModel ) );
       }

       return list;
   }
}
If we look, then NPE (short for NullPointerException), we get just from the lectureMapper variable, which is not initialized. But our implementation does not have a constructor with which we could initialize the variable. This is exactly the reason why Mapstruct implemented the mapper in this way! In Spring, there are several ways to add beans to classes, you can inject them through a field along with the Autowired annotation, as done above, or you can inject them through a constructor. In such a problematic situation, I found myself at work when it was necessary to optimize the execution time of tests. I thought that nothing could be done about it and poured out my pain on my telegram channel. And then they helped me in the comments, they said that it is possible to set up an injection strategy. The Mapper interface has a field injectionStrategy , which just accepts the enam InjectionStrategywhich has two values: FIELD and CONSTRUCTOR . Now, knowing this, let's add this setting to our mappers, I'll show it on the example of LectureListMapper :
@Mapper(componentModel = "spring", uses = LectureMapper.class, injectionStrategy = InjectionStrategy.CONSTRUCTOR)
public interface LectureListMapper {
   List<LectureModel> toModelList(List<LectureDTO> dtos);
   List<LectureDTO> toDTOList(List<LectureModel> models);
}
I highlighted in bold the part that I added. Let's add this option for everyone else and recompile the project so that mappers are generated already with a new line. When this is done, let's see how the implementation of the mapper for LectureListMapper has changed (highlighted in bold the part that we need):
package com.github.romankh3.templaterepository.springboot.mapper;

import com.github.romankh3.templaterepository.springboot.dto.LectureDTO;
import com.github.romankh3.templaterepository.springboot.model.LectureModel;
import java.util.ArrayList;
import java.util.List;
import javax.annotation.Generated;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Generated(
   value = "org.mapstruct.ap.MappingProcessor",
   date = "2021-12-09T22:25:37+0300",
   comments = "version: 1.4.2.Final, compiler: javac, environment: Java 15.0.2 (N/A)"
)
@Component
public class LectureListMapperImpl implements LectureListMapper {

   private final LectureMapper lectureMapper;

   @Autowired
   public LectureListMapperImpl(LectureMapper lectureMapper) {

       this.lectureMapper = lectureMapper;
   }

   @Override
   public List<LectureModel> toModelList(List<LectureDTO> dtos) {
       if ( dtos == null ) {
           return null;
       }

       List<LectureModel> list = new ArrayList<LectureModel>( dtos.size() );
       for ( LectureDTO lectureDTO : dtos ) {
           list.add( lectureMapper.toModel( lectureDTO ) );
       }

       return list;
   }

   @Override
   public List<LectureDTO> toDTOList(List<LectureModel> models) {
       if ( models == null ) {
           return null;
       }

       List<LectureDTO> list = new ArrayList<LectureDTO>( models.size() );
       for ( LectureModel lectureModel : models ) {
           list.add( lectureMapper.toDTO( lectureModel ) );
       }

       return list;
   }
}
And now Mapstruct has implemented mapper injection through the constructor. Exactly what we were looking for. Now our test will stop compiling, let's update it and get:
package com.github.romankh3.templaterepository.springboot.mapper;

import com.github.romankh3.templaterepository.springboot.dto.LectureDTO;
import com.github.romankh3.templaterepository.springboot.model.LectureModel;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import java.util.Collections;
import java.util.List;

class LectureListMapperTest {

   private final LectureListMapper lectureListMapper = new LectureListMapperImpl(new LectureMapperImpl());

   @Test
   void shouldProperlyMapListDtosToListModels() {
       //given
       LectureDTO dto = new LectureDTO();
       dto.setId(12L);
       dto.setName("I'm BATMAN!");

       List<LectureDTO> dtos = Collections.singletonList(dto);

       //when
       List<LectureModel> models = lectureListMapper.toModelList(dtos);

       //then
       Assertions.assertNotNull(models);
       Assertions.assertEquals(1, models.size());
       Assertions.assertEquals(dto.getId(), models.get(0).getId());
       Assertions.assertEquals(dto.getName(), models.get(0).getName());
   }
}
Now, if we run the test, everything will work as it should, because in the LectureListMapperImpl we pass the LectureMapper it needs... Victory! It’s not difficult for you, but I’m pleased: Friends, everything is as usual, subscribe to my github account , to the telegram account . There I post the result of my activities, there are really useful things) I especially invite you to join the discussion group of the telegram channel . It so happened that if someone has a technical question, you can get an answer there. This format is interesting for everyone, you can read who knows what and gain experience.

Conclusion

As part of this article, we got acquainted with such a necessary and often used product as Mapstruct. Understand what it is, why and how. On a real example, we felt what can be done and how it can be changed. Also, we figured out how to set up bean injection through the constructor, so that it would be possible to test mappers normally. Colleagues from Mapstruct allowed users of their product to choose how to inject mappers, for which we are undoubtedly grateful. BUT, despite the fact that Spring recommends injecting beans through the constructor, the guys from Mapstruct set the default to inject through the field. Why is that? No answer. I suspect there may be reasons we just don't know about and that's why they did it. And to learn from them, I created a GitHub issuein their official product repository. What is Mapstruct and how to properly set it up for unit testing in SpringBoot applications.  Part 1 - 4
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION