Java >> Java Tutorial >  >> Tag >> Spring

Spring From the Trenches:Zurücksetzen der automatischen Inkrementspalten vor jeder Testmethode

Wenn wir Integrationstests für eine Funktion schreiben, die Informationen in der Datenbank speichert, müssen wir überprüfen, ob die richtigen Informationen in der Datenbank gespeichert werden.

Wenn unsere Anwendung Spring Framework verwendet, können wir zu diesem Zweck Spring Test DbUnit und DbUnit verwenden.

Es ist jedoch sehr schwer zu überprüfen, ob der richtige Wert in die Primärschlüsselspalte eingefügt wird, da Primärschlüssel normalerweise automatisch generiert werden, indem entweder die automatische Inkrementierung oder eine Sequenz verwendet wird.

Dieser Blogbeitrag identifiziert das Problem im Zusammenhang mit den Spalten, deren Werte automatisch generiert werden, und hilft uns, es zu lösen.

Wir können das Unbekannte nicht behaupten

Beginnen wir damit, zwei Integrationstests für save() zu schreiben Methode des CrudRepository Schnittstelle. Diese Tests werden im Folgenden beschrieben:

  • Der erste Test stellt sicher, dass die richtigen Informationen in der Datenbank gespeichert werden, wenn der Titel und die Beschreibung der gespeicherten Todo Objekt gesetzt.
  • Der zweite Test überprüft, ob die richtigen Informationen in der Datenbank gespeichert werden, wenn nur der Titel der gespeicherten Todo Objekt gesetzt.

Beide Tests initialisieren die verwendete Datenbank mit demselben DbUnit-Datensatz (no-todo-entries.xml ), die wie folgt aussieht:

<dataset>
    <todos/>
</dataset>

Der Quellcode unserer Integrationstestklasse sieht wie folgt aus:

import com.github.springtestdbunit.DbUnitTestExecutionListener;
import com.github.springtestdbunit.annotation.DatabaseSetup;
import com.github.springtestdbunit.annotation.DbUnitConfiguration;
import com.github.springtestdbunit.annotation.ExpectedDatabase;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
import org.springframework.test.context.support.DirtiesContextTestExecutionListener;
import org.springframework.test.context.transaction.TransactionalTestExecutionListener;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {PersistenceContext.class})
@TestExecutionListeners({ DependencyInjectionTestExecutionListener.class,
        DirtiesContextTestExecutionListener.class,
        TransactionalTestExecutionListener.class,
        DbUnitTestExecutionListener.class })
@DbUnitConfiguration(dataSetLoader = ColumnSensingReplacementDataSetLoader.class)
public class ITTodoRepositoryTest {

    private static final Long ID = 2L;
    private static final String DESCRIPTION = "description";
    private static final String TITLE = "title";
    private static final long VERSION = 0L;

    @Autowired
    private TodoRepository repository;

    @Test
    @DatabaseSetup("no-todo-entries.xml")
    @ExpectedDatabase("save-todo-entry-with-title-and-description-expected.xml")
    public void save_WithTitleAndDescription_ShouldSaveTodoEntryToDatabase() {
        Todo todoEntry = Todo.getBuilder()
                .title(TITLE)
                .description(DESCRIPTION)
                .build();

        repository.save(todoEntry);
    }

    @Test
    @DatabaseSetup("no-todo-entries.xml")
    @ExpectedDatabase("save-todo-entry-without-description-expected.xml")
    public void save_WithoutDescription_ShouldSaveTodoEntryToDatabase() {
        Todo todoEntry = Todo.getBuilder()
                .title(TITLE)
                .description(null)
                .build();

        repository.save(todoEntry);
    }
}
Dies sind keine sehr guten Integrationstests, da sie nur testen, ob Spring Data JPA und Hibernate ordnungsgemäß funktionieren. Wir sollten unsere Zeit nicht damit verschwenden, Tests für Frameworks zu schreiben. Wenn wir einem Framework nicht vertrauen, sollten wir es nicht verwenden.

Wenn Sie lernen möchten, gute Integrationstests für Ihren Datenzugriffscode zu schreiben, sollten Sie mein Tutorial mit dem Titel lesen:Tests für Datenzugriffscode schreiben.

Das DbUnit-Dataset (save-todo-entry-with-title-and-description-expected.xml ), die verwendet wird, um zu überprüfen, ob der Titel und die Beschreibung der gespeicherten Todo Objekt werden in die todos eingefügt Tabelle sieht wie folgt aus:

<dataset>
    <todos id="1" description="description" title="title" version="0"/>
</dataset>

