0

I am having trouble testing the auditing annotations in Spring JPA (2.5.4) using an H2 in-memory database. I have a main class annotated with @EnableJpaAuditing, and a base class for my entities.

@Getter
@Setter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class AuditedEntity {
    @CreatedDate
    LocalDateTime createdDate;

    @LastModifiedDate
    LocalDateTime lastModifiedDate;
}

Two entities extend the base class: a parent and a child.

@Data
@Entity
@Table(name = "one2many")
class OneToManyEntity extends AuditedEntity {
    @Id
    @GeneratedValue(strategy = SEQUENCE)
    Integer id;

    @OneToMany(mappedBy = "parent", cascade = ALL, orphanRemoval = true)
    List<ManyToOneEntity> children;
}

@Data
@Entity
@Table(name = "many2one")
class ManyToOneEntity extends AuditedEntity {
    @Id
    @GeneratedValue(strategy = SEQUENCE)
    Integer id;

    @ManyToOne(optional = false, fetch = LAZY)
    OneToManyEntity parent;
}

The repository for the parent entity is a simple interface declaration.

@Repository
interface OneToManyRepository extends CrudRepository<OneToManyEntity, Integer> {
}

And I have a couple of Spock tests for it.

class OneToManyRepoSpec extends Specification {
    @Autowired
    OneToManyRepository repo

    def "test ID is assigned"() {
        given:
            def parent = new OneToManyEntity()
            parent.setChildren([new ManyToOneEntity()])
        expect:
            def persisted = repo.save(parent)
            persisted.getId() > 0
            persisted.getLastModifiedDate() != null
    }

    def "LastModifiedDate value is updated"() {
        given:
            def persisted1 = repo.save(new OneToManyEntity())
            sleep(1000)
            persisted1.setChildren([])
            def persisted2 = repo.save(persisted1)
        expect:
            persisted2.lastModifiedDate.isAfter(persisted1.lastModifiedDate)
    }
}

I can get either of these tests to pass, depending on how I annotate the test class; but I cannot get both tests to pass together.

  • If I annotate the test class with @DataJpaTest the first test passes (IDs and audit values are assigned) but the second test fails (audit values are not updated).
  • If I annotate the test class with @SpringBootTest(webEnvironment = NONE) the first test fails (ConstraintViolationException: NULL not allowed for column "parent_id"; so IDs are not assigned) but the second test passes (audit values are updated).

Do I have to split these tests into different classes with different annotations, or is there a way to keep them together and both passing? I'd also be interested to understand more about what causes these separate test failures.

