unit-test-mapper-converter

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Unit Testing Mappers and Converters

映射器与转换器的单元测试

Test MapStruct mappers and custom converter classes. Verify field mapping accuracy, null handling, type conversions, and nested object transformations.
测试MapStruct映射器和自定义转换器类,验证字段映射准确性、空值处理、类型转换以及嵌套对象转换。

When to Use This Skill

何时使用该技能

Use this skill when:
  • Testing MapStruct mapper implementations
  • Testing custom entity-to-DTO converters
  • Testing nested object mapping
  • Verifying null handling in mappers
  • Testing type conversions and transformations
  • Want comprehensive mapping test coverage before integration tests
在以下场景使用该技能:
  • 测试MapStruct映射器实现
  • 测试自定义实体转DTO转换器
  • 测试嵌套对象映射
  • 验证映射器中的空值处理
  • 测试类型转换与变换逻辑
  • 在集成测试前确保映射测试覆盖率达标

Setup: Testing Mappers

环境搭建:测试映射器

Maven

Maven

xml
<dependency>
  <groupId>org.mapstruct</groupId>
  <artifactId>mapstruct</artifactId>
  <version>1.5.5.Final</version>
</dependency>
<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.assertj</groupId>
  <artifactId>assertj-core</artifactId>
  <scope>test</scope>
</dependency>
xml
<dependency>
  <groupId>org.mapstruct</groupId>
  <artifactId>mapstruct</artifactId>
  <version>1.5.5.Final</version>
</dependency>
<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.assertj</groupId>
  <artifactId>assertj-core</artifactId>
  <scope>test</scope>
</dependency>

Gradle

Gradle

kotlin
dependencies {
  implementation("org.mapstruct:mapstruct:1.5.5.Final")
  testImplementation("org.junit.jupiter:junit-jupiter")
  testImplementation("org.assertj:assertj-core")
}
kotlin
dependencies {
  implementation("org.mapstruct:mapstruct:1.5.5.Final")
  testImplementation("org.junit.jupiter:junit-jupiter")
  testImplementation("org.assertj:assertj-core")
}

Basic Pattern: Testing MapStruct Mapper

基础模式:测试MapStruct映射器

Simple Entity to DTO Mapping

简单实体转DTO映射

java
// Mapper interface
@Mapper(componentModel = "spring")
public interface UserMapper {
  UserDto toDto(User user);
  User toEntity(UserDto dto);
  List<UserDto> toDtos(List<User> users);
}

// Unit test
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class UserMapperTest {

  private final UserMapper userMapper = Mappers.getMapper(UserMapper.class);

  @Test
  void shouldMapUserToDto() {
    User user = new User(1L, "Alice", "alice@example.com", 25);
    
    UserDto dto = userMapper.toDto(user);
    
    assertThat(dto)
      .isNotNull()
      .extracting("id", "name", "email", "age")
      .containsExactly(1L, "Alice", "alice@example.com", 25);
  }

  @Test
  void shouldMapDtoToEntity() {
    UserDto dto = new UserDto(1L, "Alice", "alice@example.com", 25);
    
    User user = userMapper.toEntity(dto);
    
    assertThat(user)
      .isNotNull()
      .hasFieldOrPropertyWithValue("id", 1L)
      .hasFieldOrPropertyWithValue("name", "Alice");
  }

  @Test
  void shouldMapListOfUsers() {
    List<User> users = List.of(
      new User(1L, "Alice", "alice@example.com", 25),
      new User(2L, "Bob", "bob@example.com", 30)
    );
    
    List<UserDto> dtos = userMapper.toDtos(users);
    
    assertThat(dtos)
      .hasSize(2)
      .extracting(UserDto::getName)
      .containsExactly("Alice", "Bob");
  }

  @Test
  void shouldHandleNullEntity() {
    UserDto dto = userMapper.toDto(null);
    
    assertThat(dto).isNull();
  }
}
java
// Mapper interface
@Mapper(componentModel = "spring")
public interface UserMapper {
  UserDto toDto(User user);
  User toEntity(UserDto dto);
  List<UserDto> toDtos(List<User> users);
}

