27. září 2009

Vytváření testovacích dat

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));
}

}

14. září 2009

Distribuované transakce bez JEE kontejneru

Dlouho dobu jsem si myslel, že pokud potřebuji řídit transakce přes více datových zdrojů, pak se nikdy neobejdu bez plnohodnotného JEE kontejneru resp. serveru.

Před pár lety jsem na projektu potřeboval propojit do jedné transakce události ze dvou datových zdrojů (databáze, souborový systém) a bez znalosti dané problematiky jsem si sám napsal jednoduchý mechanismus, který mi dokázal v 98% případů zaručit, že se uloží buď obě události a nebo žádná. Ve zbylých dvou procentech případů to nefungovalo, ale to mi z pohledu logiky aplikace moc nevadilo. Pro mě bylo důležitější, že jsem si vystačil pouze se Springem a Tomcatem, a že to celé běhalo celkem rychle.

Proč o tom píšu? Nedávno jsem si všimnul, že samotný Spring bude mít tento mechanismus zabudovaný přímo v jádře.

Já se vždy snažím vyhnout "těžkým" řešením při návrhu aplikací a zde je hezky vidět, že i distribuované transakce se dají řešit "lehkou" cestou. Je ale potřeba si předem jasně říci, co je pro nás nejdůležitější - zda 100% spolehlivost bez výjimek nebo rychlost celého transakčního systému nebo výběr technologií. Nejde bohužel splnit všechny tři kritéria najednou k plné spokojenosti, je potřeba si vybrat jedno a ostatní k tomu přizpůsobit.

Doplňující články: