Framework pro automatické testování jBPM Workflow zobrazujících formuláře

Předmětem článku je automatizace integračních testů jBPM Workflows pro CzechIdM. Poměrně často potřebuje workflow v nějaké své fázi převzít uživatelská data z formuláře. To je problém v případě automatického testu, kde uživatele nemáme. Pokud se nechceme pustit do zdlouhavého shánění nebohých dobrovolníků, či se uchýlit k použití Selenia, musíme si vytvořit vlastní způsob simulace formulářového vstupu. Následující text se zabývá tímto problémem včetně úpravy řešení do jednoduchého frameworku.

Formulářové workflow

Jak již bylo zmíněno v úvodu, některá workflow potřebují během svého života zobrazit formulář a získat data od uživatele, což ale není při automatickém testování možné. Zobrazení formuláře a následné čekání na vstup vede k zaseknutí workflow, a tedy i celého testu. Naším cílem je tedy zachovat workflow pokud možno nedotčené, ale zároveň se nějak elegantně zbavit zobrazování formulářů. Navíc nám zde vzniká podmínka, že data, která by uživatel do formuláře v normálním případě zadal, musíme do workflow ve správnou chvíli propašovat. Aby toho nebylo málo, větší projekty téměř nikdy nepracují s holými definicemi workflow (ProcessDefinition), ale používají vlastní třídy, kterými ProcessDefinition nějakým způsobem obalují (takto pracuje i např. CzechIdM). Obalové třídy nelze z testu vynechat, protože, dejme tomu, slouží k načítání ProcessDefinition z databáze. Z podobného důvodu se také nesmíme při řešení problému spoléhat na to, že se dostaneme ke kontextu daného workflow (ProcessInstance). Také je zapotřebí počítat s tím, že může být použita perzistence, např. Hibernate. Můžeme jedině předpokládat, že je možné ProcessDefinition upravit před vytvořením ProcessInstance. Všechny tyto předpoklady jsou zde sneseny na hromádku hlavně proto, že se vyskytují v dané podobě v CzechIdM, pro které byly testy implementovány. Nejprve se podívejme na to, jak vlastně dochází k zobrazení formuláře. Zobrazení je vázáno na tzv. akci – akce je reprezentovaná třídou org.jbpm.graph.def.Action a obsahuje objekt typu org.jbpm.instantiation.Delegation. Objekt Delegation má atribut className, který obsahuje kvalifikované jméno nějaké třídy implementující org.jbpm.graph.def.ActionHandler. Při spuštění akce je vytvořena instance třídy specifikované v atributu className a je zavolána její metoda execute(ExectuionContext arg0). Pokud se jedná o třídu určenou k zobrazení formuláře, zobrazí se (překvapivě) formulář.

Odstranění formulářů

Nyní jsme připraveni začít s řešením našeho problému. Nejprve se podíváme na to, jak odstranit zobrazování formulářů. Naštěstí víme, které akce zobrazují formulář (dejme tomu, že delegovaná třída se jmenuje „foo.form.ShowForm“). Stačí nám tuto delegaci přepsat na nějakou neškodnou prázdnou třídu, kterou si vytvoříme vedle (např. „foo.test.DummyAction“). Nyní nám stačí projít všechny akce uvnitř ProcessDefinition a provést nahrazení v atributech className. Pro jednoduchost příkladu předpokládejme, že nám stačí nahrazovat akce, které jsou přímo součástí org.jbpm.graph.def.Node, nikoli událostí (org.jbpm.graph.def.Event) apod. Odstraňování akcí vázaných na události probíhá naprosto stejně s jedním rozdílem – událost může obsahovat více akcí. Kód by mohl vypadat zhruba takto:

public void destroyActionsInWorkflow(ProcessDefinition definition){
      List<Node> nodes = definition.getNodes();
      if(nodes != null){
            for(Node node : nodes){
                  purifyNode(node);
            }
      }
}

protected void purifyNode(Node node){
      Action nodeAction = node.getAction();
      if(nodeAction == null ||  !nodeAction.getActionDelegation().getClassName().equals("foo.form.ShowForm"))
            return;

      nodeAction.getActionDelegation().setClassName("foo.test.DummyAction");
}

A je to. Tím jsme zneškodnili zobrazování formulářů. Samozřejmě velice rychle narazíme, pokud budeme chtít tento kód nějak rozšířit. Vytvoříme tedy třídu ProcessWrapper, která bude obhospodařovat ProcessDefinition. Pak již není problém připisovat podmínky, za kterých se má delegace třídy nahradit, vytvořit nahrazování více dummy třídami, atd. ProcessWrapper vytvoříme ale kvůli podstatně důležitější věci. To, co jsme právě udělali, totiž změnili ProcessDefinition nahrané v paměti, může mít nemilé následky. Je zapotřebí pamatovat, že pokud bychom se pustili do průchodu takovýmto workflow, uloží se nám do cache v Hibernate a přepíše původní ProcessDefinition. Navíc pokud bychom vypli aplikační server, upraví se nám ProcessDefinition i v databázi. Nejjednodušším řešením je použít metodu Hibernate frameworku setReadOnly(…), kterou odmítneme zápis upravené ProcessDefinition. Bohužel toto díky existenci obalových tříd nemusí být vždy realizovatelné. Proto do ProcessWrapperu implementujeme možnost vrácení změn (rollback). Náš ProcessWrapper by pak mohl vypadat třeba takto:

public class ProcessWrapper {
      protected ProcessDefinition definition;
      protected Map<Delegation, String> rollbackData;

      public ProcessWrapper(ProcessDefinition pd){
            definition = pd;
      }
      public void destroyActionsInWorkflow(){
            List<Node> nodes = definition.getNodes();
            if(nodes != null){
                  for(Node node : nodes){
                       purifyNode(node);
                  }
            }
      }

      protected void purifyNode(Node node){
            Action nodeAction = node.getAction();
            if(nodeAction == null || !nodeAction.getActionDelegation().getClassName().equals("foo.form.ShowForm"))
                  return;

            registerForRollback(nodeAction);
            nodeAction.getActionDelegation().setClassName("foo.test.DummyAction");
      }

      public void rollbackProcessDefinition(){
            for(Delegation d : rollbackData.keySet()){
                  d.setClassName(rollbackData.get(d));
            }
      }

      protected void registerForRollback(Action a){
            if(rollbackData.containsKey(a.getActionDelegation()))
                  ;
            else
                 rollbackData.put(a.getActionDelegation(), a.getActionDelegation().getClassName());
     }
}

Tím jsme zajistili, že ProcessWrapper bude schopen vrátit změny, které provedl. Metodu rollbackProcessDefinition() budeme volat jako jednu z úklidových metod po skončení testu. Poté, co navrátíme ProcessDefinition do původního stavu, ji totiž bude ještě nutné zapsat do cache. Nyní jsme dokončili nahrazování zobrazování formulářů. Je na čase zprovoznit vkládání proměnných.

Vkládání proměnných do ExecutionContextu „za běhu“

Jak jsme předpokládali na začátku, nemáme bohužel přístup k objektu ProcessInstance, který je skryt obalovou třídou. Zjistili jsme ale, že obalová třída nám při svém vytvoření naštěstí umožní vložit do ní počáteční proměnné. Použijeme poněkud špinavý trik. Vytvoříme zásobník na proměnné pro celé workflow, který procpeme, ve formě jediné proměnné, hned na počátku dovnitř ExecutionContextu. Jsme schopni to udělat proto, že jako proměnná (přesněji její hodnota) se do contextu dá poslat i jakýkoliv serializovatelný objekt. Vytvořme tedy třídu VariableInjector:

public class VariableInjector implements java.io.Serializable{

      public static final String injectorContextualName = "injector13479876546131467";
      protected Map<String,Map<String, Object>> injectionDoses;

      public VariableInjector(){
            injectionDoses = new HashMap<String,Map<String, Object>>();
      }
      public Map<String, Object> injectVariables(String nodeName){
            Map<String, Object> dose = injectionDoses.get(nodeName);
            return dose != null ? dose : new HashMap<String, Object>();
      }

      public boolean setVariable(String nodeName, String varName, Object object){
           if(!injectionDoses.containsKey(nodeName))
	         injectionDoses.put(nodeName, new HashMap<String, Object>());

	   Map<String, Object> dose = injectionDoses.get(nodeName);

	   if(dose.containsKey(varName))
	         dose.remove(varName);

	   dose.put(varName, object);
	   return true;
      }
}

V opravdové implementaci má VariableInjector spoustu getterů a setterů, avšak ty nejsou podstatou funkčnosti. Protože si je jistě dovedete představit, pro přehlednost jsem je vynechal. Všimněte si také konstanty injectorContextualName. To je jméno proměnné, která skrývá VariableInjector v ExecutionContextu. Jméno zní možná dost bláznivě – je vybráno tak, aby bylo nepravděpodobné, že se někdo pokusí setovat proměnnou stejného jména. Mapa injectionDoses má následující význam:

injectionDoses<jménoNoduVeWorkflow,Map<jménoProměnné, hodnotaProměnné>>

Injekční třída (níže) si pomocí metody injectVariables(…) z VariableInjectoru vytáhne mapu proměnných, které má zapsat do ExecutionContextu. Injekční třídou bude naše foo.test.DummyAction, která doposud vypadala takto:

