Každý, kdo píše testy, tak řeší problém s tím, jak nainicalizovat strukturu svých (doménových) objektů, aby mohl otestovat určitou funkcionalitu. Způsobů řešení je více.
"ruční" inicializace pomocí Javy
Pokud test potřebuje nějaké objekty, tak si je v rámci samotného testu prostě vytvoříme resp. přes IoC napojíme, stejně jako když vytváříme produkční kód.Nevýhodou tohoto přístupu je, že ve výsledku vzniká spoustu duplicitního kódu, protože každý si vytváří své objekty. A naopak pokud se objekty vytvářejí centrálně, tak pak je většinou ten problém, že chybí možnost si pro určité testy nastavit určité atributy jinak než implicitně.
externí popis dat
Požadovaná data nadefinuji pomocí vhodného frameworku, tak abych oddělil vlastní testovací kód a testovací data. Ideálním představitelem pro databázové systémy je DbUnit, ale vlastně se jedná o jakýkoliv systém, který mi umožňuje externě definovat data, např. pomocí XML.Výhod v tomto případě vidím několik - testy obsahují pouze kód k ověření funkčnosti a již nemusí řešit "omáčku" okolo, vytváření podobných a často se opakujících testů bude rychlejší a nakonec také testovací data a samotné testy nemusí vytvářet jedna osoba.
Na druhou stranu vím, že tímto způsobem nelze moc dobře vytvářet nějaké větší grafy objektů a také údržba aktuálnosti dat dá celkem zabrat, zejména když se pustíme do refactoringu.
testovací databáze
Pro účely testování bude k dispozici testovací databáze, která bude obsahovat nějaký konzistentní stav a k tomuto stavu budou psány testy.Kromě náročnosti na údržbu dat zde vidím problém v případě, kdy nad touto testovací databází bude pracovat více vývojářů, kteří si budou navzájem zamykat data (např. při krokování programu). Také je jen otázka času, kdy někdo omylem komitne změny v testech. Řešením by zde bylo, kdyby každý vývojář měl svojí testovací databázi.
automatické vytváření grafu objektů
Při vytváření testovacích dat nám jde většinou o několik málo atributů, které ověřujeme a ostatní atributy mohou být zcela libovolné. To je základní myšlenka našeho jednoduchého řešení, které se nám stará o centrální vytváření grafu objektů, aniž bychom museli sami tyto objekty vytvářet pro každý test.Postup je asi následující: každý objekt, který potřebujeme vytvářet během testů, bude mít odpovídající třídu "testData", která se bude starat o jeho inicializaci (nejen jednoduchých atributů, ale i o referencí na další objekty). Pro psaní těchto "testData" tříd jsme napsali dvě podpůrné třídy AbstractReadTestData a AbstractCreateTestData, takže stačí implementovat jen jednu resp. dvě metody ve vlastní "testData" třídě. Kdykoliv pak během testu potřebuji nějaký objekt, tak si zavolám "testData" třídu, (metodu get() nebo createAndSave()) a ta mi testovací objekt vrátí. Buď to může být objekt s hodnotami nastavenými v "testData" třídě nebo s hodnotami přebranými ze šablony - tím mám možnost si nastavit určité atributy nebo i celé objekty dle svých potřeb.
Ještě musím dodat, že jsme na našem projektu rozlišovali dva typy objektů - jeden typ objektu byl pouze pro čtení a byl vždy součástí testovací databáze. Typicky se jedná o číselníky - ty se nemění, ty se můžou pouze číst. Druhý typ objektu je normální objekt, který je nejdříve potřeba vytvořit a uložit do databáze.
Dle mého názoru tento přístup k vytváření testovacích objektů řeší většinu nevýhod zmíněných v předchozích způsobech inicializace. Sami jsme to otestovali na projektu, na kterém dělalo cca 15 programátorů a kde byl velmi košatý a složitý datový model. Snad jediná nevýhoda tohoto a podobných řešení je ta, že vkládaná data do databáze v rámci testů nejsou nikdy komitnuta, takže nemáme možnost si prohlédnout co skutečně v databázi je, např. během ladění.
Tento nápad nebyl pouze z mé hlavy, ale rád bych uvedl spoluautora Pavla Jetenského, který o našem řešení mluvil na letošním jOpenSpace, přednáška Lepší než Dbunit.
Samotný kód určitě napoví více než nějaká slova, tak přikládám jednotlivé třídy včetně ukázek použití.
AbstractReadTestData:
/**
* Abstraktni trida pro nacitani testovacich dat.
*
* <p>Tato trida nabizi pristup k jiz existujicim datum v DB (napr. registry).
* Pokud chcete testovaci data i vytvaret, pak je nutne pouzit tridu {@link AbstractCreateTestData}.
*
*/
public abstract class AbstractReadTestData<BO extends IIdentifiable> {
private static final String SET_METHOD_PREFIX = "set";
private static final String GET_METHOD_PREFIX = "get";
protected final Calendar now = Calendar.getInstance();
/** Mapa objektu, se kterymi se pracovalo. [ID objektu, BO] */
protected Map<Serializable, BO> dataMap = new TreeMap<Serializable, BO>();
/**
* Constructor
*/
protected AbstractReadTestData() {
}
/**
* Metoda vrati prvni (libovolnou) instanci test. dat z DB.
* Pokud dosud zadna takova instance nebyla vytvorena, tak je vyhozena vyjimka.
* Data v DB musi existovat.
*
* @return prvni (libovolnou) instanci test. dat
* @throws IllegalStateException pokud DB neobsahuje data pozadovaneho typu
*/
public BO get() {
BO data;
if (dataMap.isEmpty()) {
data = getExistingRegistryEntity(getDataDao());
if (data != null) {
dataMap.put(data.getId(), data);
}
else {
throw new IllegalStateException("There is no test data for loading.");
}
}
else {
data = dataMap.values().iterator().next();
}
return data;
}
/**
* Metoda vrati BO z DB.
*
* @param dataDao DAO pro pristup k datum
* @return BO nebo null pokud neexistuje
*/
protected BO getExistingRegistryEntity(IPlainDaoSupport<BO> dataDao) {
BO byId = null;
List<BO> findList = dataDao.findList();
if (findList.size() > 0) {
byId = findList.get(0);
}
return byId;
}
/**
* Metoda vyresetuje mapu testovacich dat, se kterymi se dosud pracovalo.
*/
public void cleanTestDataCache() {
dataMap.clear();
}
/**
* Metoda je urcena k implementaci v podtridach a vraci referenci na DAO
* pro nacitani dat z DB (a pripadne manipulaci s daty).
*
* @return DAO pro pristup k datum
*/
protected abstract IPlainDaoSupport<BO> getDataDao();
/**
* Metoda automaticky najde vsechny atributy (fieldy) tridy typu {@link Calendar}
* a nastavi jim aktualni datum a cas (pokud jsou null).
*
* @param data BO
*
* @throws UnsupportedOperationException pokud neni mozne nastavit pres setter Calendar
*/
protected void fillAllCalendarFields(IIdentifiable data) {
Method[] methods = data.getClass().getMethods();
for (Method method : methods) {
try {
Class<?>[] parameterTypes = method.getParameterTypes();
if (method.getName().startsWith(SET_METHOD_PREFIX)
&& parameterTypes.length == 1
&& Calendar.class.isAssignableFrom(parameterTypes[0])) {
// nastavime aktualni cas pouze pokud neni nic nastaveno
String getterMethodName = method.getName().replaceFirst(SET_METHOD_PREFIX, GET_METHOD_PREFIX);
Method getter = data.getClass().getMethod(getterMethodName);
Object value = getter.invoke(data);
if (value == null) {
method.invoke(data, this.now);
}
}
}
catch (Exception ex) {
throw new UnsupportedOperationException("Cannot automatically setup calendar method "
+ method.getName() + " of " + data.getClass().getName() + " to default not null value", ex);
}
} //end-for
}
}
AbstractCreateTestData:
/**
* Abstraktni trida pro vytvareni testovacich dat.
*
*/
public abstract class AbstractCreateTestData<BO extends IIdentifiable>
extends AbstractReadTestData<BO> {
/**
* Metoda vrati prvni (libovolnou) instanci test. dat (BO), ktera jiz byla drive vytvorena.
* Pokud dosud zadna takova instance nebyla vytvorena, tak se vytvori objekt s implicitnima hodnotama.
*
* <p><b>Pozor, tato metoda nenacita jiz existujici data z DB, pouze z interni cache po
* naslednem vytvoreni.</b>
*
* @return prvni (libovolnou) instanci BO - bud primo z DB
* @see #createAndSave
*/
final public BO get() {
BO data;
if (dataMap.isEmpty()) {
//vytvorime a ulozim impl. data
data = createAndSave();
}
else {
data = dataMap.values().iterator().next();
}
return data;
}
/**
* Metoda v databazi vytvori instanci, naplnenou vychozimi implicitnimi testovacimi daty.
* Instanci prideli nove logicke id s DB sekvence.
*
* @return BO s implicitnimi daty
*/
final public BO createAndSave() {
return createAndSave(null);
}
/**
* Metoda v databazi vytvori novou instanci test dat,
* naplnenou kombinaci vychozich testovacich hodnot a hodnot ze vstupni sablony.
*
* @param data Sablona pro vytvoreni test. objektu
* @return vytvoreny BO nebo null, pokud se nic nevytvori
*/
final public BO createAndSave(BO data) {
//vytvorime novy test. objekt
BO createdBo = create(data);
//ulozim je do DB a do cache
if (createdBo != null) {
getDataDao().save(createdBo);
dataMap.put(createdBo.getId(), createdBo);
}
return createdBo;
}
/**
* Metoda vytvari (neuklada!) novy testovaci objekt.
* Metoda je zodpovedna za vytvoreni nove instance objektu s nastavenim vsech "not-null" atributu, vcetne
* reference na dalsi objekty.
*
* <p>Vsude, kde je nektera z vlastnosti parametru sablony <i>data</i> nenulova, je pouzita pro novy objekt.
* V ostatnich pripadech se pouzije <i>nejaka</i> vychozi hodnota.
*
* @param data Sablona pro vytvoreni test. objektu
* @return nova testovaci data (BO)
*/
protected abstract BO create(BO data);
}
Aby byl přehled základních tříd kompletní, tak uvádím i rozhraní IIdentifiable, které používáme jako rozhraní pro všechny naše doménové objekty:
public interface IIdentifiable<T extends Serializable> {
/**
* Identifikator.
*
* @return Vraci identifikator objektu.
*/
T getId();
/**
* Metoda zjistuje, jestli ma objekt <i>id</i>.
*
* @return Vraci <tt>true</tt>, pokud ma objekt korektni id, jinak <tt>false</tt>.
*/
boolean isIdentified();
}
A nakonec ukázka použití - jsou použity doménové objekty IPersonBo a IRoleBo, vztah je takový, že osoba (Person) může mít jednu roli (Role). Nejdříve "testData" třídy:
public class PersonReadTestData extends AbstractReadTestData<IPersonBo> {
@Autowired
private IPersonDao personDao;
protected IPlainDaoSupport<IPersonBo> getDataDao() {
return personDao;
}
}
public class PersonCreateTestData extends AbstractCreateTestData<IPersonBo> {
@Autowired
private IPersonDao personDao;
@Autowired
private RoleCreateData roleCreateData;
protected IPersonBo create(IPersonBo data) {
if (data == null) {
data = personDao.createNewInstance();
}
if (StringUtils.isEmpty(data.getName())) {
data.setName("Petr");
}
if (StringUtils.isEmpty(data.getSurname())) {
data.setSurname("Kolousek");
}
if (data.getRole() == null) {
data.setRole(roleCreateData.createAndSave());
}
return data;
}
protected IPlainDaoSupport<IPersonBo> getDataDao() {
return personDao;
}
}
public class RoleCreateTestData extends AbstractCreateTestData<IRoleBo> {
@Autowired
private IRoleDao roleDao;
protected IRoleBo create(IRoleBo data) {
if (data == null) {
data = roleDao.createNewInstance();
}
if (StringUtils.isEmpty(data.getName())) {
data.setName("READ");
}
return data;
}
protected IPlainDaoSupport<IRoleBo> getDataDao() {
return roleDao;
}
}
... a pak použití v rámci testů:
public class CreateDataTest extends AbstractTransactionalJUnit4SpringContextTests {
@Autowired
private PersonCreateTestData personCreateData;
@Autowired
private RoleCreateTestData roleCreateData;
@Before
public void cleanCache() {
personCreateData.cleanTestDataCache();
roleCreateData.cleanTestDataCache();
}
@Test
public void testCreatingNewData() {
//vytvorime zaznamy v DB
IPersonBo person = personCreateData.createAndSave();
assertThat(person, is(notNullValue()));
assertThat(person.getName(), is("Petr"));
assertThat(person.getSurname(), is("Kolousek"));
assertThat(person.getRole(), is(notNullValue()));
assertThat(person.getRole().getName(), is("READ"));
assertThat(countRowsInTable("T_PERSON"), is(1));
IPersonBo retPerson = personCreateData.get();
assertThat(retPerson, is(person));
}
@Test
public void testCreatingNewDataWithGet() {
//vytvorime zaznamy v DB
IPersonBo person = personCreateData.get();
assertThat(person, is(notNullValue()));
IPersonBo retPerson = personCreateData.get();
assertThat(retPerson, is(person));
}
@Test
public void testCreatingExistingData() {
//vytvorime roli podle sablony
IRoleBo roleTempl = new RoleBoImpl();
roleTempl.setName("EXEC");
IRoleBo role = roleCreateData.createAndSave(roleTempl);
assertThat(role, is(notNullValue()));
//vytvorime person ze sablony
IPersonBo personTempl = new PersonBoImpl();
personTempl.setName("Jan");
personTempl.setSurname("Vavra");
personTempl.setRole(role);
IPersonBo person = personCreateData.createAndSave(personTempl);
assertThat(person, is(notNullValue()));
assertThat(person.getName(), is("Jan"));
assertThat(person.getSurname(), is("Vavra"));
assertThat(person.getRole(), is(notNullValue()));
assertThat(person.getRole().getName(), is("EXEC"));
assertThat(countRowsInTable("T_PERSON"), is(1));
IPersonBo retPerson = personCreateData.get();
assertThat(retPerson, is(person));
}
}
5 komentářů:
Ahoj Petře, díky za článek, hezky shrnuto, jak to dokáže vytvořit ten graf objektů.
Zajímavé řešení. Určitě je dobré dodržovat pro vytváření testovacích dat nějakou konvenci. Asi bych se ale snažil oddělit vytváření testovacích instancí od jejich zápisu do databáze. To proto, abych mohl vedle testů dao vrstvy používat stejná testovací data i pro testy servisní vrstvy (kde se může kontrolovat a testovat hlídání duplicit, vytvoření nějakých souvisejících záznamů a pod.). Jak by se postupovalo při přípravě větší množiny dat, třeba pro testování stránkování? Řešily by to samostatné ..TestData třídy pro seznamy?
Ad oddělení od DAO vrstvy - cíleně jsme to potřebovali pro testování s databází, ale určitě s vámi souhlasím a děkuji za zajímavý nápad.
Ad velké množství dat - řešil bych to tak nějak, jak píšete, nic jiného mě teď nenapadá.
ahoj Petre, prijde mi zajimave, ze mixujete v testech data, ktera skutecne v testovaci databazi existuji s daty, ktera si vytvarite pouze pro test (a nekomitujete je). Tim si asi usnadnite praci, ale co kdyz v budoucnu budete potrebovat data v testovaci DB pozmenit? Prece to ohrozi vsechny stare testy ne?
Osobne bych chtel vyzkouset pristup, kdy jeden test (nebo mala mnozina testu) pouziva kompletne svou sadu dat - vytvarenou v pameti pri spusteni testu. Tim se myslim da zajistit, ze test jednou z "neznamych" pricin neprestane fungovat.
Co se mi libi, ze na jednom miste resite nastavovani atributu, ktere pro samotny test nejsou relevantni (not null).
Ahoj Přemku, v testovací DB máme pořád uložena jen číselníková data, tedy data, která tam budou vždy a pořád. Všechny ostatní data si vytváří každý test sám.
Pro změnu dat nebo struktury databáze používáme interní nástroj DbUpgrade, který se nám stará o automatický upgrade mezi jednotlivými verzemi aplikací. Tento nástroj je možné pustit i nad testovací db, takže zde nemáme žádný problém.
Jinak článek je to už trochu starý, za tu dobu jsem spoustu věcí zase posunul dál a s úspěchem jsem toto řešení nasadil do dalších dvou firem.
Okomentovat