How to catch Hibernate ConstraintViolationException (or Spring DataIntegrityViolationException?) in Spring Boot with JUnit 5

SoCal :

I considered naming this “The Heisenberg Uncertainty Corollary for Java Exceptions”, but that was (a) too unwieldy, and (b) not sufficiently descriptive.

BLUF: I’m trying to catch, in a JUnit 5 test against a Spring Boot application, the exception thrown when a tuple is persisted to a database table with a constraint violation (duplicate value in column marked “unique”). I can catch the exception in try-catch block, but not using JUnit’s “assertThrows()”.

Elaboration

For ease-of-replication, I have narrowed down my code to only the entity and repository, and two tests (one works, the other is the reason for this post). Also for ease-of-replication, I am using H2 as the database.

I had read that there are potential transactional scope issues which can cause the constraint-generated exception to not be thrown within the scope of the invoking method. I confirmed this with a simple try-catch block around the statement “foos.aave(foo);” in shouldThrowExceptionOnSave() (without the “tem.flush()” statement).

I decided to use TestEntityManager.flush() to force the transaction to commit/end, and was able to successfully catch an exception in the try-catch block. However, it was not the expected DataIntegrityViolationException, but PersistenceException.

I attempted to use a similar mechanism (i.e., employ TestEntityManager.flush() to force the issue in the assertThrows() statement. But, “no joy”.

When I try “assertThrows(PersistenceException.class,…”, the method terminates with a DataIntegrityViolationException.

When I try “assertThrows(DataIntegrityViolationException.class,…”, I actually get a JUnit error message, indicating that the expected DataIntegrityViolationException didn’t match the actual exception. Which is…javax.persistence.PersistenceException!

Any help/insight would be greatly appreciated.

Add Note: The try-catch block in shouldThrowExceptionOnSave() is just to see what exception is caught.

Entity Class

package com.test.foo;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;

@Entity
public class Foo {

    @Id
    @Column(name     = "id",
            nullable = false,
            unique   = true)
    private String id;
    @Column(name     = "name",
            nullable = false,
            unique   = true)
    private String name;

    public Foo() {
        id   = "Default ID";
        name = "Default Name";
    }

    public Foo(String id, String name) {
        this.id   = id;
        this.name = name;
    }

    public String getId() { return id;}

    public void setName(String name) { this.name = name; }

    public String getName() { return name; }
}

Repository Interface

package com.test.foo;

import org.springframework.data.repository.CrudRepository;

public interface FooRepository extends CrudRepository<Foo, String> { }

Repository Test Class

package com.test.foo;

import org.hibernate.exception.ConstraintViolationException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.dao.DataIntegrityViolationException;

import javax.persistence.PersistenceException;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

@DataJpaTest
public class FooRepositoryITest {

    @Autowired
    private TestEntityManager tem;

    @Autowired
    private FooRepository foos;

    private static final int    NUM_ROWS  = 25;
    private static final String BASE_ID   = "->Test Id";
    private static final String BASE_NAME = "->Test Name";

    @BeforeEach
    public void insertFooTuples() {
        Foo foo;

        for (int i=0; i<NUM_ROWS; i++) {
            foo = new Foo(i+BASE_ID, i+BASE_NAME);
            tem.persist(foo);
        }
        tem.flush();
    }

    @AfterEach
    public void removeFooTuples() {
        foos.findAll()
                .forEach(tem::remove);
        tem.flush();
    }

    @Test
    public void shouldSaveNewTyple() {
        Optional<Foo> newFoo;
        String        newId   = "New Test Id";
        String        newName = "New Test Name";
        Foo           foo     = new Foo(newId, newName);

        foos.save(foo);
        tem.flush();

        newFoo = foos.findById(newId);
        assertTrue(newFoo.isPresent(), "Failed to add Foo tuple");
    }

    @Test
    public void shouldThrowExceptionOnSave() {
        Optional<Foo> newFoo;
        String        newId   = "New Test Id";
        String        newName = "New Test Name";
        Foo           foo     = new Foo(newId, newName);

        foo.setName(foos.findById(1+BASE_ID).get().getName());

        try {
            foos.save(foo);
            tem.flush();
        } catch(PersistenceException e) {
            System.out.println("\n\n**** IN CATCH BLOCK ****\n\n");
            System.out.println(e.toString());
        }

//        assertThrows(DataIntegrityViolationException.class,
//        assertThrows(ConstraintViolationException.class,
        assertThrows(PersistenceException.class,
                () -> { foos.save(foo);
                        tem.flush();
                      } );
    }
}

build.gradle

plugins {
    id 'org.springframework.boot' version '2.1.3.RELEASE'
    id 'java'
}

apply plugin: 'io.spring.dependency-management'

group = 'com.test'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

repositories {
    mavenCentral()
}

dependencies {
    implementation('org.springframework.boot:spring-boot-starter-data-jpa')
    implementation('org.springframework.boot:spring-boot-starter-web')
    runtimeOnly('com.h2database:h2')
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'junit'
        exclude group: 'org.hamcrest'
    }
    testImplementation('org.junit.jupiter:junit-jupiter:5.4.0')
    testImplementation('com.h2database:h2')
}

test {
    useJUnitPlatform()
}

Output with "assertThrows(PersitenceException, ...)"

2019-02-25 14:55:12.747  WARN 15796 --- [           main] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 23505, SQLState: 23505
2019-02-25 14:55:12.747 ERROR 15796 --- [           main] o.h.engine.jdbc.spi.SqlExceptionHelper   : Unique index or primary key violation: "UK_A7S9IMMDPCXHLN2D4JHLAY516_INDEX_1 ON PUBLIC.FOO(NAME) VALUES ('1->Test Name', 2)"; SQL statement:
insert into foo (name, id) values (?, ?) [23505-197]

**** IN CATCH BLOCK ****

javax.persistence.PersistenceException: org.hibernate.exception.ConstraintViolationException: could not execute statement
.
. (some debug output removed for brevity)
.
2019-02-25 14:55:12.869  WARN 15796 --- [           main] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 23505, SQLState: 23505
2019-02-25 14:55:12.869 ERROR 15796 --- [           main] o.h.engine.jdbc.spi.SqlExceptionHelper   : Unique index or primary key violation: "UK_A7S9IMMDPCXHLN2D4JHLAY516_INDEX_1 ON PUBLIC.FOO(NAME) VALUES ('1->Test Name', 2)"; SQL statement:
insert into foo (name, id) values (?, ?) [23505-197]
2019-02-25 14:55:12.877  INFO 15796 --- [           main] o.s.t.c.transaction.TransactionContext   : Rolled back transaction for test: [DefaultTestContext@313ac989 testClass = FooRepositoryITest, testInstance = com.test.foo.FooRepositoryITest@71d44a3, testMethod = shouldThrowExceptionOnSave@FooRepositoryITest, testException = org.springframework.dao.DataIntegrityViolationException: could not execute statement; SQL [n/a]; constraint ["UK_A7S9IMMDPCXHLN2D4JHLAY516_INDEX_1 ON PUBLIC.FOO(NAME) VALUES ('1->Test Name', 2)"; SQL statement:
insert into foo (name, id) values (?, ?) [23505-197]]; nested exception is org.hibernate.exception.ConstraintViolationException: could not execute statement, mergedContextConfiguration = [MergedContextConfiguration@4562e04d testClass = FooRepositoryITest, locations = '{}', classes = '{class com.test.foo.FooApplication}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.autoconfigure.OverrideAutoConfigurationContextCustomizerFactory$DisableAutoConfigurationContextCustomizer@527e5409, org.springframework.boot.test.autoconfigure.filter.TypeExcludeFiltersContextCustomizer@351584c0, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@8b41920b, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@2a32de6c, [ImportsContextCustomizer@2a65fe7c key = [org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration, org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration, org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration, org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration, org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration, org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration, org.springframework.boot.test.autoconfigure.jdbc.TestDatabaseAutoConfiguration, org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManagerAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@147ed70f, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@15b204a1, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0], contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map[[empty]]]

org.springframework.dao.DataIntegrityViolationException: could not execute statement; SQL [n/a]; constraint ["UK_A7S9IMMDPCXHLN2D4JHLAY516_INDEX_1 ON PUBLIC.FOO(NAME) VALUES ('1->Test Name', 2)"; SQL statement:
insert into foo (name, id) values (?, ?) [23505-197]]; nested exception is org.hibernate.exception.ConstraintViolationException: could not execute statement

Output with "assertThrows(DataIntegrityViolationException, ...)

2019-02-25 14:52:16.880  WARN 2172 --- [           main] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 23505, SQLState: 23505
2019-02-25 14:52:16.880 ERROR 2172 --- [           main] o.h.engine.jdbc.spi.SqlExceptionHelper   : Unique index or primary key violation: "UK_A7S9IMMDPCXHLN2D4JHLAY516_INDEX_1 ON PUBLIC.FOO(NAME) VALUES ('1->Test Name', 2)"; SQL statement:
insert into foo (name, id) values (?, ?) [23505-197]

**** IN CATCH BLOCK ****

javax.persistence.PersistenceException: org.hibernate.exception.ConstraintViolationException: could not execute statement
.
. (some debug output removed for brevity)
.
insert into foo (name, id) values (?, ?) [23505-197]
2019-02-25 14:52:16.974  INFO 2172 --- [           main] o.s.t.c.transaction.TransactionContext   : Rolled back transaction for test: [DefaultTestContext@313ac989 testClass = FooRepositoryITest, testInstance = com.test.foo.FooRepositoryITest@71d44a3, testMethod = shouldThrowExceptionOnSave@FooRepositoryITest, testException = org.opentest4j.AssertionFailedError: Unexpected exception type thrown ==> expected: <org.springframework.dao.DataIntegrityViolationException> but was: <javax.persistence.PersistenceException>, mergedContextConfiguration = [MergedContextConfiguration@4562e04d testClass = FooRepositoryITest, locations = '{}', classes = '{class com.test.foo.FooApplication}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.autoconfigure.OverrideAutoConfigurationContextCustomizerFactory$DisableAutoConfigurationContextCustomizer@527e5409, org.springframework.boot.test.autoconfigure.filter.TypeExcludeFiltersContextCustomizer@351584c0, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@8b41920b, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@2a32de6c, [ImportsContextCustomizer@2a65fe7c key = [org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration, org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration, org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration, org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration, org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration, org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration, org.springframework.boot.test.autoconfigure.jdbc.TestDatabaseAutoConfiguration, org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManagerAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@147ed70f, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@15b204a1, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0], contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map[[empty]]]

org.opentest4j.AssertionFailedError: Unexpected exception type thrown ==> 
Expected :<org.springframework.dao.DataIntegrityViolationException> 
Actual   :<javax.persistence.PersistenceException>
<Click to see difference>
Sam Brannen :

Side Notes

Your project actually does not use JUnit Jupiter 5.4. Rather, it's using JUnit Jupiter 5.3.2 as managed by Spring Boot. See Gradle 5 JUnit BOM and Spring Boot Incorrect Versions for the solution.

There is no need to flush() in your @BeforeEach method.

You should remove your @AfterEach method since all changes to the database will be rolled back automatically with the test-managed transaction.

Catching a ConstraintViolationException

You actually cannot catch the ConstraintViolationException since JPA will wrap it an a PersistenceException, but you can verify that a ConstraintViolationException caused the PersistenceException.

To do that, simply rewrite your test as follows.

@Test
public void shouldThrowExceptionOnSave() {
    String newId = "New Test Id";
    String newName = "New Test Name";
    Foo foo = new Foo(newId, newName);

    foo.setName(fooRepository.findById(1 + BASE_ID).get().getName());

    PersistenceException exception = assertThrows(PersistenceException.class, () -> {
        fooRepository.save(foo);
        testEntityManager.flush();
    });

    assertTrue(exception.getCause() instanceof ConstraintViolationException);
}

Catching a DataIntegrityViolationException

If you want to catch an exception from Spring's DataAccessException hierarchy — such as DataIntegrityViolationException, you have to ensure that the EntityManager#flush() method is invoked in such a way that Spring performs exception translation.

Exception translation is performed via Spring's PersistenceExceptionTranslationPostProcessor which wraps your @Repository bean in a proxy in order to catch the exceptions and translate them. Spring Boot registers the PersistenceExceptionTranslationPostProcessor for you automatically and ensures that your Spring Data JPA repositories are properly proxied.

In your example, you are invoking flush() directly on Spring Boot's TestEntityManager which does not perform exception translation. That is why you see the raw javax.persistence.PersistenceException instead of Spring's DataIntegrityViolationException.

If you want to assert that Spring will wrap the PersistenceException in a DataIntegrityViolationException, you need to do the following.

  1. Redeclare your repository as follows. JpaRepository gives you access to the flush() method directly on your repository.

    public interface FooRepository extends JpaRepository<Foo, String> {}

  2. In your shouldThrowExceptionOnSave() test method, invoke fooRepository.save(foo); fooRepository.flush(); or fooRepository.saveAndFlush(foo);.

If you do so, the following will now pass.

@Test
public void shouldThrowExceptionOnSave() {
    String newId = "New Test Id";
    String newName = "New Test Name";
    Foo foo = new Foo(newId, newName);

    foo.setName(fooRepository.findById(1 + BASE_ID).get().getName());

    assertThrows(DataIntegrityViolationException.class, () -> {
        fooRepository.save(foo);
        fooRepository.flush();
        // fooRepository.saveAndFlush(foo);
    });
}

Again, the reason this works is that the flush() method is now invoked directly on your repository bean which Spring has wrapped in a proxy that catches the PersistenceException and translates it into a DataIntegrityViolationException.

Guess you like

Origin http://43.154.161.224:23101/article/api/json?id=78714&siteId=1