public class DummyAction implements ActionHandler{
      public void execute(ExecutionContext arg0) throws Exception {
      }
}

Změníme vnitřek metody execute tak, aby byla schopná pracovat s VariableInjectorem:

public class DummyAction implements ActionHandler{
      public void execute(ExecutionContext arg0) throws Exception {
            String myNodeName = arg0.getToken().getNode().getName();

            VariableInjector inj = arg0.getVariable(VariableInjector.injectorContextualName);
            if(inj != null){
                  Map<String, Object> dose = inj.injectVariables(myNodeName);
                  for(String s:dose.keySet())
                       arg0.setVariable(s, dose.get(s));
            }
      }
}

Od teď pokaždé když bude spuštěna akce DummyAction, zjistí si jméno Node, odkud byla zavolána, pokusí se vytáhnout z ExecutionContextu VariableInjector a pokud se jí to povede, řekne si o proměnné, které má do contextu nastavit. Nastaví všechny proměnné, které jí injector předá. Tím je vyřešena i simulace vyplnění formuláře uživatelem – přesně v okamžiku, kdy by se zobrazil formulář, se nasetují předpřipravené proměnné. Kostra frameworku je hotova, nyní zbývá to všecho nějak hezky zabalit. Ještě než se do toho pustíme, je zapotřebí se zmínit o těch obalových třídách…

Obalové třídy

O obalových třídách bylo zmíněno jen něco málo na začátku, kde byly představeny v možná poněkud horším světle a spíše jako překážka poklidného testování. Vlastně kvůli nim jsme páchali dosavadní psí kusy. Faktem je, že obalové třídy nám šetří práci se správou workflow. Z pohledu CzechIdM se nás týkají tři obalové třídy. Třída pro správu workflow, říkejme jí třeba WorkflowControler, třída pro správu ProcessDefinition, kterou nazveme WorkflowDefinition, a konečně třída pro správu spuštěného workflow aka WorkflowInstance. Jejich vztah je následující: WorkflowControler načítá definici workflow z perzistentního úložiště a vytváří kolem ní WorkflowDefinition. WorkflowDefinition obaluje ProcessDefinition daného workflow a v příhodnou dobu se s její pomocí vytváří WorkflowInstance. WorkflowInstance obaluje běžící workflow. Můžeme o nich předpokládat: WorkflowControler: Vrátí nám na požádání WorkflowDefinition podle jména workflow, které mu zadáme. WorkflowDefinition: Obsahuje přístupnou referenci na ProcessDefinition. Umožní nám uložit svou ProcessDefinition do cache. WorkflowInstance: Při vytváření jí můžeme předat Map<String, Object> s počátečními proměnnými a případný odkaz na mateřské workflow. Pracuje s ní samotný test.

Stavba frameworku

Z frameworku již máme dost věcí hotových. Máme ProcessWrapper, který nám spravuje změny v ProcessDefinition. Máme VariableInjector, zásobník na proměnné. Máme i třídu, která tyto proměnné z injectoru registruje v konkrétním ExecutionContextu. Jediné, co stojí mimo naši správu, jsou obalové třídy. Vytvoříme tedy ještě třídu WorkflowWrapper, která bude zastřešovat obalové třídy a zbytek frameworku, a tedy tvořit rozhraní, které bude test používat:

public class WorkflowWrapper {

      protected WorkflowControler wfc;
      protected WorkflowDefinition wfd;
      protected WorkflowInstance wfi;
      protected ProcessWrapper pw;
      protected String workflowName;

      public WorkflowWrapper(String workflowName){
            this.workflowName = workflowName;

            wfc = ... //vytvoreni WorkflowControler
            wfd = wfc.retrieveWorkflowDefinition(workflowName);
            pw = new ProcessWrapper(wfd.getProcessDefinition());
      }

      public VariableInjector prepareWorkflowDefinition(){
            pw.destroyActionsInWorkflow();
            return pw.getInjector();
      }

      public WorkflowInstance createWorkflowInstance(Map<String, Object> initialVariables, WorkflowInstance parentWorkflow){
            if(initialVariables == null)
                  initialVariables = new HashMap<String, Object>();

            if(initialVariables.containsKey(IVariableInjector.injectorContextualName))
                  ;
            else
                  initialVariables.put(IVariableInjector.injectorContextualName, pw.getInjector());

            wfi = new WorkflowInstance(wfd, initialVariables, parentWorkflow);
            return wfi;
      }

      public void afterTestCleanUp(){
            pw.rollbackProcessDefinition();
            wfd.saveProcessDefinition();
      }
}

