Perzisztencia réteg tesztelése DbUnittal

Frissítve: 2019. július 17.

Felhasznált technológiák: DbUnit 2.6.0, Hibernate 5.4.3, HSQLDB 2.5.0, SLF4J 1.7.26, JUnit 5, Maven 3

Gyakran hajlamosak vagyunk a perzisztens rétegre úgy tekinteni (pl. Data Access Object - DAO Java EE tervezési minta), mint egy szükséges, magától érthetődő elemre, azonban ennek tesztelését sem szabad elfelejteni, különösen ha adatintenzív alkalmazásról, vagy egy egyszerű CRUD (create, read, update, delete) felületről van szó.

Azonban ez már nem a unit tesztelés témakörébe tartozik, hiszen itt nem magát a DAO osztály logikáját teszteljük, hanem annak hatását az adatbázisra. Így már legalább két komponens vesz részt aktívan a tesztelésben. Ezen kívül az adatbázis kapcsolatot is fel kell építeni, ami egy webes, vagy Java EE alkalmazás esetén JNDI-ből vett DataSource, vagy JPA esetén egy konténer által (alkalmazásszerver, vagy Spring Framework) injektált EntityManager példány. Egyszerű teszteset írásakor azonban nincs sem JNDI, sem dependency injection, ezért fejlettebb eszközökhöz kell fordulnunk.

A perzisztens réteg tesztelésénél ugyanazt a módszert használjuk, mint általában a tesztelésnél, inicializáljuk az input adatokat, meghívjuk a tesztelendő kódot, majd ellenőrizzük az adatokat. Ezzel kapcsolatban több probléma is felmerül a perzisztencia esetén. Első, hogy az inicializálás jelen esetben adatbázisműveleteket jelent. Sőt, amennyiben egy klasszikus adatbázissal dolgozunk, lehetséges, hogy már vannak benne adatok, amelyek megzavarhatják a tesztet. De lehet, hogy törölni sem akarjuk őket, mert kellhetnek a teszteléshez. Második probléma a tesztelendő kód futtatásakor az előbb említett adatbázis kapcsolat kiépítése. Hiszen tesztelésnél ez teljesen másképpen történhet, mint futás közben. Harmadrészt az assertek megírása sem egyszerű feladat, hiszen lehetséges, hogy az adatbázis állapotát akarjuk ellenőrizni, akár több tábla több sorát.

Ezekben segít nekünk a DbUnit, mely egy JUnitra épülő teszteléshez használható library. Segít nekünk az adatbázis inicializálásában (ottmaradt adatok törlése, input adatok betöltése), valamint a aktuális és az elvárt adatok összehasonlításában.

Ezeket ún. Helper osztályokkal és metódusokkal teszi. Definiál egy IDataSet interfészt, mely több táblát tartalmazó adathalmaz, valamint egy ITable interfészt, mely egy tábla adatai. Azonban az IDataSet interfésznek több megvalósítását is biztosítja. Egyrészt természetesen lehetséges ennek betöltése adatbázisból, de lehetőség van arra, hogy a betöltés XML állományból (két fajta: flat, original), lekérdezésből (SQL select), Excel állományból, stb. történjen. Valamint különböző metódusokat biztosít ezek összehasonlítására.

Ezek felhasználásával egy tipikus teszteset a következőképpen nézhet ki. Definiálunk XML-ben egy DataSetet, mely a teszt esethez szükséges pár tábla input adatait tartalmazza, és beszúrjuk ezeket. Lefuttatjuk a tesztelendő metódust, majd betöltjük XML állományból az elvárt állapotot is, és összehasonlítjuk magával az adatbázissal.

A DbUnit JAR állományán kívül az SLF4J API és valamelyik megvalósításának (Log4J, Commons Logging, java.util.logging) JAR állományát is be kell tenni a teszt classpath-ba.

