Issue
I have developed a test project to reproduce this issue.
This is a 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?
- 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 - I tried to add
spring.cache.type=none
to theapplication.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
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.
Solution
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:
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.
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:
- Data Access
- Transaction Propagation and Isolation in Spring @Transactional
- Transaction Propagation with illustrations
- Hibernate Caching - First Level Cach
Answered By - Дмитрий Бабанин
Answer Checked By - David Goodson (JavaFixing Volunteer)