// Unit test
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class UserMapperTest {

  private final UserMapper userMapper = Mappers.getMapper(UserMapper.class);

  @Test
  void shouldMapUserToDto() {
    User user = new User(1L, "Alice", "alice@example.com", 25);
    
    UserDto dto = userMapper.toDto(user);
    
    assertThat(dto)
      .isNotNull()
      .extracting("id", "name", "email", "age")
      .containsExactly(1L, "Alice", "alice@example.com", 25);
  }

  @Test
  void shouldMapDtoToEntity() {
    UserDto dto = new UserDto(1L, "Alice", "alice@example.com", 25);
    
    User user = userMapper.toEntity(dto);
    
    assertThat(user)
      .isNotNull()
      .hasFieldOrPropertyWithValue("id", 1L)
      .hasFieldOrPropertyWithValue("name", "Alice");
  }

  @Test
  void shouldMapListOfUsers() {
    List<User> users = List.of(
      new User(1L, "Alice", "alice@example.com", 25),
      new User(2L, "Bob", "bob@example.com", 30)
    );
    
    List<UserDto> dtos = userMapper.toDtos(users);
    
    assertThat(dtos)
      .hasSize(2)
      .extracting(UserDto::getName)
      .containsExactly("Alice", "Bob");
  }

  @Test
  void shouldHandleNullEntity() {
    UserDto dto = userMapper.toDto(null);
    
    assertThat(dto).isNull();
  }
}

Testing Nested Object Mapping

测试嵌套对象映射

Map Complex Hierarchies

映射复杂层级结构

java
// Entities with nesting
class User {
  private Long id;
  private String name;
  private Address address;
  private List<Phone> phones;
}

// Mapper with nested mapping
@Mapper(componentModel = "spring")
public interface UserMapper {
  UserDto toDto(User user);
  User toEntity(UserDto dto);
}

// Unit test for nested objects
class NestedObjectMapperTest {

  private final UserMapper userMapper = Mappers.getMapper(UserMapper.class);

  @Test
  void shouldMapNestedAddress() {
    Address address = new Address("123 Main St", "New York", "NY", "10001");
    User user = new User(1L, "Alice", address);
    
    UserDto dto = userMapper.toDto(user);
    
    assertThat(dto.getAddress())
      .isNotNull()
      .hasFieldOrPropertyWithValue("street", "123 Main St")
      .hasFieldOrPropertyWithValue("city", "New York");
  }

  @Test
  void shouldMapListOfNestedPhones() {
    List<Phone> phones = List.of(
      new Phone("123-456-7890", "MOBILE"),
      new Phone("987-654-3210", "HOME")
    );
    User user = new User(1L, "Alice", null, phones);
    
    UserDto dto = userMapper.toDto(user);
    
    assertThat(dto.getPhones())
      .hasSize(2)
      .extracting(PhoneDto::getNumber)
      .containsExactly("123-456-7890", "987-654-3210");
  }

  @Test
  void shouldHandleNullNestedObjects() {
    User user = new User(1L, "Alice", null);
    
    UserDto dto = userMapper.toDto(user);
    
    assertThat(dto.getAddress()).isNull();
  }
}
java
// Entities with nesting
class User {
  private Long id;
  private String name;
  private Address address;
  private List<Phone> phones;
}

// Mapper with nested mapping
@Mapper(componentModel = "spring")
public interface UserMapper {
  UserDto toDto(User user);
  User toEntity(UserDto dto);
}

// Unit test for nested objects
class NestedObjectMapperTest {

  private final UserMapper userMapper = Mappers.getMapper(UserMapper.class);

