Jak na rychlé integrační testy ve Springu

Integrační testy spočívají v testování konkrétní kódu spolu s okolními částmi, se kterými spolupracuje. Cílem je snaha otestovat kód ve stavu, který se blíží reálnému nasazení. Obvykle takto testujeme datovou vrstvu aplikace (jelikož tam klasické jednotkové testy ztrácejí smysl - chceme přeci otestovat správné dotazování databáze, tudíž databázi k testu potřebujeme) a v řadě případů se nám nevyplatí mockovat ani na úrovni business vrstvy. Dokonce i Rod Johnson ve své prezentaci (kterou byl inspirován tento článek) zdůrazňuje důležitost integračních testů.

Hlavní problém integračních testů, u kterých máte ve spodu relační databázi je rychlost. Každý test spoléhá na nějaká data v DB - ty mohou být (a jsou) ostatními testy poškozena a proto je nedílnou částí všech testů setUp / tearDown operace, která se o tuto přípravu a uklizení stará. Jelikož jsou programátoři cháska líná a nechtějí se zabývat přípravou pouze minimální potřebné sady pro každý test, obvykle si vytvoří nějakého předka, který obsahuje setUp a tearDown, pro všechny testy najednou. Tím pádem se vždycky inicializuje kompletní sada dat a to si bere významné množství času. Odhadoval bych, že 80% času testů se stráví v této přípravě dat a pouze 20% času běží skutečné testy.

Na začátku projektu toto obvykle člověku nevadí, časem se to ale stane docela velkou bolestí. Na projektu, který jsem právě ukončil trvá běh testů na integračním serveru už něco kolem 30 minut. Spustit si takové testy na lokálním vývojovém prostředí je už prostě neúnosné (ještě že ty integrační servery máme ;)). Proto chce přemýšlet nad tímto problémem už od začátku - měnit princip fungování testů v pokročilé fázi projektu už stojí docela dost času.

Jedním z řešení, které například používají na rozsáhlém projektu pro bankovní sféru ve společnosti mého kolegy, je výměna datové vrstvy z cílové platformy (Oracle) za databázi v paměti (HSQL). Na začátku testů vytvoří v paměti kompletní nové schéma, které naplní daty a na konci testu celou databázi opět dropnou. Rychlost provádění se tím samozřejmě drasticky zvýší nicméně aplikaci už netestujeme na "finální" databázi, takže jsou naše testy částečně znehodnoceny. Navíc toto lze rozumně použít pouze v případě, kdy používáme ORM, který nám zakryje rozdíly mezi databázemi. V případě použití klasického JDBC nebo např. iBatisu (jako používáme my) bychom museli připravit dvě implementace a testy by už vůbec neplnily svůj smysl.

Řešení existuje!

Všechny problémy mají svá řešení a dobrá řešení se šíří jako lavina. Spring Framework je toho příkladem a i pro podporu testů obsahuje v balíku spring-mock dobré nápady. Částečně jsme toto rozkryli již v prezentaci mého kolegy základy testování ve Springu, do větší hloubky to však rozebírá sám Rod Johnson v prezentaci integrační testování ve Springu. Krom základního předka AbstractDependencyInjectionSpringContextTests (který se vám postará o načtení a zacachování spring contextu + nasetování bean přes settery do instance testu) jsou ve zmíněné knihovně ještě další třídy AbstractTranactionalSpringContextTests, AbstractTransactionalDataSourceSpringContextTests a AbstractJPATests, jejich použití si v tomto článku ukážeme.

Princip, o kterém Rod hovoří, je poměrně jednoduchý. Základem je, že máte v databázi stabilní sadu s daty, na kterých provádíte své testy. Všechny testy se mohou spolehnout na to, že v DB budou tato data a žádná jiná. Před započetím testu se AbstractTranactionalSpringContextTests postará o to, aby byla nastartovaná nová transakce, ve které test běží, a na konci je tato transakce rollbacknutá. Veškeré operace s daty, které byly v rámci testu provedeny jsou tedy vráceny zpět a další test opět běží nad stabilní datovou bází aniž bychom museli provádět nějaké extenzivní operace v setUp / tearDown.

Toto řešení má poměrně dost kladných dopadů:

  • testy se řádově zrychlí - toto zrychlení je vidět už i v případě, že spouštíme testy pouze jedné třídy
  • průměrná délka v řádcích konkrétní testové třídy se výrazně zmenší
  • vlastní psaní testů je daleko rychlejší a člověk z něj není tolik unaven - většinou se zabývá tím co chce skutečně testovat a netráví tolik času otravnou přípravou a uklízení dat