Der DbUnit-Datensatz (save-todo-entry-within-description-expected.xml ), die verwendet wird, um zu überprüfen, ob nur der Titel der gespeicherten Todo Objekt wird in die todos eingefügt Tabelle sieht wie folgt aus:

<dataset>
    <todos id="1" description="[null]" title="title" version="0"/>
</dataset>

Wenn wir unsere Integrationstests ausführen, schlägt einer von ihnen fehl und wir sehen die folgende Fehlermeldung:

junit.framework.ComparisonFailure: value (table=todos, row=0, col=id) 
Expected :1
Actual   :2

Der Grund dafür ist, dass die id Spalte der Aufgaben Tabelle ist eine Auto-Increment-Spalte, und der zuerst aufgerufene Integrationstest "erhält" die ID 1. Wenn der zweite Integrationstest aufgerufen wird, wird der Wert 2 in id gespeichert Spalte und der Test schlägt fehl.

Lassen Sie uns herausfinden, wie wir dieses Problem lösen können.

Schnelle Lösungen für den Sieg?

Es gibt zwei schnelle Lösungen für unser Problem. Diese Korrekturen werden im Folgenden beschrieben:

Zuerst , könnten wir die Testklasse mit @DirtiesContext kommentieren -Anmerkung und legen Sie den Wert ihres classMode fest Attribut zu DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD . Dies würde unser Problem beheben, da unsere Anwendung eine neue In-Memory-Datenbank erstellt, wenn ihr Anwendungskontext geladen wird, und der @DirtiesContext Annotation stellt sicher, dass jede Testmethode einen neuen Anwendungskontext verwendet.

Die Konfiguration unserer Testklasse sieht wie folgt aus:

import com.github.springtestdbunit.DbUnitTestExecutionListener;
import com.github.springtestdbunit.annotation.DatabaseSetup;
import com.github.springtestdbunit.annotation.DbUnitConfiguration;
import com.github.springtestdbunit.annotation.ExpectedDatabase;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
import org.springframework.test.context.support.DirtiesContextTestExecutionListener;
import org.springframework.test.context.transaction.TransactionalTestExecutionListener;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {PersistenceContext.class})
@TestExecutionListeners({ DependencyInjectionTestExecutionListener.class,
        DirtiesContextTestExecutionListener.class,
        TransactionalTestExecutionListener.class,
        DbUnitTestExecutionListener.class })