  @Test
  void shouldMapNestedAddress() {
    Address address = new Address("123 Main St", "New York", "NY", "10001");
    User user = new User(1L, "Alice", address);
    
    UserDto dto = userMapper.toDto(user);
    
    assertThat(dto.getAddress())
      .isNotNull()
      .hasFieldOrPropertyWithValue("street", "123 Main St")
      .hasFieldOrPropertyWithValue("city", "New York");
  }

  @Test
  void shouldMapListOfNestedPhones() {
    List<Phone> phones = List.of(
      new Phone("123-456-7890", "MOBILE"),
      new Phone("987-654-3210", "HOME")
    );
    User user = new User(1L, "Alice", null, phones);
    
    UserDto dto = userMapper.toDto(user);
    
    assertThat(dto.getPhones())
      .hasSize(2)
      .extracting(PhoneDto::getNumber)
      .containsExactly("123-456-7890", "987-654-3210");
  }

  @Test
  void shouldHandleNullNestedObjects() {
    User user = new User(1L, "Alice", null);
    
    UserDto dto = userMapper.toDto(user);
    
    assertThat(dto.getAddress()).isNull();
  }
}

Testing Custom Mapping Methods

测试自定义映射方法

Mapper with @Mapping Annotations

带有@Mapping注解的映射器

java
@Mapper(componentModel = "spring")
public interface ProductMapper {
  @Mapping(source = "name", target = "productName")
  @Mapping(source = "price", target = "salePrice")
  @Mapping(target = "discount", expression = "java(product.getPrice() * 0.1)")
  ProductDto toDto(Product product);

  @Mapping(source = "productName", target = "name")
  @Mapping(source = "salePrice", target = "price")
  Product toEntity(ProductDto dto);
}

class CustomMappingTest {

  private final ProductMapper mapper = Mappers.getMapper(ProductMapper.class);

  @Test
  void shouldMapFieldsWithCustomNames() {
    Product product = new Product(1L, "Laptop", 999.99);
    
    ProductDto dto = mapper.toDto(product);
    
    assertThat(dto)
      .hasFieldOrPropertyWithValue("productName", "Laptop")
      .hasFieldOrPropertyWithValue("salePrice", 999.99);
  }

  @Test
  void shouldCalculateDiscountFromExpression() {
    Product product = new Product(1L, "Laptop", 100.0);
    
    ProductDto dto = mapper.toDto(product);
    
    assertThat(dto.getDiscount()).isEqualTo(10.0);
  }

  @Test
  void shouldReverseMapCustomFields() {
    ProductDto dto = new ProductDto(1L, "Laptop", 999.99);
    
    Product product = mapper.toEntity(dto);
    
    assertThat(product)
      .hasFieldOrPropertyWithValue("name", "Laptop")
      .hasFieldOrPropertyWithValue("price", 999.99);
  }
}
java
@Mapper(componentModel = "spring")
public interface ProductMapper {
  @Mapping(source = "name", target = "productName")
  @Mapping(source = "price", target = "salePrice")
  @Mapping(target = "discount", expression = "java(product.getPrice() * 0.1)")
  ProductDto toDto(Product product);

  @Mapping(source = "productName", target = "name")
  @Mapping(source = "salePrice", target = "price")
  Product toEntity(ProductDto dto);
}

class CustomMappingTest {

  private final ProductMapper mapper = Mappers.getMapper(ProductMapper.class);

  @Test
  void shouldMapFieldsWithCustomNames() {
    Product product = new Product(1L, "Laptop", 999.99);
    
    ProductDto dto = mapper.toDto(product);
    
    assertThat(dto)
      .hasFieldOrPropertyWithValue("productName", "Laptop")
      .hasFieldOrPropertyWithValue("salePrice", 999.99);
  }

  @Test
  void shouldCalculateDiscountFromExpression() {
    Product product = new Product(1L, "Laptop", 100.0);
    
    ProductDto dto = mapper.toDto(product);
    
    assertThat(dto.getDiscount()).isEqualTo(10.0);
  }

  @Test
  void shouldReverseMapCustomFields() {
    ProductDto dto = new ProductDto(1L, "Laptop", 999.99);
    
    Product product = mapper.toEntity(dto);
    
    assertThat(product)
      .hasFieldOrPropertyWithValue("name", "Laptop")
      .hasFieldOrPropertyWithValue("price", 999.99);
  }
}

Testing Enum Mapping

测试枚举映射

Map Enums Between Entity and DTO

在实体与DTO之间映射枚举

java
// Enum with different representation
enum UserStatus { ACTIVE, INACTIVE, SUSPENDED }
enum UserStatusDto { ENABLED, DISABLED, LOCKED }

@Mapper(componentModel = "spring")
public interface UserMapper {
  @ValueMapping(source = "ACTIVE", target = "ENABLED")
  @ValueMapping(source = "INACTIVE", target = "DISABLED")
  @ValueMapping(source = "SUSPENDED", target = "LOCKED")
  UserStatusDto toStatusDto(UserStatus status);
}

class EnumMapperTest {

  private final UserMapper mapper = Mappers.getMapper(UserMapper.class);

  @Test
  void shouldMapActiveToEnabled() {
    UserStatusDto dto = mapper.toStatusDto(UserStatus.ACTIVE);
    assertThat(dto).isEqualTo(UserStatusDto.ENABLED);
  }

  @Test
  void shouldMapSuspendedToLocked() {
    UserStatusDto dto = mapper.toStatusDto(UserStatus.SUSPENDED);
    assertThat(dto).isEqualTo(UserStatusDto.LOCKED);
  }
}
java
// Enum with different representation
enum UserStatus { ACTIVE, INACTIVE, SUSPENDED }
enum UserStatusDto { ENABLED, DISABLED, LOCKED }

@Mapper(componentModel = "spring")
public interface UserMapper {
  @ValueMapping(source = "ACTIVE", target = "ENABLED")
  @ValueMapping(source = "INACTIVE", target = "DISABLED")
  @ValueMapping(source = "SUSPENDED", target = "LOCKED")
  UserStatusDto toStatusDto(UserStatus status);
}

class EnumMapperTest {

  private final UserMapper mapper = Mappers.getMapper(UserMapper.class);

  @Test
  void shouldMapActiveToEnabled() {
    UserStatusDto dto = mapper.toStatusDto(UserStatus.ACTIVE);
    assertThat(dto).isEqualTo(UserStatusDto.ENABLED);
  }

  @Test
  void shouldMapSuspendedToLocked() {
    UserStatusDto dto = mapper.toStatusDto(UserStatus.SUSPENDED);
    assertThat(dto).isEqualTo(UserStatusDto.LOCKED);
  }
}

Testing Custom Type Conversions

测试自定义类型转换

Non-MapStruct Custom Converter

非MapStruct自定义转换器

java
// Custom converter class
public class DateFormatter {
  private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");

  public static String format(LocalDate date) {
    return date != null ? date.format(formatter) : null;
  }

  public static LocalDate parse(String dateString) {
    return dateString != null ? LocalDate.parse(dateString, formatter) : null;
  }
}

// Unit test
class DateFormatterTest {

  @Test
  void shouldFormatLocalDateToString() {
    LocalDate date = LocalDate.of(2024, 1, 15);
    
    String result = DateFormatter.format(date);
    
    assertThat(result).isEqualTo("2024-01-15");
  }

  @Test
  void shouldParseStringToLocalDate() {
    String dateString = "2024-01-15";
    
    LocalDate result = DateFormatter.parse(dateString);
    
    assertThat(result).isEqualTo(LocalDate.of(2024, 1, 15));
  }

  @Test
  void shouldHandleNullInFormat() {
    String result = DateFormatter.format(null);
    assertThat(result).isNull();
  }