Pro přehlednost opět chybí gettery a settery. Všechna volání, která vyřizuje ProcessWrapper jsou pouze předávána dál. Je to jen pomůcka pro pohodlné, aby test nemusel komunikovat s nikým jiným než s WorkflowWrapperem (a s injectorem). V konstruktoru je vytvořena hierarchie obalových tříd tak, jak je zapotřebí. Je načtena definice workflow. Metoda prepareWorkflowDefinition() slouží ke spuštění úpravy ProcessDefinition. Všimněte si,  že metoda vrací VariableInjector. To je proto, že nyní (po úpravě) je vhodný čas pro nastavení případných vkládaných proměnných. V okamžiku, kdy bude vytvořena WorkflowInstance může dojít k serializaci injectoru a další změny na něj pak nebudou mít vliv. Injector je spravován ProcessWrapperem – ten ještě musíme mírně upravit. Metoda createWorkflowInstance(…) vytváří WorkflowInstance, se kterou bude test pracovat. Mezi počáteční proměnné přidá VariableInjector. Metoda afterTestCleanUp() slouží k úklidu po testu: rekonstrukci původního ProcessDefinition a jeho uložení do cache. Metody vykonávají separátně kroky přípravy workflow na testování, aby umožnily dílčí testy či případná podrobnější nastavení, pokud je součásti frameworku implementují. Poslední nutnou věcí je svázání ProcessWrapperu s VariableInjectorem. Do ProcessWrapperu stačí přidat proměnnou na referenci, zajistit vytvoření VariableInjectoru (například v konstruktoru ProcessWrapperu nebo na jiném vhodném místě) a metodu ProcessWrapper.getInjector() na získání reference. Tím je funkční část frameworku hotova. Pro lepší modularitu je vhodné zakrýt ProcessWrapper a VariableInjector vlastními interfacy. A samozřejmě ve stejném duchu pozměnit kód na odpovídajících místech.

Použití frameworku v testu

Aby nezůstalo jen u suché teorie, nakonec se podíváme ještě na trochu suché praxe. Takto vypadá kostra na použití vytvořeného frameworku v TestNG testu:

public class FrameworkTest extends SeamTest {

      @Test
      public void frameworkTest() throws Exception{
            new ComponentTest(){
                  @Override
                  protected void testComponents() throws Exception{
                        WorkflowWrapper wfw = null;
                        String name = "moje.super.uzasne.workflow";
                        Map<String, Object> initialVariables = new HashMap<String, Object>();
                        initialVariables.put("userName", "uzivatel");

                        try {
                              wfw = new WorkflowWrapper(name);
                              /*
                              * Tady mohou byt ruzne kontroly nad puvodni ProcessDefinition. Take tu muze byt podrobnejsi nastavovani cistitka.
                              */
                              VariableInjector inj = wfw.prepareWorkflowDefinition();
                              /*
                              * Zde mohou byt kontroly nad upravenym ProcessDefinition.
                              * Do objektu injectoru muzeme nasetovat promenne, ktere pak budou pridany
                              * mezi promenne v ExecutionContext.
                              */
                              inj.setVariable("showFormNode", "kontrolni promenna", "uspesne injectnuto");
                              WorkflowInstance wfi = wfw.createWorkflowInstance(initialVariables);

                              //PRUCHOD PRES WORKFLOW - toto je na uzivateli/testu
                              wfi.goToNextState(); //provede volani ProcessInstance.signal()

                              Map<String, Object> check = wfi.getVariables(); //ukazkova kontrola
                              assert check.containsKey("kontrolni promenna");
                              assert check.get("kontrolni promenna").equals("uspesne injectnuto");
                              //KONEC PRUCHODU PRES WORKFLOW

                        } finally {
                              /*
                              * Povinny cleanUp.
                              */
                              if(wfw != null)
                                    wfw.afterTestCleanUp();
                        }
                  }
            }.run();
      }
}

Závěr

Vytvořili jsme kostru frameworku pro automatické testování formulářových workflow a diskutovali jsme hlavní myšlenky řešení a jejich realizaci. Nakonec jsme si ukázali, jak se framework používá v testu. Některé vlastnosti reálně používaného frameworku – způsob definice více nahrazených akcí, možnosti bližší specifikace nahrazování, použití modifikovaného injectoru nebo ProcessWrapperu – byly z článku kvůli přehlednosti vypuštěny. Taktéž zdrojový kód v příkladech byl upraven kvůli čitelnosti a neodráží zcela přesně implementaci, která se používá k testům pro CzechIdM. Všechna zjednodušení byla učiněna pouze v zájmu přehlednosti a srozumitelnosti. Pokud máte k článku dotazy nebo veřejně nepublikovatelné připomínky :) , zašlete je prosím na info@bcvsolutions.eu.