@DbUnitConfiguration(dataSetLoader = ColumnSensingReplacementDataSetLoader.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
public class ITTodoRepositoryTest {

}

Dies sieht sauber aus, kann aber leider die Leistung unserer Integrationstestsuite zerstören, da es einen neuen Anwendungskontext erstellt, bevor jede Testmethode aufgerufen wird. Deshalb sollten wir @DirtiesContext nicht verwenden Anmerkung, es sei denn, es ist UNBEDINGT ERFORDERLICH .

Wenn unsere Anwendung jedoch nur über eine geringe Anzahl von Integrationstests verfügt, wird die Leistungseinbuße durch @DirtiesContext Anmerkung könnte tolerierbar sein. Wir sollten diese Lösung nicht aufgeben, nur weil sie unsere Tests langsamer macht. Manchmal ist dies akzeptabel, und wenn dies der Fall ist, verwenden Sie @DirtiesContext Anmerkungen sind eine gute Lösung.

Zweite , könnten wir die id weglassen Attribut der todos -Element aus unseren Datensätzen und legen Sie den Wert von @ExpectedDatabase fest assertionMode der Anmerkung Attribut zu DatabaseAssertionMode.NON_STRICT . Dies würde unser Problem beheben, da der DatabaseAssertionMode.NON_STRICT bedeutet, dass Spalten und Tabellen, die nicht in unserer Datensatzdatei vorhanden sind, ignoriert werden.

Dieser Zusicherungsmodus ist ein nützliches Werkzeug, da er uns die Möglichkeit gibt, Tabellen zu ignorieren, deren Informationen durch den getesteten Code nicht geändert werden. Der DatabaseAssertionMode.NON_STRICT ist nicht das richtige Werkzeug, um dieses spezielle Problem zu lösen, weil es uns dazu zwingt, Datensätze zu schreiben, die zu wenige Dinge verifizieren.

Beispielsweise können wir den folgenden Datensatz nicht verwenden:

<dataset>
	<todos id="1" description="description" title="title" version="0"/>
	<todos description="description two" title="title two" version="0"/>
</dataset>

Wenn wir den DatabaseAssertionMode.NON_STRICT verwenden , muss jede "Zeile" unseres Datensatzes dieselben Spalten angeben. Mit anderen Worten, wir müssen unseren Datensatz so ändern, dass er folgendermaßen aussieht:

<dataset>
	<todos description="description" title="title" version="0"/>
	<todos description="description two" title="title two" version="0"/>
</dataset>

Das ist keine große Sache, weil wir darauf vertrauen können, dass Hibernate die richtige ID in die id einfügt Spalte der Aufgaben Tisch.

Wenn jedoch jeder Todo-Eintrag 0..*-Tags haben könnte, würden wir in Schwierigkeiten geraten. Nehmen wir an, wir müssen einen Integrationstest schreiben, der zwei neue Todo-Einträge in die Datenbank einfügt, und einen DbUnit-Datensatz erstellen, der dafür sorgt, dass

  • Der Todo-Eintrag mit dem Titel:'title one' hat ein Tag namens:'tag one'
  • Der Todo-Eintrag mit dem Titel:'title two' hat ein Tag namens:'tag two'

Unsere Bemühungen sehen wie folgt aus:

<dataset>
	<todos description="description" title="title one" version="0"/>
	<todos description="description two" title="title two" version="0"/>
	
	<tags name="tag one" version="0"/>
	<tags name="tag two" version="0"/>
</dataset>

Wir können keinen nützlichen DbUnit-Datensatz erstellen, da wir die IDs der Aufgabeneinträge nicht kennen, die in der Datenbank gespeichert sind.

Wir müssen eine bessere Lösung finden.

Eine bessere Lösung finden

Wir haben bereits zwei verschiedene Lösungen für unser Problem gefunden, aber beide schaffen neue Probleme. Es gibt eine dritte Lösung, die auf der folgenden Idee basiert:

Wenn wir den nächsten Wert nicht kennen, der in eine Auto-Increment-Spalte eingefügt wird, müssen wir die Auto-Increment-Spalte zurücksetzen, bevor jede Testmethode aufgerufen wird.

Wir können dies tun, indem wir diesen Schritten folgen:

  1. Erstellen Sie eine Klasse, die verwendet wird, um die Auto-Increment-Spalten der angegebenen Datenbanktabellen zurückzusetzen.
  2. Beheben Sie unsere Integrationstests.

Machen wir uns die Hände schmutzig.

Erstellen der Klasse, die Spalten mit automatischer Erhöhung zurücksetzen kann

Wir können die Klasse erstellen, die die Spalten mit automatischer Erhöhung der angegebenen Datenbanktabellen zurücksetzen kann, indem wir diesen Schritten folgen:

  1. Erstellen Sie ein Finale Klasse namens DbTestUtil und verhindern Sie seine Instanziierung, indem Sie ihm einen privaten Konstruktor hinzufügen.
  2. Fügen Sie ein öffentliches statisches void resetAutoIncrementColumns() hinzu -Methode zum DbTestUtil Klasse. Diese Methode benötigt zwei Methodenparameter:
    1. Der ApplicationContext Objekt enthält die Konfiguration der getesteten Anwendung.
    2. Die Namen der Datenbanktabellen, deren Auto-Increment-Spalten zurückgesetzt werden müssen.
  3. Implementieren Sie diese Methode, indem Sie diesen Schritten folgen:
    1. Erhalten Sie einen Verweis auf die Datenquelle Objekt.
    2. Lesen Sie die SQL-Vorlage aus der Eigenschaftendatei (application.properties ) mit dem Schlüssel 'test.reset.sql.template'.
    3. Öffnen Sie eine Datenbankverbindung.
    4. Erstellen Sie die aufgerufenen SQL-Anweisungen und rufen Sie sie auf.

Der Quellcode von DbTestUtil Klasse sieht wie folgt aus:

import org.springframework.context.ApplicationContext;
import org.springframework.core.env.Environment;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;

public final class DbTestUtil {

    private DbTestUtil() {}

    public static void resetAutoIncrementColumns(ApplicationContext applicationContext,
                                                 String... tableNames) throws SQLException {
        DataSource dataSource = applicationContext.getBean(DataSource.class);
        String resetSqlTemplate = getResetSqlTemplate(applicationContext);
        try (Connection dbConnection = dataSource.getConnection()) {
            //Create SQL statements that reset the auto increment columns and invoke 
            //the created SQL statements.
            for (String resetSqlArgument: tableNames) {
                try (Statement statement = dbConnection.createStatement()) {
                    String resetSql = String.format(resetSqlTemplate, resetSqlArgument);
                    statement.execute(resetSql);
                }
            }
        }
    }

    private static String getResetSqlTemplate(ApplicationContext applicationContext) {
        //Read the SQL template from the properties file
        Environment environment = applicationContext.getBean(Environment.class);
        return environment.getRequiredProperty("test.reset.sql.template");
    }
}

Lassen Sie uns weitermachen und herausfinden, wie wir diese Klasse in unseren Integrationstests verwenden können.

Behebung unserer Integrationstests

Wir können unsere Integrationstests reparieren, indem wir diesen Schritten folgen:

  1. Fügen Sie die Reset-SQL-Vorlage zur Eigenschaftendatei unserer Beispielanwendung hinzu.
  2. Setzen Sie die Spalte für die automatische Erhöhung zurück (id ) der Aufgaben Tabelle, bevor unsere Testmethoden aufgerufen werden.

Zuerst , müssen wir die Reset-SQL-Vorlage zur Eigenschaftendatei unserer Beispielanwendung hinzufügen. Diese Vorlage muss das Format verwenden, das von format() unterstützt wird Methode des String Klasse. Da unsere Beispielanwendung die In-Memory-Datenbank von H2 verwendet, müssen wir die folgende SQL-Vorlage zu unserer Eigenschaftendatei hinzufügen:

test.reset.sql.template=ALTER TABLE %s ALTER COLUMN id RESTART WITH 1

Zweite , müssen wir die Auto-Increment-Spalte zurücksetzen (id ) der Aufgaben Tabelle, bevor unsere Testmethoden aufgerufen werden. Wir können dies tun, indem wir die folgenden Änderungen am ITTodoRepositoryTest vornehmen Klasse:

  1. Fügen Sie den ApplicationContext ein Objekt, das die Konfiguration unserer Beispielanwendung enthält, in die Testklasse.
  2. Setzen Sie die Auto-Inkrement-Spalte der Aufgaben zurück Tabelle.

Der Quellcode unserer festen Integrationstestklasse sieht wie folgt aus (die Änderungen sind hervorgehoben):

import com.github.springtestdbunit.DbUnitTestExecutionListener;
import com.github.springtestdbunit.annotation.DatabaseSetup;
import com.github.springtestdbunit.annotation.DbUnitConfiguration;
import com.github.springtestdbunit.annotation.ExpectedDatabase;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
import org.springframework.test.context.support.DirtiesContextTestExecutionListener;
import org.springframework.test.context.transaction.TransactionalTestExecutionListener;

import java.sql.SQLException;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {PersistenceContext.class})
@TestExecutionListeners({ DependencyInjectionTestExecutionListener.class,
        DirtiesContextTestExecutionListener.class,
        TransactionalTestExecutionListener.class,
        DbUnitTestExecutionListener.class })
@DbUnitConfiguration(dataSetLoader = ColumnSensingReplacementDataSetLoader.class)
public class ITTodoRepositoryTest {

    private static final Long ID = 2L;
    private static final String DESCRIPTION = "description";
    private static final String TITLE = "title";
    private static final long VERSION = 0L;

    @Autowired
    private ApplicationContext applicationContext;

    @Autowired
    private TodoRepository repository;

    @Before
    public void setUp() throws SQLException {
        DbTestUtil.resetAutoIncrementColumns(applicationContext, "todos");
    }

    @Test
    @DatabaseSetup("no-todo-entries.xml")
    @ExpectedDatabase("save-todo-entry-with-title-and-description-expected.xml")
    public void save_WithTitleAndDescription_ShouldSaveTodoEntryToDatabase() {
        Todo todoEntry = Todo.getBuilder()
                .title(TITLE)
                .description(DESCRIPTION)
                .build();

        repository.save(todoEntry);
    }

    @Test
    @DatabaseSetup("no-todo-entries.xml")
    @ExpectedDatabase("save-todo-entry-without-description-expected.xml")
    public void save_WithoutDescription_ShouldSaveTodoEntryToDatabase() {
        Todo todoEntry = Todo.getBuilder()
                .title(TITLE)
                .description(null)
                .build();

        repository.save(todoEntry);
    }
}

Wenn wir unsere Integrationstests zum zweiten Mal ausführen, bestehen sie.

Fahren wir fort und fassen zusammen, was wir aus diesem Blogbeitrag gelernt haben.

Zusammenfassung

Dieser Blogbeitrag hat uns drei Dinge gelehrt:

  • Wir können keine nützlichen Integrationstests schreiben, wenn wir die Werte nicht kennen, die in Spalten eingefügt werden, deren Werte automatisch generiert werden.
  • Verwendung von @DirtiesContext Annotation könnte eine gute Wahl sein, wenn unsere Anwendung nicht viele Integrationstests hat.
  • Wenn unsere Anwendung viele Integrationstests hat, müssen wir die Auto-Increment-Spalten zurücksetzen, bevor jede Testmethode aufgerufen wird.

Java-Tag