  @Test
  void shouldHandleInvalidDateFormat() {
    assertThatThrownBy(() -> DateFormatter.parse("invalid-date"))
      .isInstanceOf(DateTimeParseException.class);
  }
}
java
// Custom converter class
public class DateFormatter {
  private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");

  public static String format(LocalDate date) {
    return date != null ? date.format(formatter) : null;
  }

  public static LocalDate parse(String dateString) {
    return dateString != null ? LocalDate.parse(dateString, formatter) : null;
  }
}

// Unit test
class DateFormatterTest {

  @Test
  void shouldFormatLocalDateToString() {
    LocalDate date = LocalDate.of(2024, 1, 15);
    
    String result = DateFormatter.format(date);
    
    assertThat(result).isEqualTo("2024-01-15");
  }

  @Test
  void shouldParseStringToLocalDate() {
    String dateString = "2024-01-15";
    
    LocalDate result = DateFormatter.parse(dateString);
    
    assertThat(result).isEqualTo(LocalDate.of(2024, 1, 15));
  }

  @Test
  void shouldHandleNullInFormat() {
    String result = DateFormatter.format(null);
    assertThat(result).isNull();
  }

  @Test
  void shouldHandleInvalidDateFormat() {
    assertThatThrownBy(() -> DateFormatter.parse("invalid-date"))
      .isInstanceOf(DateTimeParseException.class);
  }
}

Testing Bidirectional Mapping

测试双向映射

Entity ↔ DTO Round Trip

实体 ↔ DTO 往返转换

java
class BidirectionalMapperTest {

  private final UserMapper mapper = Mappers.getMapper(UserMapper.class);

  @Test
  void shouldMaintainDataInRoundTrip() {
    User original = new User(1L, "Alice", "alice@example.com", 25);
    
    UserDto dto = mapper.toDto(original);
    User restored = mapper.toEntity(dto);
    
    assertThat(restored)
      .hasFieldOrPropertyWithValue("id", original.getId())
      .hasFieldOrPropertyWithValue("name", original.getName())
      .hasFieldOrPropertyWithValue("email", original.getEmail())
      .hasFieldOrPropertyWithValue("age", original.getAge());
  }

  @Test
  void shouldPreserveAllFieldsInBothDirections() {
    Address address = new Address("123 Main", "NYC", "NY", "10001");
    User user = new User(1L, "Alice", "alice@example.com", 25, address);
    
    UserDto dto = mapper.toDto(user);
    User restored = mapper.toEntity(dto);
    
    assertThat(restored).usingRecursiveComparison().isEqualTo(user);
  }
}
java
class BidirectionalMapperTest {

  private final UserMapper mapper = Mappers.getMapper(UserMapper.class);

  @Test
  void shouldMaintainDataInRoundTrip() {
    User original = new User(1L, "Alice", "alice@example.com", 25);
    
    UserDto dto = mapper.toDto(original);
    User restored = mapper.toEntity(dto);
    
    assertThat(restored)
      .hasFieldOrPropertyWithValue("id", original.getId())
      .hasFieldOrPropertyWithValue("name", original.getName())
      .hasFieldOrPropertyWithValue("email", original.getEmail())
      .hasFieldOrPropertyWithValue("age", original.getAge());
  }

  @Test
  void shouldPreserveAllFieldsInBothDirections() {
    Address address = new Address("123 Main", "NYC", "NY", "10001");
    User user = new User(1L, "Alice", "alice@example.com", 25, address);
    
    UserDto dto = mapper.toDto(user);
    User restored = mapper.toEntity(dto);
    
    assertThat(restored).usingRecursiveComparison().isEqualTo(user);
  }
}

Testing Partial Mapping

测试部分映射

Update Existing Entity from DTO

从DTO更新现有实体

java
@Mapper(componentModel = "spring")
public interface UserMapper {
  void updateEntity(@MappingTarget User entity, UserDto dto);
}