Třída AbstractTransactionalDataSourceSpringContextTests už přidává pouze několik pomocných metod, které vám umožní přistoupit k datasourcu, se kterým testy pracují a provést nad ním některé často používané operace (např. zjisti počet řádků v tabulce, vymaž všechny záznamy v tabulkách, proveď nějaký SQL příkaz).

Třída AbstractJPATests je optimalizovaná pro projekty, které používají na datové vrstvě ORM frameworky, které provádějí instrumentaci POJO a dalších potřebných tříd. Pro tento účel je vytvořen tzv. ShadowingClassLoader, který izoluje takto instrumentované třídy a je možné jej i se všemi třídami zrušit v případě potřeby (nicméně přiznám, že na tuhle oblast nejsem expert, takže vám k tomu víc neřeknu).

Příklad z praxe

V této části uvedu pár příkladových tříd z projektu, na kterém v současnosti pracuji. Vytvořil jsem si ještě jednoho předka, který mi zaručí konzistenci datové báze pro testy. Ve zkratce se tento předek před započetím transakce zeptá na jména tabulek a očekávaný počet řádků v těchto tabulkách - před startem každého testu se provádí tato jednoduchá kontrola. V případě, že počty sedí test počítá s tím, že data jsou v pořádku a test se spustí. Pokud by test nastartoval a databáze by byla prázdná, nebo by někdo ručně změnil data v databázi, došlo by k obnovení dat, se kterými test počítá. Vlastní testy běží v transakci, takže data neovlivní - ale v některých případech potřebujeme pro test toto chování vyřadit (např. potřebujeme právě otestovat správné chování transakcí) a k ovlivnění dat v DB dojde. V takovém případě se data před startem dalšího testu znovu vytvoří (tedy pokud došlo ke změně počtu řádků, pokud nikoliv může programátor zavolat metodu setDatabaseDirty a k obnovení dat si tímto vynutí). Takové případy, kdy ale potřebujeme jet mimo "testovou" transakci je ale dost málo, takže těch pár "kompletních" inicializací už nečiní takový problém, jako když se inicializace dělaly před každým testem.

Kód tohoto předka je zde:


/**
 * Performs the same funcionality as AbstractTransactionalDataSourceSpringContextTests but more than that
 * it contains coherent logic for keeping state of the database in consistency with prepared testing data.
 * See #AbstractTransactionalDataSourceSpringContextTests documentation for more hints on testing.
 */
