Beispiel: JSFUnit


Inhalt:

Vorbereiten der Application
Der Test
Ausführen
Security

Beispiel für einen Unit-Test, der die Ergebnisse aus dem JSF-Framework analysiert.
Dieses Projekt baut auf dem KuchenZutatJSF-Beispiel auf und kann deshalb nicht gleichzeitig mit diesem auf dem Server deployed werden.

Hier gibt es das Projekt zum Download (dies ist ein EAR-Export, die Importanleitung findet man im Stateless-Beispiel): KuchenZutatJSF.ear
Das Beispiel enthält bereits alle Libraries aus dem Schritt Vorbereiten der Application.


Vorbereiten der Application

JSFUnit ist ein Unit-Test-Framework von JBoss, das einerseits einen Client-Browser simuliert, sich andererseits in JSF einklinkt und z.B. den Zugriff auf den Komponentenbaum erlaubt.
Webseite:
http://www.jboss.org/jsfunit)

Für einen Einstieg: http://www.jboss.org/jsfunit/gettingstarted.html

1. Libraries
Ich verwende folgende Libraries (weiß allerdings nicht, ob die wirklich alle nötig sind):
Hier gibt es das Paket: jsfunit-2009-02-26.zip
.
Hinweis: die Datei "cargo-0.5.jar" ist im Original eigentlich kaputt, denn sie enthält eine Datei mehrfach im gleichen Verzeichnis. Dies führt beim Deploy zu Fehlern. In meinem Paket ist sie korrigiert.

Diese Libraries werden nach WEB-INF\lib kopiert. Anschließend muss im Eclipse-Project Explorer "Refresh" gewählt werden, damit Eclipse die neuen Libraries erkennt.

Anmerkung:
JSFUnit-Tests müssen leider im gleichen Projekt liegen wie die zu testende Anwendung, da sonst kein Zugriff auf den JSF-FacesContext möglich ist.
Schicker wäre natürlich eine zweite Webanwendung, aber das ist wohl nicht möglich.


2. cactus-report.xsl
Folgende Datei muss heruntergeladen und in "WebContent" platziert werden.
http://jakarta.apache.org/cactus/misc/cactus-report.xsl

Anschließend im Eclipse den Project Explorer über "Refresh" aktualisieren.

Diese Datei wird zu einer Warnung führen: "No grammar constraints (DTD or XML schema) detected for the document."
Da wir die Warnung nicht beheben könnnen, wird die Validierung für diese Datei abgeschaltet: In den Projekt-Properties auf "Validation" gehen, den Haken bei "Enable project specific settings" setzn und in der Zeile des "XML Validator" auf den Button mit den drei Punkten klicken.
XML Validator
Im "Validation Filters"-Baum die Gruppe "Exclude Group" wählen und auf "Add Rule..." klicken.
Validation Filters
Im erscheinenden Dialog die Option "Folder or file name" wählen:
Exclude rule (1)
Im nächsten Schritt wird über "Browse File" die zu ignorierende Datei "cactus-report.xls" ausgewählt.
Exclude rule (2)
Jetzt taucht die Datei in der "Exclude Group" auf:
Validation Filters (2)
Ein "Clean" mehr, und die Warnung ist weg.


3. web.xml
In "web.xml" wird folgendes eingetragen:
   <filter>
     <filter-name>JSFUnitFilter</filter-name>
     <filter-class>org.jboss.jsfunit.framework.JSFUnitFilter</filter-class>
   </filter>

   <filter-mapping>
     <filter-name>JSFUnitFilter</filter-name>
     <servlet-name>ServletTestRunner</servlet-name>
   </filter-mapping>	
   
   <filter-mapping>
     <filter-name>JSFUnitFilter</filter-name>
     <servlet-name>ServletRedirector</servlet-name>
   </filter-mapping>
 
   <servlet>
     <servlet-name>ServletRedirector</servlet-name>
     <servlet-class>org.jboss.jsfunit.framework.JSFUnitServletRedirector</servlet-class>
   </servlet>
   
   <servlet>
      <servlet-name>ServletTestRunner</servlet-name>
      <servlet-class>org.apache.cactus.server.runner.ServletTestRunner</servlet-class>
   </servlet> 

   <servlet-mapping>
     <servlet-name>ServletRedirector</servlet-name>
     <url-pattern>/ServletRedirector</url-pattern>
   </servlet-mapping> 
   
   <servlet-mapping>
      <servlet-name>ServletTestRunner</servlet-name>
      <url-pattern>/ServletTestRunner</url-pattern>
   </servlet-mapping>


Anmerkung: JSFUnit bietet eine Möglichkeit, eine vorhandene Webanwendung mit integrierten Tests (aber ohne integrierte JSFUnit-JARs/-Config) automatisch für JSFUnit anzupassen: http://www.jboss.org/community/docs/DOC-10975
Dies habe ich noch nicht ausprobiert.

Der Test

Leider verwendete JSFUnit noch JUnit 3, also muss man ohne Annotations arbeiten.

Testklasse
Der Rumpf der Testklasse sieht so aus:
public class KuchenNeuTest extends ServletTestCase
{
  ...
}
Der Test ist also von org.apache.cactus.ServletTestCase abgeleitet.

Testsuite In einer static Methode (die wohl "suite" heißen muss) wird die Testsuite erzeugt:
  public static Test suite()
  {
    return new TestSuite( KuchenNeuTest.class );
  }
Anmerkung: die Testsuite könnte auch von einer separaten Klasse initialisiert werden, siehe Aufruf der Anwendung.

Initialisierung
Unser Test definiert zwei Membervariablen:
  private JSFClientSession client;
  private JSFServerSession server;
Die Klasse org.jboss.jsfunit.jsfsession.JSFServerSession steuert die Zugriffe auf den Server (also Seitenaufrufe und Auswertung der Antworten).
Die Klasse org.jboss.jsfunit.jsfsession.JSFClientSession dient dazu, Formulare auszufüllen und abzuschicken (simuliert also die User-Interaktion).

In der überladenen Methode "setUp" wird der Test initialisiert: die Startseite "/kuchenliste.faces" wird aufgerufen, und die Variablen "client" und "server" werden initialisiert.
  @Override
  public void setUp() throws IOException
  {
    JSFSession jsfSession = new JSFSession("/kuchenliste.faces");
    
    this.client = jsfSession.getJSFClientSession();
    this.server = jsfSession.getJSFServerSession();
  }

Jetzt bauen wir den Test:
  public void testKuchenNeu() throws IOException
  {
    ...
  }
Alle Testmethoden müssen mit "test..." beginnen, damit das JUnit 3-Framework sie erkennt. Die hier geworfene IOException stammt aus dem JSFUnit-Paket und gehört nicht zur Standard-Signatur von Testmethoden.

Test-Schritt 1: Eingabe eines neuen Kuchens
Es soll auf der "kuchenliste.jsp" der Button "Neuer Kuchen" geklickt werden:
    this.client.click("formkuchenneu:kuchenneu");
    assertEquals("/kuchendetail.jsp", server.getCurrentViewID());
Dies geschieht durch Aufruf der Methode click der JSFClientSession. Übergeben wird die volle ID des zu klickenden Buttons (diese kann man aus dem Seitenquelltext holen. Jetzt wird der Request vom JSFUnit-Framework ausgeführt.
Anschließend wird geprüft, dass die Ergebnis-View wirklich "/kuchendetail.jsp" heißt.

Es wird das Formular ausgefüllt und auf "OK" geklickt:
this.client.setValue("formkuchen:name", "Testkuchen");
    
    this.client.click("formkuchen:kuchensave");
    
    assertEquals("/kuchenliste.jsp", server.getCurrentViewID());
Die Methode JSFClientSession.setValue schreibt einen Wert in ein HTML-Eingabefeld.
Danach wird der Submit-Button des Formulars geklickt und geprüft, dass die Ergebnisview wiederum "/kuchenliste.jsp" ist.

Test-Schritt 2: Prüfen der Kuchenliste
Es wird geprüft, ob in der Kuchenliste der neue Kuchen auftaucht. Da davon auszugehen ist, dass der Test nicht auf einer leeren Datenbank läuft, wird der vom h:dataTable-Tag generierte Datatable durchlaufen und in jeder Zeile nach dem String "Testkuchen" gesucht. Das einzige Problem hierbei: wie bekommen wir die Zeilenzahl des DataTable? Aus der HTML-Seite ist diese Info nicht ermittelbar (zumindest nicht ohne Parsen derselben und Zählen der Zeilen), doch zum Glück bietet uns das JSFUnit-Framework die Möglichkeit, auf den der Tabelle zugrundeliegenden javax.faces.component.html.HtmlDataTable zuzugreifen. Wir nutzen dessen Zeilenzahl für eine Schleife. Auf die Komponenten einer jeden Zeile können wir zugreifen, indem wir den Zeilenindex in die ID der Komponente integrieren.

    UIComponent component = this.server.findComponent("tablekuchen");
    HtmlDataTable table = (HtmlDataTable) component;
    
    int intRowKuchen = -1;
    for (int intIndex = 0; intIndex < table.getRowCount() && intRowKuchen == -1; intIndex++)
    {
      Object objKuchen = this.server.getComponentValue("tablekuchen:" + intIndex + ":name");
      
      String strKuchen = (String) objKuchen; 
      if ("Testkuchen".equals(strKuchen) == true)
      {
        intRowKuchen = intIndex;
      }
    }
    assertTrue("Kuchen nicht im DataTable gefunden", intRowKuchen != -1);
Wenn am Ende der Schleife die Variable intRowKuchen immer noch auf "-1" steht, dann haben wir nix gefunden.

Achtung:
Der Zugriff auf Tabellen funktioniert nur auf der obersten Hierarchie-Ebene. Wenn man auf eine Zelle einer Tabelle in einer Tabelle zugreifen will, (this.server.getComponentValue("tableKuchen:1:tableInner:2:somefield") für Zugriff auf Zeile 1 der äußeren Tabelle, dann Zeile 2 der inneren Tabelle) schlägt das fehl, wir erhalten immer Daten der letzten Zeile der äußeren Zeile. Dies ist ein Bug in der Sun-Referenzimplementation, und ab Version 1.2_13 behoben.
Siehe
http://www.jboss.com/index.html?module=bb&op=viewtopic&t=148518&start=10#4202710


Test-Schritt 3: Bearbeiten des Kuchens
Es wird der "Bearbeiten"-Link geklickt, und anschließend im Bearbeiten-Formular von "kuchendetail.jsp" auf den Link "Zurück zur Kuchenliste" geklickt. Der Bearbeiten-Button ist die Komponente "tablekuchen:xxx:formkuchenedit:kuchenedit" (wobei "xxx" dem Wert der Variablen "intRowKuchen" entspricht).
this.client.click("tablekuchen:" + intRowKuchen + ":formkuchenedit:kuchenedit");

    assertEquals("/kuchendetail.jsp", server.getCurrentViewID());

    Object objValue = this.server.getComponentValue("formkuchen:name");
    assertEquals("Gefundener Kuchen heißt nicht 'Testkuchen', sondern '" + objValue + "'", "Testkuchen", objValue.toString());
    
    this.client.click("formnavigation:navigationkuchenliste");
    assertEquals("/kuchenliste.jsp", server.getCurrentViewID());
Hier wird geprüft, dass nach dem Klick auf "Bearbeiten" die Seite "/kuchendetail.jsp" aufgerufen wird, und dass im Feld "Name" der Wert "Testkuchen" steht. Anschließend wird auf den Link "Zurück zur Kuchenliste" geklickt und geprüft, dass wir wieder auf der "/kuchenliste.jsp" sind.

Test-Schritt 4: Löschen
Hier wird auf den Löschen-Link des oben gefundenen Kuchens geklickt (wobei der Test davon ausgeht, dass der Kuchen immer noch am gleichen Zeilenindex im DataTable steckt wie in Schritt 2).
    this.client.click("tablekuchen:" + intRowKuchen + ":formkuchendelete:kuchendelete");
    assertEquals("/kuchenliste.jsp", server.getCurrentViewID());
    
    //Check auf gekillten Kuchen:
    intRowKuchen = -1;
    component = this.server.findComponent("tablekuchen");
    table = (HtmlDataTable) component;
    for (int intIndex = 0; intIndex < table.getRowCount() && intRowKuchen == -1; intIndex++)
    {
      Object objKuchen = this.server.getComponentValue("tablekuchen:" + intIndex + ":name");
      
      String strKuchen = (String) objKuchen; 
      if ("Testkuchen".equals(strKuchen) == true)
      {
        intRowKuchen = intIndex;
      }
    }
    assertTrue("Kuchen trotz Löschen im DataTable gefunden", intRowKuchen == -1);
Einziges Problem bei diesem Test: wenn es in der Datenbank einen zweiten Kuchen mit dem Namen "Testkuchen" gibt, dann schlägt er fehl.


Ausführen

Im Browser:
Das Ausführen geschieht denkbar einfach: folgende URL im Browser aufrufen:
http://localhost:8080/KuchenZutatJSFWeb/ServletTestRunner?suite=de.fhw.komponentenarchitekturen.knauf.kuchenzutatjsf.test.KuchenNeuTest&xsl=cactus-report.xsl

Achtung:
Nach einigen Redeploys gab es bei mir regelmäßig eine Fehlermeldung "Failed to get the test results".
In der Server-Konsole tauchten Meldungen wie diese auf:
21:14:04,734 ERROR [[ServletRedirector]] Servlet.service() for servlet ServletRedirector threw exception
java.lang.IllegalStateException
	at com.sun.faces.context.FacesContextImpl.assertNotReleased(FacesContextImpl.java:395)
	at com.sun.faces.context.FacesContextImpl.getViewRoot(FacesContextImpl.java:294)
	at org.jboss.jsfunit.context.JSFUnitFacesContext.getViewRoot(JSFUnitFacesContext.java:199)
	at org.jboss.jsfunit.context.JSFUnitFacesContext.viewHasChildren(JSFUnitFacesContext.java:285)
	at org.jboss.jsfunit.context.JSFUnitFacesContext.release(JSFUnitFacesContext.java:215)
	at org.jboss.jsfunit.context.JSFUnitFacesContext.cleanUpOldFacesContext(JSFUnitFacesContext.java:332)
	at org.jboss.jsfunit.framework.JSFUnitFilter.doFilter(JSFUnitFilter.java:99)
	...
Dies war nur durch einen Server-Neustart zu beheben.

Ein Workaround ist hier beschrieben: http://www.jboss.com/index.html?module=bb&op=viewtopic&t=148593#4203793
Es wird hierzu in einer Config-Datei ein Parameter geändert.

In JBoss 5.0.1GA habe ich diesen Fehler nicht mehr gesehen. Hoffentlich wurde hier etwas geändert.


Als Unit-Test in Eclipse:
Man kann den Unit-Test auch direkt aus Eclipse heraus starten.
Hierzu wird eine neue "Run Configuration" des Typs "JUnit" angelegt:
Run Configuration (1)
Wichtig hierbei: Auf dem Karteireiter "Arguments" muss der Parameter cactus.contextURL angegeben werden:
Run Configuration (2)
Er gibt die URL an, unter der die Unit-Tests auf dem Server zu finden sind:
-Dcactus.contextURL=http://localhost:8080/KuchenZutatJSFWeb
Jetzt können wir die Tests wie ganz normale Unittests in Eclipse laufen lassen.


Security

Quelle: http://www.jboss.org/community/docs/DOC-10974

Login:
Erfordert eine Anwendung eine User-Authentifizierung, so muss der Unit-Test so initialisiert werden:
    WebClientSpec wcSpec = new WebClientSpec("/mytargetpage.faces");
    FormAuthenticationStrategy formAuth = new FormAuthenticationStrategy("username", "password", "name of login button");
    wcSpec.setInitialRequestStrategy(formAuth);
    JSFSession jsfSession = new JSFSession(wcSpec);
Die Klasse WebClientSpec ist die Schnittstelle zwischen JSFUnit und dem darunterliegenden Framework "HtmlUnit", dass sich als Browser ausgibt und die Kommunikation mit dem Server übernimmt. Ihr wird die aufzurufende Zielseite sowie eine Klasse für die Authentifizierung übergeben. Diese Authentifizierungsklasse ist hier eine Form-Authentication, der Benutzer und Passwort übergeben werden. Der dritte Konstruktor-Parameter von FormAuthenticationStrategy gibt den Namen des Submit-Buttons des Login-Formulars an (den "HtmlUnit" intern ja klicken muss, nachdem es die Felder "j_username" und "j_password" ausgefüllt hat). In meinem Security-Beispiel ist dies "login":
<form method="post" ACTION="j_security_check">
Login: <input type="text" name="j_username" /> <br>
Passwort: <input type="password" name="j_password" /> <br>
<input type="submit" name="login" value="Login"></form>
Logout:
Der Logout kann über den Klick auf den "Logout"-Button erfolgen. Allerdings ist zu beachten, dass das Beenden der Session nicht auf einer JSP-Seite über "session.invalidate();" erfolgen darf (wie bisher in meinem Security-Beispiel!), da JSFUnit die Session weiterhin benötigt. Deshalb sollte die Logout-Seite idealerweise als JSF-Seite implementiert sein (in meinem Beispiel: "/pages/jsflogout.jsp"), und die Session über die HttpSession beenden:
<%@ page import="javax.faces.context.ExternalContext, javax.faces.context.FacesContext" 
language="java"
contentType="text/html; charset=ISO-8859-1"
pageEncoding="ISO-8859-1"

%>
<%@ taglib uri="http://java.sun.com/jsf/html" prefix="h"%>
<%@ taglib uri="http://java.sun.com/jsf/core" prefix="f"%>

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">

<HTML>
<HEAD>
<META http-equiv="Content-Type" content="text/html; charset=ISO-8859-1">
<TITLE>Logout</TITLE>
</HEAD>
<BODY>
<f:view>

<P>Sie wurden abgemeldet ! </P>
<a href="jsfindex.faces">Zurück zur Startseite</a>
</f:view>

<%
  //Session beenden:
  ExternalContext extCtx = FacesContext.getCurrentInstance().getExternalContext();
  HttpSession sessionHttp = (HttpSession)extCtx.getSession(false);
  sessionHttp.invalidate();
%>
</BODY>
</HTML>
Wichtig ist hier, dass das Beenden der Session erst NACH dem schließenden <f:view>-Tag erfolgen darf. Ansonsten führt der Aufruf der Seite zu einer Fehlermeldung.
Quelle dieses Tips: http://www.jboss.com/index.html?module=bb&op=viewtopic&t=148854#4202720


Hier gibt es eine modifizierte Variante des Security-Beispiels: Security.ear. Ich musste folgendes ändern:
Hier der vollständige Code:
package de.fhw.komponentenarchitekturen.knauf.security.test;

import java.io.IOException;

import junit.framework.Test;
import junit.framework.TestSuite;

import org.apache.cactus.ServletTestCase;
import org.jboss.jsfunit.framework.FormAuthenticationStrategy;
import org.jboss.jsfunit.framework.WebClientSpec;
import org.jboss.jsfunit.jsfsession.JSFClientSession;
import org.jboss.jsfunit.jsfsession.JSFServerSession;
import org.jboss.jsfunit.jsfsession.JSFSession;

public class SecurityTest  extends ServletTestCase
{
  private JSFClientSession client;
  private JSFServerSession server;
  
  public static Test suite()
  {
    return new TestSuite( SecurityTest.class );
  }
  
  @Override
  public void setUp() throws IOException
  {
    WebClientSpec wcSpec = new WebClientSpec("/pages/jsfindex.faces");
    FormAuthenticationStrategy formAuth = new FormAuthenticationStrategy("admin", "admin", "login");
    wcSpec.setInitialRequestStrategy(formAuth);
    JSFSession jsfSession = new JSFSession(wcSpec);

    this.client = jsfSession.getJSFClientSession();
    this.server = jsfSession.getJSFServerSession();
  }
  
  public void testSecurity() throws IOException
  {
    String strValue = (String) this.server.getComponentValue("somestring");
    assertEquals("Testtext!", strValue);
	
    //Ausloggen:
    this.client.click("formlogout:logout");
    //Und wir sollten auf "/pages/jsflogout.jsp" sein:
    assertEquals("/pages/jsflogout.jsp", this.server.getCurrentViewID());
  }
}
Für den Aufruf des Tests muss folgende URL im Browser eingetragen werden:
http://localhost:8080/SecurityWeb/ServletTestRunner?suite=de.fhw.komponentenarchitekturen.knauf.security.test.SecurityTest&xsl=cactus-report.xsl
Stand 18.03.2009
Historie:
14.01.2009: Beispiel erstellt
18.01.2009: Validierungsfehler in "cactus-report.xsl", Abschnitt "Security" zugefügt.
19.01.2009: Logout im Security-Abschnitt zugefügt.
24.01.2009: Update für "geschachtelte Tabellen"-Bug, Workaround für Fehler nach Redeploys
26.02.2009: Update auf JSFUnit 1.0GA
18.03.2009: "Ausführen"-Doku erweitert um "Ausführen in Eclipse"