Készítettem is egy példa projektet, mely elérhető a GitHub-on, az mvn test parancs kiadásával indíthatóak a tesztesetek. Van egy Employee osztályt, melynek példányait le szeretnénk menteni és beolvasni, és egy EmployeeDao osztály.

Osztálydiagram

A példában HSQLDB beépített adatbáziskezelőt használok. Az inicializálást egy @BeforeAll annotációval ellátott metódusban végezzük el. Itt hozzunk létre egy adatbázis kapcsolatot, és a JPA számára egy EntityManagerFactory-t. Majd töltsünk be egy XML állományt, mely az Employee tábla adatait tartalmazza, majd ürítsük az adatbázisból az Employee táblát, és szúrjuk be az előbb betöltött adatokat.

static DataSource dataSource;

static EntityManagerFactory emf;

EntityManager entityManager;

EmployeeDao employeeDao;

@BeforeAll
static void init() throws Exception {
    Properties properties = new Properties();
    properties.put("url", "jdbc:hsqldb:mem:dbunittutor");
    properties.put("user", "sa");
    properties.put("password", "");

    dataSource = JDBCDataSourceFactory.createDataSource(properties);
    emf = Persistence.createEntityManagerFactory("dbunittutorPu");
}

@BeforeEach
void setUp() throws Exception {
    IDatabaseConnection conn = new DatabaseDataSourceConnection(dataSource);
    IDataSet data = new XmlDataSet(EmployeeDaoTest.class.getResourceAsStream("/employees.xml"));
    DatabaseOperation.CLEAN_INSERT.execute(conn, data);

    assertEquals(3, conn.getRowCount("employee"));

    entityManager = emf.createEntityManager();
    employeeDao = new EmployeeDao(entityManager);
}

A példában látható, hogy az adatbázis kapcsolatot egy IDatabaseConnection interfészt implementáló DatabaseDataSourceConnection osztály burkolja be, de az IDatabaseConnection interfésznek egyéb megvalósításai is vannak, pl. DatabaseConnection, melynek egy Connection példányt kell átadni.

Az employees.xml írja le a kezdeti adatbázis tartalmát:

<?xml version="1.0" encoding="UTF-8"?>
 <dataset>
 <table name="employee">
  <column>id</column>
  <column>name</column>
  <row>
   <value>1</value>
   <value>John Doe</value>
  </row>
  ...
 </table>
</dataset>

A következő teszt eset a listEmployees() metódust teszteli.

@Test
void testListEmployees() {
    List<Employee> employees = employeeDao.listEmployees(1, 2);
    assertEquals(2, employees.size());
    assertTrue("A name prefixszel kell kezdodnie",
        employees.get(0).getName().startsWith("name"));
}

A következő metódus a mentést ellenőrzi, de úgy, hogy az adatbázis elvárt állapotát egy XML-ből tölti be, és összehasonlítja az adatbázis tényleges állapotával.

@Test
public void testListEmployees() {
    Employee employee = new Employee("name4");
    employeeDao.persistEmployee(employee);

    ITable tableDb = new DatabaseDataSourceConnection(ds)
        .createDataSet().getTable("employee");
    ITable tableXml = new XmlDataSet(EmployeeDaoTest.class
        .getResourceAsStream("/expectedEmployees.xml"))
        .getTable("employee");

    new DbUnitAssert().assertEquals(tableXml, tableDb);
}

Ehhez a expectedEmployees.xml már ki van egészítve a beszúrt Employee adataival.

Ez lehet, hogy elsőre tökéletesen lefut, de több probléma is van vele. Második futtatásnál már hibát jelezhet a teszteset. A hiba szövege nagyon beszédes, leírja, hogy melyik tábla hanyadik sorában van az eltérés, és kiírja az aktuális és az elvárt értéket is. A hiba ott lehet, hogy egyrészt nem biztos, hogy a sorokat a megfelelő sorrendben kapjuk vissza. Ehhez van a SortedTable osztály, mely a dekorátor tervezési mintát valósítja meg. Ezzel valamelyik oszlopra lehet rendezni. Így a kiadott id-ban sem lehetünk biztosak, így erre sem kell ellenőrizni. Erre használjuk a DefaultColumnFilter osztályt, mellyel meg lehet mondani, hogy mely oszlopokat akarunk figyelembe venni az összehasonlításkor. Így a javított kód a következő.