public abstract class AbstractDatabaseSpringTestCase extends AbstractTransactionalDataSourceSpringContextTests {
	/**
	 * Flag signalizing, that test modified data in database outside transaction, but the rowcount
	 * in tables stood the same.
	 */
	private static boolean databaseIsDirty;
	/**
	 * Subclasses can override this method to perform any setup operations,
	 * such as populating a database table, before the transaction
	 * created by this class. Only invoked if there is a transaction:
	 * that is, if {@link #preventTransaction()} has not been invoked in
	 * an overridden {@link #runTest()} method.
	 *
	 * @throws Exception simply let any exception propagate
	 */
	protected void onSetUpBeforeTransaction() throws Exception {
		super.onSetUpBeforeTransaction();
		refreshDatabase(true);
	}
	/**
	 * Subclasses can override this method to perform cleanup after a transaction
	 * here. At this point, the transaction is not active anymore.
	 *
	 * @throws Exception simply let any exception propagate
	 */
	protected void onTearDownAfterTransaction() throws Exception {
		super.onTearDownAfterTransaction();
		if(databaseIsDirty) refreshDatabase(false);
	}
	/**
	 * Performs refresh of the data in database.
	 * @param checkCounts when true check whether counts in tables has changed
	 *        - if not skip refreshment
	 */
	private void refreshDatabase(boolean checkCounts) {
		TableCountHolder[] tablesWithCounts = getTablesToBeCheckedForCount();
		if(tablesWithCounts != null) {
			if(!checkCounts || isDataInconsistent(tablesWithCounts)) {
				String[] tableNames = createTableNames(tablesWithCounts);
				deleteFromTables(tableNames);
				populateEmptyTables();
				databaseIsDirty = false;
			}
		}
	}
	/**
	 * This method should be called by test, when it changes data outside a transaction and
	 * possible does not change rowcount in tables.
	 */
	protected void setDatabaseDirty() {
		AbstractDatabaseSpringTestCase.databaseIsDirty = true;
	}
	/**
	 * Converts set into array of tablenames.
	 *
	 * @param tablesWithCounts
	 * @return
	 */
	private String[] createTableNames(TableCountHolder[] tablesWithCounts) {
		String[] result = new String[tablesWithCounts.length];
		for(int j = 0; j < tablesWithCounts.length; j++) {
			result[j] = tablesWithCounts[j].getTableName();
		}
		return result;
	}
	/**
	 * Returns true if data is not consistent in database.
	 *
	 * @param tablesWithCounts
	 */
	private boolean isDataInconsistent(TableCountHolder[] tablesWithCounts) {
		for(int i = 0; i < tablesWithCounts.length; i++) {
			TableCountHolder tableInfo = tablesWithCounts[i];
			String tableName = tableInfo.getTableName();
			if(tableInfo.getExpectedRowCount() != countRowsInTable(tableName)) {
				logger.info("Table " + tableName + " inconsistent, forcing data refresh.");
				return true;
			}
			else {
				logger.info("Table " + tableName + " consistent, can reuse existing data.");
			}
		}
		return false;
	}
	/**
	 * Method to be overriden by subclasses.
	 * Should populate data into all tables mentioned in getTablesToBeCheckedForCount method.
	 * This class ensures that all tables are emptied before calling this method.
	 */
	protected void populateEmptyTables() {
		//let the subclass do what it needs
	}
	/**
	 * Method to be overriden by subclasses.
	 *
	 * It is important to have items in array in right order optimized for
	 * possible record deletion sequence.
	 *
	 * @return
	 */
	protected TableCountHolder[] getTablesToBeCheckedForCount() {
		return null;
	}
	/**
	 * Holds information about expected rowcount in a table.
	 */
	public static class TableCountHolder {
		private String tableName;
		private int expectedRowCount;
		public TableCountHolder(String tableName, int expectedRowCount) {
			this.tableName = tableName;
			this.expectedRowCount = expectedRowCount;
		}
		public String getTableName() {
			return tableName;
		}
		public void setTableName(String tableName) {
			this.tableName = tableName;
		}
		public int getExpectedRowCount() {
			return expectedRowCount;
		}
		public void setExpectedRowCount(int expectedRowCount) {
			this.expectedRowCount = expectedRowCount;
		}
	}
}

V projektu si potom vytvářím ještě dalšího předka, který implementuje strategii obnovy dat pro projektové testy. V předku mám statické pole se seznamem POJO objektů, které reprezentují data v databázi a se kterými mohou testy dále pracovat. Tyto POJO objekty jsou v populateEmptyTables metodě vloženy do databáze.

Pozn.: Možná by bylo vhodnější data vkládat způsobem nezávislým na kódu naší aplikace kterou testujeme (tedy nikoli přes daoRole.createRole(...)), jenže tento způsob je prostě o mnoho jednodušší a přistupuji na tu nevýhodu, že pokud bude chyba v této metodě, nerozjedou se ani testy.


/**
 * Project database test ancestor. Contains database population logic.
 */
public abstract class AbstractProjectDatabaseTestCase extends AbstractDatabaseSpringTestCase {
	protected SqlMapClient sqlMapClient;
	protected IRoleStorage daoRole = null;
	protected static IRole[] roles = new IRole[]{
		//creates populated POJO Role object: id, systemName, name, description
		PojoFactory.getRole(1, "SUPERVIZOR", "Oprávnění supervizora", "Role s maximálními oprávněními."),
		PojoFactory.getRole(2, "ADMINISTRATOR", "Administrátor", "Administrační práva."),
		PojoFactory.getRole(3, "PUBLISHER", "Pisatel", "Umožňuje psát články."),
		PojoFactory.getRole(4, "READER", "Čtenář", "Umožňuje číst články."),
		PojoFactory.getRole(5, "REDACTOR", "Redaktor", "Schvaluje články.")
	};
	public void setDaoRole(IRoleStorage daoRole) {
		this.daoRole = daoRole;
	}
	public void setSqlMapClient(SqlMapClient sqlMapClient) {
		this.sqlMapClient = sqlMapClient;
	}
	protected void populateEmptyTables() {
		try {
			for(int i = 0; i < roles.length; i++) daoRole.createRole(roles[i]);
		}
		catch(Exception ex) {
			throw new RuntimeException("Cannot prepare database for tests!", ex);
		}
	}
	protected TableCountHolder[] getTablesToBeCheckedForCount() {
		return new TableCountHolder[]{
				new TableCountHolder("T_FGUSER_AUTHORITY", roles.length),
	}
}

Vlastní testová třída již vypadá velmi jednoduše. Testy se skutečně soustředí na logiku aplikace, píšou se velmi jednoduše a na mém počítači trvá spuštění všech testů této třídy asi 3 vteřiny, z čehož přes 2 vteřiny trvá inicializace Springu a získání konekce z databáze. Když rozšířím sadu testů na pět (každý má zhruba stejný počet metod), trvá celý běh asi o 0,6 vteřiny déle. Původním způsobem s inicializací DB před každým testem bych byl minimálně na 30 sekundách.


public class RoleStorageTest extends AbstractProjectDatabaseTestCase {
	public void testCreateRole() throws Exception {
		IRole role = getSomeNewRole();
		daoRole.createRole(role);
		assertTrue(role.getId() > 0);
	}
	public void testCreateRoleTwice() throws Exception {
		IRole role = ((Role)roles[0]).getClone();
		try {
			role.setId(0);
			daoRole.createRole(role);
			fail("Exception should have been thrown.");
		}
		catch(ObjectAlreadyExists e) {
			//yup, this is right
		}
	}
	public void testGetRoleNonExisting() throws Exception {
		assertNull(daoRole.getRoleById(-1));
		assertNull(daoRole.getRoleBySystemName("nejaka nesmyslna"));
	}
	public void testGetRoleById() throws Exception {
		IRole role = daoRole.getRoleById(roles[0].getId());
		assertTrue("Roles should be the same", roles[0].match(role));
	}
	public void testGetRoleBySystemName() throws Exception {
		IRole role = daoRole.getRoleBySystemName(roles[0].getSystemName());
		assertTrue("Roles should be the same", roles[0].match(role));
	}
	public void testUpdateRole() throws Exception {
		Role role = ((Role)roles[0]).getClone();
		int id = role.getId();
		role.setSystemName("WHATEVER");
		role.setName("Whatever name");
		role.setDescription("Whatever description");
		daoRole.updateRole(role);
		IRole role2 = daoRole.getRoleBySystemName("WHATEVER");
		assertTrue("Roles should be the same", role.match(role2));
		assertEquals(id, role2.getId());
	}
	public void testRemoveRole() throws Exception {
		assertTrue(daoRole.removeRole(roles[0].getId()));
		assertNull(daoRole.getRoleBySystemName(roles[0].getSystemName()));
	}
	public void testRemoveRoleNonExisting() throws Exception {
		assertFalse(daoRole.removeRole(-1));
	}
	public void testListRoles() throws Exception {
		List list;
		list = daoRole.listRoles(new HashMap(), null, null, 0, 20);
		assertNotNull(list);
		assertTrue(list.size() == users.length);
		assertEquals("ADMINISTRATOR", ((IRole)list.get(0)).getSystemName());
		list = daoRole.listRoles(new HashMap(), RoleStorage.ORDER_BY_DESCRIPTION, Boolean.FALSE, 0, 20);
		assertEquals("PUBLISHER", ((IRole)list.get(0)).getSystemName());
		HashMap conditions = new HashMap();
		conditions.put("description", "Umožňuje%");
		list = daoRole.listRoles(conditions, null, null, 0, 20);
		assertTrue(list.size() == 2);
	}
	private IRole getSomeNewRole() {
		return PojoFactory.getRole("EVICTOR", "Mazač", "Role která umožňuje mazání.");
	}
}

Závěr a odkazy

Na vyzkoušení tohoto způsobu implementace testů mne upozornila již několikrát zmiňovaná přednáška Roda Johnsona o integračním testování. Přednáška má cca. 90 minut a rozhodně stojí za shlédnutí - Rod tam rozebírá ještě další věci, takže pokud už celý Spring nemáte v malíku, doporučuji.

Detailní popisky o fungování Spring test tříd jsou v javadocech, proto v závěru článku uvedu odkazy. O základech testování ve Springu přednáší kolega v prezentaci Basics of JUnit testing with Spring. Pokud vás zajímají základy, doporučuji jeho přednášku.

Odkazy: