Search code examples
spring-boothibernatespring-data-jpadata-jpa-test

Why does DataJpaTest fail when saving OneToMany-related data in this pattern?


I have three Hibernate @Entity's below that mimic a failure in my production app:

@Entity
@Data
@SuperBuilder(toBuilder = true)
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
public class Dog extends Animal {
    String barkType;
}

The Dog entity uses JOINED inheritance with this class, Animal:

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@Data
@SuperBuilder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
public class Animal {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Type(type = "uuid-char")
    private UUID id;

    @OneToMany(cascade = CascadeType.REMOVE)
    @JoinColumn(name = "animalId", referencedColumnName = "id", insertable = false, updatable = false)
    @Builder.Default
    private List<Toy> toys = new ArrayList<>();
}

This Toy Entity is related to the parent class, Animal

@Entity
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Toy {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Type(type = "uuid-char")
    private UUID id;
    
    @Type(type = "uuid-char")
    private UUID animalId;
    
    private String shape;
}

And here is my implementation I am testing:

@Service
@AllArgsConstructor
public class DogService {

    DogRepository repository;
    ToyRepository toyRepository;

    @Transactional
    public Dog saveDogDTO(DogDTO dogDTO) {
        Dog entity = Dog.builder()
                .barkType(dogDTO.getBarkType())
                .build();

        repository.save(entity);
        toyRepository.save(Toy.builder()
                .shape(dogDTO.getToyShape())
                .animalId(entity.getId())
                .build());

        return entity;
    }
}

Here is my failing Test, which fails on the LAST line:

@DataJpaTest
class DogServiceTests {

    private DogService dogService;

    @Autowired
    private DogRepository dogRepository;

    @Autowired
    private ToyRepository toyRepository;

    @Test
    void save_not_working_example() {
        dogService = new DogService(dogRepository, toyRepository);

        var dogDTO = DogDTO.builder()
                .barkType("big bark")
                .toyShape("some shape")
                .build();

        var savedDog = dogService.saveDogDTO(dogDTO);

        assertThat(dogRepository.count()).isEqualTo(1);
        assertThat(toyRepository.count()).isEqualTo(1);

        var findByIdResult = dogRepository.findById(savedDog.getId());
        assertThat(findByIdResult.get().getToys()).hasSize(1);
    }

}

The test failure message:

Expected size: 1 but was: 0 in:
[]
java.lang.AssertionError: 
Expected size: 1 but was: 0 in:
[]

The issue seems to be that the double JPA repository save clashes within the @Transaction. Is there a way to overcome this issue? I tried adding @Transactional(propagation = Propagation.NEVER) to the test, but then I get this failure:

failed to lazily initialize a collection of role: com.example.datajpatest.demo.models.Animal.toys, could not initialize proxy - no Session
org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.example.datajpatest.demo.models.Animal.toys, could not initialize proxy - no Session

Solution

  • @DataJpaTest is annotated @Transactional so your test method is all wrapped in a single transaction, and hence a single EntityManager. You could make your test pass by calling EntityManager.detach() on the savedDog before querying using findById(). You could also fix it by manually setting up the dog's toys in the DogService. That would be my recommendation because otherwise sooner or later you might find the same inconsistency bug in production code - the transaction boundaries just have to shift a bit and that would be quite hard to spot. In a way @DataJpaTest has done you a favour by pointing out the problem, albeit somewhat indirectly.

    Ultimately, the database state doesn't match the state of the EntityManager cache, so you have to clear the cache to get the result you want. Starting a new transaction would clear the cache too, and that's what is probably happening in production. Hibernate trusts you to make the object graph match the database state when you save (or flush). If they don't match then Hibernate has no way of knowing without querying the database, which it would regard as redundant and inefficient.