class PartialMapperTest {

  private final UserMapper mapper = Mappers.getMapper(UserMapper.class);

  @Test
  void shouldUpdateExistingEntity() {
    User existing = new User(1L, "Alice", "alice@old.com", 25);
    UserDto dto = new UserDto(1L, "Alice", "alice@new.com", 26);
    
    mapper.updateEntity(existing, dto);
    
    assertThat(existing)
      .hasFieldOrPropertyWithValue("email", "alice@new.com")
      .hasFieldOrPropertyWithValue("age", 26);
  }

  @Test
  void shouldNotUpdateFieldsNotInDto() {
    User existing = new User(1L, "Alice", "alice@example.com", 25);
    UserDto dto = new UserDto(1L, "Bob", null, 0);
    
    mapper.updateEntity(existing, dto);
    
    // Assuming null-aware mapping is configured
    assertThat(existing.getEmail()).isEqualTo("alice@example.com");
  }
}
java
@Mapper(componentModel = "spring")
public interface UserMapper {
  void updateEntity(@MappingTarget User entity, UserDto dto);
}

class PartialMapperTest {

  private final UserMapper mapper = Mappers.getMapper(UserMapper.class);

  @Test
  void shouldUpdateExistingEntity() {
    User existing = new User(1L, "Alice", "alice@old.com", 25);
    UserDto dto = new UserDto(1L, "Alice", "alice@new.com", 26);
    
    mapper.updateEntity(existing, dto);
    
    assertThat(existing)
      .hasFieldOrPropertyWithValue("email", "alice@new.com")
      .hasFieldOrPropertyWithValue("age", 26);
  }

  @Test
  void shouldNotUpdateFieldsNotInDto() {
    User existing = new User(1L, "Alice", "alice@example.com", 25);
    UserDto dto = new UserDto(1L, "Bob", null, 0);
    
    mapper.updateEntity(existing, dto);
    
    // Assuming null-aware mapping is configured
    assertThat(existing.getEmail()).isEqualTo("alice@example.com");
  }
}

Best Practices

最佳实践

  • Test all mapper methods comprehensively
  • Verify null handling for every nullable field
  • Test nested objects independently and together
  • Use recursive comparison for complex nested structures
  • Test bidirectional mapping to catch asymmetries
  • Keep mapper tests simple and focused on transformation correctness
  • Use Mappers.getMapper() for non-Spring standalone tests
  • 全面测试所有映射器方法
  • 验证每个可空字段的空值处理
  • 独立及联合测试嵌套对象
  • 对复杂嵌套结构使用递归比较
  • 测试双向映射以发现不对称问题
  • 保持映射器测试简洁,聚焦转换正确性
  • 非Spring独立测试使用Mappers.getMapper()

Common Pitfalls

常见陷阱

  • Not testing null input cases
  • Not verifying nested object mappings
  • Assuming bidirectional mapping is symmetric
  • Not testing edge cases (empty collections, etc.)
  • Tight coupling of mapper tests to MapStruct internals
  • 未测试空值输入场景
  • 未验证嵌套对象映射
  • 假设双向映射是对称的
  • 未测试边缘情况(如空集合等)
  • 映射器测试与MapStruct内部实现过度耦合

Troubleshooting

故障排除

Null pointer exceptions during mapping: Check
nullValuePropertyMappingStrategy
and
nullValueCheckStrategy
in
@Mapper
.
Enum mapping not working: Verify
@ValueMapping
annotations correctly map source to target values.
Nested mapping produces null: Ensure nested mapper interfaces are also mapped in parent mapper.
映射期间出现空指针异常:检查@Mapper注解中的
nullValuePropertyMappingStrategy
nullValueCheckStrategy
配置。
枚举映射不生效:验证@ValueMapping注解是否正确映射源与目标值。
嵌套映射返回空值:确保嵌套映射器接口也在父映射器中配置。

References

参考资料