jaco0646
  • 15,303
  • 7
  • 59
  • 83
  • I know Spock, but not Spring and/or JPA. I think it would be helpful for you to publish an [MCVE](https://stackoverflow.com/help/mcve) on GitHub, ideally a Maven project (Gradle, if must be). Then I could take a look, if Leonard is not faster. He knows more anyway. BTW, does the `ConstraintViolationException` go away if you actually make the `ManyToOneEntity` point to its parent for referential integrity? – kriegaex Nov 25 '21 at 10:02
  • Yes, manually assigning `child.setParent(parent)` is a workaround for the `@SpringBootTest` scenario. I will work on publishing a GitHub repo after the holiday. Thanks for taking a look. – jaco0646 Nov 25 '21 at 14:06
  • 1
    I would assume that your problem is, that the `@DataJpaTest` is annotated with `@Transactional` causing the whole test to run in a single transaction. – Leonard Brünings Nov 27 '21 at 10:17
  • @LeonardBrünings, that's a great observation: `@Transactional` seems to be the difference between the two annotations. But why does a transaction cause the second test to fail? – jaco0646 Nov 27 '21 at 14:45
  • @kriegaex, I've created a GitHub project here: https://github.com/jaco0646/jpa-audit-test. – jaco0646 Nov 27 '21 at 14:46

3 Answers3

1

Like I said, I am not a Spring user, but I noticed the following things when playing with your MCVE:

  • For @DataJpaTest, not only persisted2 == persisted1 is true, but even persisted2 === persisted1. I.e., the object is changed in-place, no new instance is created. Therefore, the check persisted2.lastModifiedDate.isAfter(persisted1.lastModifiedDate) can never work.
  • For @DataJpaTest, lastModifiedDate is never updated. Maybe that kind of test is not meant to check timestamps. I.e. that you cannot even use def lastModifiedDate = persisted1.lastModifiedDate before saving the second time and later persisted2.lastModifiedDate.isAfter(lastModifiedDate). It also fails.

So you really should use @SpringBootTest, if you wish to check timestamps like that. Then however, you need to satisfy referential integrity for your parent-child relationships. If there is an option to modify the @DataJpaTest behaviour in order to also update timestamps, I have no idea. But probably that is database functionality which is mocked away in JPA tests. Someone more experienced in Spring can maybe answer this question.


Update: Something like this should work for you:

package spring.jpa

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import spock.lang.Specification

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class AuditingSpec extends Specification {
  @Autowired
  OneToManyRepository repo

  def "New parent and child are both assigned IDs and dates"() {
    given:
    def parent = new OneToManyEntity()
    parent.setChildren([createChild(parent)])
    when:
    def persisted = repo.save(parent)
    then:
    def persistedChild = persisted.children.first()
    persisted.createdDate
    persisted.createdDate == persisted.lastModifiedDate
    persistedChild.createdDate
    persistedChild.createdDate == persistedChild.lastModifiedDate
  }

  def "Appended child is assigned IDs and dates"() {
    given:
    def parent = new OneToManyEntity()
    parent.setChildren([createChild(parent)])
    def persisted = repo.save(parent)
    persisted.children.add(createChild(parent))
    when:
    def persisted2 = repo.save(persisted)
    then:
    persisted2.children.size() == 2
    def firstChild = persisted2.children.first()
    def secondChild = persisted2.children.last()
    secondChild.id > firstChild.id
    secondChild.createdDate
    secondChild.createdDate == secondChild.lastModifiedDate
  }

  def "LastModifiedDate value is updated"() {
    given:
    def persisted1 = repo.save(new OneToManyEntity())
    //sleep(1000)
    persisted1.setChildren([])
    def persisted2 = repo.save(persisted1)
    expect:
    persisted2.lastModifiedDate.isAfter(persisted1.lastModifiedDate)
  }

  static ManyToOneEntity createChild(OneToManyEntity parent) {
    def child = new ManyToOneEntity()
    child.setParent(parent)
    child
  }
}
kriegaex
  • 63,017
  • 15
  • 111
  • 202
  • Leonard's comment above seems to be on the right track. `@DataJpaTest` was a red herring: it changes the test behavior only because it is meta-annotated with `@Transactional`. So the two different testing behaviors can both be observed using `@SpringBootTest` by combining it with `@Transactional`, or not. I think it may be the expected behavior of `@LastModifiedDate` to only be updated by changes in separate transactions, while multiple changes in a single transaction are not recorded as individual modifications. – jaco0646 Nov 29 '21 at 21:24
0

Thanks to the tips in the comments, I've realized the two tests fail in opposite scenarios because the first test must run in a transaction, while the second test must not run in a transaction. The test failures manifested with different class-level annotations because @DataJpaTest is transactional while @SpringBootTest is not. So the solution is to use @SpringBootTest and annotate only the first test as @Transactional. I have updated the GitHub project accordingly.

jaco0646
  • 15,303
  • 7
  • 59
  • 83
  • "_the first test **must** run in a transaction, while the second test **must not** run in a transaction"_ - It would have been nice if you had mentioned that before. So basically you use my solution plus one more `@Transactional` annotation. Was that worth an extra answer, self-accepting it instead of mine? – kriegaex Nov 30 '21 at 07:04
  • BTW, even though the last of the 3 feature methods must not run in a (single) transaction, the other two also pass without being transactional. They **should** be transactional, but by no means have to. If we would want to do it 100% right, the test saving twice should also open 2 separate transactions, because having zero children is but a special case of one-to-many. – kriegaex Nov 30 '21 at 07:57
  • At the time I posed the question I did not understand the implication of transactions on the different tests. Leonard pointed it out, and I still don't understand it 100%. But that's why I didn't mention it: it's something I've learned since. The first tests must be transactional as written in the question. Modifying the tests to explicitly set the parent reference is not an acceptable solution when Spring is capable of setting that reference automatically. This accepted answer allows the tests to pass in their original form from the OP. – jaco0646 Nov 30 '21 at 14:17
  • 1
    _"The first tests must be transactional as written in the question._" That is not written anywhere in the question. The term transaction is absent from the text. Otherwise I would not have bothered writing may answer the way I did. But be it as it may, I am happy you solved your problem, and I can live with having wasted some time on cloning your repository and making your tests run in a way I did not know would feel suboptimal or unacceptable to you. We both learned something here, so it is fine. – kriegaex Nov 30 '21 at 18:05
0

for anybody who has a problem with the scenario f.e. @CreatedDate is not generated after storing in DB in junit test. Just use @DataJpaTest together with @EnableJpaAuditing and your @CreatedDate and other generated fields will start to work also in junit tests. What was missing in my case was @EnableJpaAuditing annotation. @DataJpaTest has be default not enabled jpa auditing so with annotation @EnableJpaAuditing you will enable it. Good luck :)

Marko
  • 101
  • 6