-1

I have developed a test project to reproduce this issue.

This is a project structure:

The project structure

pom.xml file:

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.1</version>
        <relativePath/>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.example</groupId>
    <artifactId>value-updated-after-fail-spring</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </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-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>
    </dependencies>

</project>

Persone.java file:

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@RequiredArgsConstructor
@AllArgsConstructor
@Builder
@Getter
@Setter
@ToString
@FieldDefaults(level = AccessLevel.PRIVATE)
public class Person {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    Long id;

    @Column(nullable = false)
    @NonNull
    String name;
}

PersonRepository.java file:

@Repository
public interface PersonRepository extends JpaRepository<Person, Long> {}

PersonService.java file:

@Component
public class PersonService {

    private final PersonRepository repository;

    public PersonService(PersonRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public Person create(String name) {
        return repository.save(new Person(name));
    }

    @Transactional
    public Person save(Person person) {
        if(StringUtils.isBlank(person.getName())) {
            throw new RuntimeException();
        }
        Person personFromDB = getById(person.getId());
        personFromDB.setName(person.getName());
        return repository.save(personFromDB);
    }

    @Transactional
    public Person getById(Long id) {
        return repository.findById(id)
                .orElseThrow(NullPointerException::new);
    }

    @Transactional
    public void deleteAll() {
        repository.deleteAll();
    }
}

application.properties file:

spring.datasource.url=jdbc:h2:mem:test;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.h2.console.enabled=true
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect

TestApplicationConfiguration.java file

@SpringBootConfiguration
@EnableAutoConfiguration
@EnableJpaRepositories
@EntityScan("net.example.model")
@ComponentScan(basePackages = "net.example")
public class TestApplicationConfiguration {}

PersonServiceTest.java file:

@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
class PersonServiceTest {

    @Autowired
    private PersonService service;

    @AfterEach
    void tearDownEach() {
        service.deleteAll();
    }

    @Test
    void rename() {
        String expected = "name";
        Person person = service.create(expected);
        Person personFromDB = service.getById(person.getId());
        personFromDB.setName("");
        assertThrows(RuntimeException.class, () -> service.save(personFromDB));
        assertEquals(expected, service.getById(personFromDB.getId()).getName());
    }
}

The issue: Last assertion fails

org.opentest4j.AssertionFailedError:
Expected :name
Actual   :

What I already tried to fix this?

  1. I tried to remove the @Transactional annotation for the PersonService#getById method to avoid getting the entity from the cache. - This didn't fix the issue
  2. I tried to add spring.cache.type=none to the application.properties file to disable the cache. - This didn't fix the issue

Why do I think it's the cache?

When I debugged this, I found that the PersonService#getById() method doesn't return actual data, but the method returns a cached object with a changed title. The database isn't changed after calling the PersonService#save method because it throws an exception enter image description here

Perhaps I'm not developing the tests correctly. Maybe I should change the method of saving changed data.

Please share best practices and articles to better understand how to update data and how to properly configure and write tests for Spring Boot applications.

  • I'm not sure why you believe `person` and `personFromDB` cannot be references to the same object. The repository is under no obligation to fetch a new copy if it believes its cached copy represents the current state of the database, which in this case it does. – Jim Garrison Sep 01 '22 at 20:41
  • In this case, the database isn't changed after calling the ```PersonService#save()``` method because an exception is thrown because the title is blank. I expect that when the system calls the ```PersonService#getById()``` method, we should get the actual data since the cached data does not match the data from the database. – Дмитрий Бабанин Sep 01 '22 at 21:09
  • I fixed ```Why do I think it's the cache?``` section of the description – Дмитрий Бабанин Sep 01 '22 at 21:19
  • 1
    take a look at `@DataJpaTest` annotation, there is `@Transactional` annotation over class name, that in turn means all your test methods are backed up by a single transaction, that explains why `person` and `perfonFromDB` are the same object instance - HBN picked them up from session level cache (one single transaction - one single session), that is by design. – Andrey B. Panfilov Sep 01 '22 at 21:53
  • to achieve expected result try to inject `TestEntityManager` in test class and call its `.clear()` method before calling methods where you want a DB call - this will force entity manager to detach all managed entities – Andrei Titov Sep 02 '22 at 07:13
  • @AndrewThomas Your suggestion looks like a workaround – Дмитрий Бабанин Sep 03 '22 at 15:54
  • @AndreyB.Panfilov Thank you very much for your tip, it really helped me to understand my issue and study this topic more deeply. – Дмитрий Бабанин Sep 03 '22 at 15:55

1 Answers1

2

Thanks a lot for the comment from Andrey B. Panfilov.

I investigated the @Transactional and the first level cache of Hibernate. Indeed, each test method call in a class annotated with the @DataJpaTest annotation creates, runs, and rollbacks a transaction. Each transaction creates and closes the Hibernate session. As we know, the first level cache exists until the session closes. That's why it's also called session cache.

You can see the evidence in the following screenshots:

Open new session before each test

In the first screenshot, you can see that SpringExtension, which is defined in the @DataJpaTest annotation, opens a new session before each test is called.

close the session after each test

In the second screenshot, you can see that SpringExtension closes the session after each test is called.

I decided to override the default transaction propagation: @Transactional(propagation = Propagation.NEVER) it doesn't create a transaction when the method is called and throw an exception if the method is called in an existing transaction

Links that helped me:

  1. Data Access
  2. Transaction Propagation and Isolation in Spring @Transactional
  3. Transaction Propagation with illustrations
  4. Hibernate Caching - First Level Cach