ITable tableDb = new SortedTable(DefaultColumnFilter
    .includedColumnsTable(new DatabaseDataSourceConnection(ds)
    .createDataSet().getTable("employee"), new String[]{"name"}),
    new String[]{"name"});

ITable tableXml = new SortedTable(DefaultColumnFilter
    .includedColumnsTable(new XmlDataSet(EmployeeDaoTest.class
    .getResourceAsStream("dataset_result.xml")).getTable("employee"),
    new String[]{"name"}), new String[]{"name"});

Ez mindkét táblán szűrést és rendezést is végez, és ezután már össze lehet hasonlítani őket.

Az ITable interfésznek egyéb helper metódusai is vannak, melyeket érdemes használni, mert megkönnyítik a programozást. Egyrészt a getRowCount() metódus visszaadja a sorok számát, másrészt a getValue(0, "name") visszaadja a 0. sor name oszlopának értékét.

A perzisztens réteg tesztelésekor a következő tanácsokat érdemes betartani:

  • Minden fejlesztő kapjon saját adatbázist, vagy használjunk embedded adatbázist. Ezzel kikerülhető az, hogy egyszerre többen használják, így összekeverednek az adatok.
  • Két stratégiát választhatunk teszteléskor: vagy minden teszteset után visszagörgetünk (rollback), vagy minden teszteset előtt inicializáljuk az adatbázist. Az elsőt használják többen is, de én a másodikat javaslom, mert mikor először próbálkoztam az első megközelítéssel, a teszt esetek sikeresen lefutottak, a kivétel pont a commit műveletnél jött. Az utóbbi esetében viszont szintén érdemes megfogadni két tanácsot. Egyrészt kis adathalmazokkal dolgozzunk, hogy a teszt lefutása gyors legyen. Másrészt nem kell a teszteset lefutása után cleanup, hiszen a teszt eseteket úgy kell megírni, hogy az inicializálás (@Before) úgy állítsa be az adatbázist, hogy azon azonnal le lehessen futtatni a tesztesetet. A tesztesetek között sorrendi függőség, állapotátmenet ne legyen lehetséges.
  • Amennyiben read-only adataink is vannak, melyet minden teszt eset használ, javasolt azokat osztályszintű inicializáláskor betölteni, szintén a gyorsítás végett.

Amennyiben a DataSource JNDI-ből való lekérését is tesztelni akarjuk, használhatunk stub/mock JNDI Context implementációt.

A DbUnit Antból is használható, és Maven pluginje is van, melynek goaljai képesek integrációs tesztelés esetén az adatbázis inicializációjára, adatbázis adatainak xml-be exportálására, valamint DataSet-ek összehasonlítására.

Spring Framework használata esetén kötelező olvasmány a Reference Testing fejezete, különösen a tranzakciókezelés részre.

JPA esetén javasolt az Apress kiadásában készült Pro EJB 3 Java Persistence API könyv, melynek 12. fejezet a JPA teszteléséről szól. Itt a tesztelés különböző szintjeit tárgyalja. Első szint, mikor csak az entitást, mint POJO-t teszteled. Második szinten már a DAO-t teszteled, de adatbázis kapcsolat nélkül, stub/mock EntityManagerrel. Az integrációs teszt során kötöd össze a DAO-t ténylegesen az adatbázissal. Itt lehet trükközni azzal, hogy a teszteset futtatásakor honnan veszel EntityManager példányt (pl. factory), illetve felül lehet írni a teszteset futtatásakor az entitáson elhelyezett annotációkat xml konfigurációval. Különösen érzékeny terület itt is a tranzakciókezelés.