Beispiel: Unit-Test


Inhalt:

Vorbereiten der Application
Anlegen des Application Clients
Hinzufügen der JUnit-Libraries
Vorbereitung des Tests
Erstellen eines Tests
Test ausführen
Unit-Test mit Security

Beispiel für einen Unit-Test.
Dieses Projekt baut auf dem KuchenZutatNM-Beispiel auf.
Hier gibt es das Projekt zum Download (dies ist ein EAR-Export, die Importanleitung findet man im Stateless-Beispiel): KuchenZutatNM.ear
Nach dem Import müssen die JUnit-Libraries zum Classpath zugefügt werden (siehe "Hinzufügen der JUnit-Libraries").

Vorbereiten der Application

Es wird die Enterprise Application "KuchenZutatNM" aus dem KuchenZutatNM-Beispiel verwendet. Dies wird in Eclipse importiert.

Am Code des EJB-Projekts sind folgende Änderungen vorzunehmen:
Ein Remote-Interface für den KuchenZutatNMWorkerRemote muss zugefügt werden:
@Remote()
public interface KuchenZutatNMWorkerRemote
{
  public void saveKuchen(KuchenNMBean kuchen);

  public List<KuchenNMBean> getKuchen();

  public KuchenNMBean findKuchenById(Integer int_Id);

  public void deleteKuchen(KuchenNMBean kuchen);

  public void addZutatToKuchen (KuchenNMBean kuchen, ZutatNMBean zutat);
  
  public void removeZutatFromKuchen (KuchenNMBean kuchen, ZutatNMBean zutat);
  
  public ZutatNMBean findZutatById(Integer int_Id);

  public void deleteZutat(ZutatNMBean zutat);

  public void saveZutat(ZutatNMBean zutat);

  public List<ZutatNMBean> getZutaten();
} 
Die Bean muss natürlich auch dieses Interface implementieren:
@Stateless
public class KuchenZutatNMWorkerRemoteBean implements KuchenZutatNMWorkerRemoteLocal, KuchenZutatNMWorkerRemote
{ 


Anlegen des Application Clients

Wir fügen der Enterprise Application ein Application Client-Projekt zu:
Application Client (1)
Das Projekt soll "KuchenZutatNMTestClient" heißen. Wichtig ist dass wir es zur Enterprise Application "KuchenZutatNM" zufügen:
Application Client (2)
Alle weiteren Einstellungen können wir auf dem Default lassen.

Wichtig ist, dass wir uns im letzten Schritt den Deployment-Deskriptor "application-client.xml" erzeugen lassen.

Das Projekt muss in den "Java EE Module Dependencies" das EJB-Modul referenzieren.

Jetzt fügen wir eine Referenz auf das Remote Interface der "KuchenZutatNMWorkerRemoteBean" zu:
"application-client.xml":
<?xml version="1.0" encoding="UTF-8"?>
<application-client id="Application-client_ID" version="5"
	xmlns="http://java.sun.com/xml/ns/javaee"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/application-client_5.xsd">
	<display-name>
	KuchenZutatNMTestClient</display-name>
	
	<ejb-ref>
		<ejb-ref-name>ejb/KuchenZutatNMWorkerRemote</ejb-ref-name>
		<ejb-ref-type>Session</ejb-ref-type>
		<remote>de.fhw.komponentenarchitekturen.knauf.kuchenzutatnm.KuchenZutatNMWorkerRemote</remote>
	</ejb-ref>
</application-client> 
"jboss-client.xml":
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE jboss-client
    PUBLIC "-//JBoss//DTD Application Client 5.0//EN" 
	"http://www.jboss.org/j2ee/dtd/jboss-client_5_0.dtd" >
<jboss-client>
	<jndi-name>KuchenZutatNMTestClient</jndi-name>
	<ejb-ref>
		<ejb-ref-name>ejb/KuchenZutatNMWorkerRemote</ejb-ref-name>
		<jndi-name>KuchenZutatNM/KuchenZutatNMWorkerRemoteBean/remote</jndi-name>
	</ejb-ref>
</jboss-client> 

Wichtig ist, dass "Manifest.mf" des Clients keine Zeile "Main-Class" enthalten ist, da unsere Anwendung keine solche enthält.


Vorbereitung des Tests

Ich habe mir eine Util-Klasse "KuchenZutatTestUtil" gebaut, die einige Konstanten deklariert und Hilfsmethoden bietet.
public class KuchenZutatTestUtil
{
  /**Name des ersten Kuchens in unseren Tests. */
  public static final String KUCHENNAME1 = "TESTKUCHEN1";
  
  /**Name des zweiten Kuchens in unseren Tests. */
  public static final String KUCHENNAME2 = "TESTKUCHEN2";
  
  /**Name der ersten Zutat in unseren Tests. */
  public static final String ZUTATNAME1 = "TESTZUTAT1";
  
  /**Name der zweiten Zutat in unseren Tests. */
  public static final String ZUTATNAME2 = "TESTZUTAT2";
  
  public static KuchenNMBean getKuchenByName (KuchenZutatNMWorkerRemote kuchenZutatWorker, String strKuchenName)
  {
    List<KuchenNMBean> listKuchen = kuchenZutatWorker.getKuchen();
    
    for (KuchenNMBean kuchenAktuell : listKuchen)
    {
      if (strKuchenName.equals(kuchenAktuell.getName() ) )
      {
        return kuchenAktuell;
      }
    }
    //Nichts gefunden:
    return null;
  }
  
  public static ZutatNMBean getZutatByName (KuchenZutatNMWorkerRemote kuchenZutatWorker, String strZutatName)
  {
    List<ZutatNMBean> listZutaten = kuchenZutatWorker.getZutaten();
    
    for (ZutatNMBean zutatAktuell : listZutaten)
    {
      if (strZutatName.equals(zutatAktuell.getZutatName() ) )
      {
        return zutatAktuell;
      }
    }
    //Nichts gefunden:
    return null;
  }
} 


Hinzufügen der JUnit-Libraries

Wir müssen die JAR-Dateien des JUnit-Frameworks dem Projekt zufügen. Zum Glück bringt Eclipse dieses Framework bereits mit. Wir gehen in die Projekt-Properties und klicken unter "Java Build Path" auf "Add Library".
JUnit-JARs (1)
Wir wählen eine Library vom Typ "JUnit" aus:
JUnit-JARs (2)
Es wird die JUnit-Version 4 gewählt:
JUnit-JARs (3)
Das Ergebnis sieht so aus:
JUnit-JARs (4)
Jetzt haben wir leider eine Warnung am Backen:
Classpath entry org.eclipse.jdt.junit.JUNIT_CONTAINER/4 will not be exported or published. Runtime ClassNotFoundExceptions may result.

Der Eclipse-Quickfix bietet uns eine Möglichkeit, dieses zu beheben. Dazu Rechtsklick auf die Warnung und "Quick Fix" wählen:
JUnit-JARs (5)
In dem erscheinenden Dialog die Option "Exclude the associated classpath entry from the set of potential publish/export dependencies" wählen:
JUnit-JARs (6)
Anschließend ein Rebuild, und alles ist toll.

Zur Info: diese Änderung fügt die im folgenden fett markierten Zeilen in der Datei ".classpath" im Projektverzeichnis ein:
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
	<classpathentry kind="src" path="appClientModule"/>
	<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/jre6"/>
	<classpathentry kind="con" path="org.eclipse.jst.server.core.container/org.eclipse.jst.server.generic.runtimeTarget/JBoss v5.0"/>
	<classpathentry kind="con" path="org.eclipse.jst.j2ee.internal.module.container"/>
	<classpathentry kind="con" path="org.eclipse.jdt.junit.JUNIT_CONTAINER/4">
		<attributes>
			<attribute name="org.eclipse.jst.component.nondependency" value=""/>
		</attributes>
	</classpathentry>
	<classpathentry kind="output" path="build/classes"/>
</classpath>


Erstellen eines Tests

Wir legen einen Test im Application Client an: Rechtsklick, "New", "Other...", in der Rubrik "Java" - "JUnit" finden wir den "JUnit Test case".
New JUnit Test
Wichtig ist dass wir einen JUnit-4-Test zufügen. Wir lassen uns die Methoden "setUp" und "tearDown" generieren (auch wenn diese erst in einem späteren Test wirklich Verwendung finden werden !).
New JUnit Test
Wir fügen eine public Methode "testKuchen" zu. Dies wird mit der Annotation org.junit.Test versehen.
  @Test
  public void testKuchen() throws Exception
  {
    Object objRemote = this.getInitialContext().lookup("java:comp/env/ejb/KuchenZutatNMWorkerRemote");
    KuchenZutatNMWorkerRemote kuchenZutatWorker = (KuchenZutatNMWorkerRemote) PortableRemoteObject.narrow(objRemote, KuchenZutatNMWorkerRemote.class);
   
    KuchenNMBean kuchenNeu = new KuchenNMBean ();
    kuchenNeu.setName(KuchenZutatTestUtil.KUCHENNAME1);
    kuchenZutatWorker.saveKuchen(kuchenNeu);
    
    //Laden. Geht hier nicht über ID !
    KuchenNMBean kuchenLoad = KuchenZutatTestUtil.getKuchenByName(kuchenZutatWorker, KuchenZutatTestUtil.KUCHENNAME1);
    assertNotNull("Kuchen " + KuchenZutatTestUtil.KUCHENNAME1 + " nicht gefunden !", kuchenLoad); 
    
    //Löschen: 
    kuchenZutatWorker.deleteKuchen(kuchenLoad.getId() );

    //Prüfen dass wir den nicht mehr finden...
    kuchenLoad = KuchenZutatTestUtil.getKuchenByName(kuchenZutatWorker, KuchenZutatTestUtil.KUCHENNAME1);
    assertNull("Kuchen " + KuchenZutatTestUtil.KUCHENNAME1 + " nach Löschen gefunden !", kuchenLoad);
  } 
Die Methode getInitialContext ist eine private Hilfsmethode die den InitialContext liefert.

Die Methoden der Klasse org.junit.Assert bietet uns diverse Möglichkeiten um eine Bedingung abzuprüfen und im Fehlerfall den Test mit einer entsprechenden Meldung als "Fehlgeschlagen" zu deklarieren.
Hier sehen wir ein neues Feature von Java5: eigentlich müsste der Aufruf von assertNotNull so erfolgen: Assert.assertNotNull. Java5 erlaubt es allerdings static Methoden einer Klasse wie ein Package zu importieren: import static org.junit.Assert.*;

Unser obiger Test enthält eine potentielle Fehlerquelle: wenn bereits ein Kuchen mit dem Namen KuchenZutatTestUtil.KUCHENNAME1 vorhanden wäre, dann würde der Zugriff auf den Kuchen nach dem Löschen trotzdem erfolgreich sein. Eine Lösung wäre im "setUp" bzw. "tearDown" dafür zu sorgen dass die Datenbank explizit geleert wird.


Das Beispiel enthält einen identisch aussehenden Test für die Zutat-Bean.

Ein komplexerer Test ist "TestKuchenZutat":
public class TestKuchenZutat
{
  /**Der Worker für den Test. Wird im "setUp" geholt. */
  private KuchenZutatNMWorkerRemote kuchenZutatWorker = null;
 
  /**Mit diesen Kuchen wird gearbeitet. */
  private Integer intKuchenId1 = null, intKuchenId2 = null;
  
  /**Mit diesen Zutaten wird gearbeitet. */
  private Integer intZutatId1 = null, intZutatId2 = null;
  
  public TestKuchenZutat(String name)
  {
    super(name);
  }

  @Before
  public void setUp() throws Exception
  {
    //Worker holen:
    Object objRemote = this.getInitialContext().lookup("java:comp/env/ejb/KuchenZutatNMWorkerRemote");
    this.kuchenZutatWorker = (KuchenZutatNMWorkerRemote) PortableRemoteObject.narrow(objRemote, KuchenZutatNMWorkerRemote.class);
    
    //Zwei Kuchen und zwei Zutaten anlegen:
    KuchenNMBean kuchenNeu = new KuchenNMBean ();
    kuchenNeu.setName(KuchenZutatTestUtil.KUCHENNAME1);
    kuchenZutatWorker.saveKuchen(kuchenNeu);
    //Direkt wieder rausholen:
    //TODO Hier erfolgt keinerlei Prüfung !
    this.intKuchenId1 = KuchenZutatTestUtil.getKuchenByName(this.kuchenZutatWorker, KuchenZutatTestUtil.KUCHENNAME1).getId();
    
    kuchenNeu = new KuchenNMBean ();
    kuchenNeu.setName(KuchenZutatTestUtil.KUCHENNAME2);
    kuchenZutatWorker.saveKuchen(kuchenNeu);
    this.intKuchenId2 = KuchenZutatTestUtil.getKuchenByName(this.kuchenZutatWorker, KuchenZutatTestUtil.KUCHENNAME2).getId();
    
    ZutatNMBean zutatNeu = new ZutatNMBean ();
    zutatNeu.setZutatName(KuchenZutatTestUtil.ZUTATNAME1);
    kuchenZutatWorker.saveZutat(zutatNeu);
    this.intZutatId1 = KuchenZutatTestUtil.getZutatByName(this.kuchenZutatWorker, KuchenZutatTestUtil.ZUTATNAME1).getId();
    
    zutatNeu = new ZutatNMBean ();
    zutatNeu.setZutatName(KuchenZutatTestUtil.ZUTATNAME2);
    kuchenZutatWorker.saveZutat(zutatNeu);
    this.intZutatId2 = KuchenZutatTestUtil.getZutatByName(this.kuchenZutatWorker, KuchenZutatTestUtil.ZUTATNAME2).getId();
    
  }

  @After
  public void tearDown() throws Exception
  { 
    //Kuchen und Zutaten löschen:
    this.kuchenZutatWorker.deleteKuchen( this.intKuchenId1);
    this.kuchenZutatWorker.deleteKuchen( this.intKuchenId2);
    
    this.kuchenZutatWorker.deleteZutat( this.intZutatId1);
    this.kuchenZutatWorker.deleteZutat( this.intZutatId2);
    
    //Worker wegwerfen:
    this.kuchenZutatWorker = null;
  }
  
  private InitialContext getInitialContext() throws Exception
  {
    ...Implementierung wie im TestKuchen
  }

  @Test
  public void testZutat() throws Exception
  {
    //Kuchen1 als echtes Objekt holen. Geht hier nicht über ID !
    KuchenNMBean kuchenLoad = this.kuchenZutatWorker.findKuchenById(this.intKuchenId1);
    assertNotNull("Kuchen " + KuchenZutatTestUtil.KUCHENNAME1 + " nicht gefunden !", kuchenLoad);
    
    //Zutat 1 und 2 holen:
    ZutatNMBean zutat1 = this.kuchenZutatWorker.findZutatById(this.intZutatId1);
    assertNotNull("Zutat mit ID " + this.intZutatId1 + " nicht gefunden !", zutat1);
    ZutatNMBean zutat2 = this.kuchenZutatWorker.findZutatById(this.intZutatId2);
    assertNotNull("Zutat mit ID " + this.intZutatId2 + " nicht gefunden !", zutat2);
    
    //In den Kuchen hängen:
    kuchenZutatWorker.addZutatToKuchen(kuchenLoad.getId(), zutat1.getId());
    kuchenZutatWorker.addZutatToKuchen(kuchenLoad.getId(), zutat2.getId());
    
    //Den Kuchen erneut holen und sicherstellen dass er zwei Zutaten hat:
    kuchenLoad = this.kuchenZutatWorker.findKuchenById(this.intKuchenId1);
    assertNotNull("Kuchen mit ID " + this.intKuchenId1 + " nicht gefunden !", kuchenLoad);
    assertTrue("Kuchen hat keine zwei Zutaten", kuchenLoad.getZutaten().size() == 2);
    
    //Jetzt die Zutat 2 löschen. Die muss ich erstmal als echtes Objekt suchen.
    zutat1 = this.kuchenZutatWorker.findZutatById(this.intZutatId1);
    assertNotNull("Zutat mit ID " + this.intZutatId1 + " nicht gefunden !", zutat1);
    
    this.kuchenZutatWorker.removeZutatFromKuchen(kuchenLoad.getId(), zutat1.getId());
    
    //Der Kuchen darf jetzt nur noch eine Zutat haben:
    kuchenLoad = this.kuchenZutatWorker.findKuchenById(this.intKuchenId1);
    assertNotNull("Kuchen mit ID " + this.intKuchenId1 + " nicht gefunden !", kuchenLoad);
    
    assertTrue("Kuchen hat nicht genau eine Zutat", kuchenLoad.getZutaten().size() == 1);
  }
}
Hier sieht man die Verwendung von "setUp" / "tearDown": die Methoden werden benutzt um die Datenbank mit einem definierten Datenbestand zu initialisieren.


Test-Basisklasse

Es fällt auf dass alle vier Tests die Methode "getInitialContext" enthalten. Aus diesem Grund wurde eine gemeinsame Basisklasse TestBase erstellt, die den InitialContext in einer Membervariablen hält und ihn beim ersten Aufruf einer protected Methode getInitialContext anlegt.
public class TestBase
{
  private InitialContext initialContext = null;

  protected InitialContext getInitialContext() throws Exception
  {
    //InitialContext nur erzeugen beim ersten Abruf:
    if (this.initialContext == null)
    {
      Properties props = new Properties();
      props.setProperty(Context.INITIAL_CONTEXT_FACTORY, "org.jnp.interfaces.NamingContextFactory");
      props.setProperty(Context.URL_PKG_PREFIXES, "org.jboss.naming.client");
      props.setProperty(Context.PROVIDER_URL, "jnp://localhost:1099");
      props.setProperty("j2ee.clientName", "KuchenZutatNMTestClient");
    
      this.initialContext = new InitialContext(props);
    }

    return this.initialContext;
  }
}
Die einzelnen Tests sind Subklassen hiervon:
public class TestKuchenZutat extends TestBase
{
  ...


Test ausführen

Zuerst wird der Server gestartet und die Enterprise Application einschließlich des Application Clients deployed.

Endlich sind die Vorbereitungen abgeschlossen und wir können den Test starten. Dazu: Rechtsklick, "Run", "Run Configurations..." wählen. In dem Dialog "Create, manage, and run configurations" erzeugen wir uns unter "JUnit" eine neue "Launch Configuration". Wir geben dem Test einen schönen Namen, wählen die Option "Run all tests in the selected project, package or source folder" und wählen als TestRunner "JUnit 4".
Configuration
Es erscheint ein neues Fenster mit den Ausgaben der Tests. Hier erkennen wir auch Exceptions oder fehlgeschlagene Tests:
Testergebnisse


Unit-Test mit Security

Soll der Unit-Test auf Beans zugreifen die gesichert sind, so sind einige kleine Änderungen nötig. Folgendes Beispiel entstand aufgrund des Security-Beispiels, indem ich im Application Client einen Testcase zugefügt habe.

Im folgenden der gesamte Test-Code: der InitialContext wird initialisiert, es wird ein Login am Server durchgeführt, danach werden die drei Zugriffsvarianten durchprobiert. Die Methode "forKundeOnly" muss zwingend eine javax.ejb.EJBAccessException auslösen, deshalb gilt hier eine ausgelöste Exception also erfolgreicher Test !
  @Test
  public void testSecurity() throws Exception
  {
    Properties props = new Properties();
    props.setProperty(Context.INITIAL_CONTEXT_FACTORY, "org.jnp.interfaces.NamingContextFactory");
    props.setProperty(Context.URL_PKG_PREFIXES, "org.jboss.naming.client");
    props.setProperty(Context.PROVIDER_URL, "jnp://localhost:1099");
    props.setProperty("j2ee.clientName", "SecurityClient");
    
    InitialContext initialContext = new InitialContext(props);
    
    AppCallbackHandler callbackHandler = new AppCallbackHandler("admin", "admin".toCharArray() );
    LoginContext loginContext = new LoginContext ("knaufclientsecurity", callbackHandler);
    loginContext.login();
    
    Object securedRef = initialContext.lookup("java:comp/env/ejb/Secured");
    SecuredRemote secured = (SecuredRemote) PortableRemoteObject.narrow(securedRef, SecuredRemote.class);

    //Wir haben uns als "Admin" eingeloggt => also diese Methode testen !
    secured.forAdminOnly();
    
    //"Both" muss ebenfalls klappen:
    secured.forBoth();
    
    //"Kunde" muss fehlschlagen !
    try
    {
      secured.forKundeOnly();
      
      //Wenn wir hierhin kommen, ist das ein Fehler !
      Assert.fail("FEHLER: forKundeOnly durfte aufgerufen werden !");
    }
    catch (EJBAccessException ex)
    {
      //Whow, ein Zugriffsfehler: toll, das ist richtig !
    }
    //Jeder andere Fehler fliegt weiter !
    
  }
Die Klasse AppCallbackHandler stammt aus dem Package org.jboss.security.auth.callback.AppCallbackHandler, ihr können direkt Login und Passwort übergeben werden. Der Name des Login-Contexts ("knaufclientsecurity") stammt aus dem Security-Beispiel.

Das Ausführen des Tests gestaltet sich jetzt ebenfalls ein wenig schwieriger, denn ein Rechtsklick -> "Run as" -> "Unit Test" ist nicht mehr direkt möglich. Stattdessen legt man sich die Run-Konfiguration am besten manuell an (siehe weiter oben). Auf der Karteikarte "Arguments" muss man folgendes eintragen:

-Djava.security.auth.login.config=appClientModule/META-INF/auth.conf

Security konfigurieren


Stand 25.11.2008
Historie:
25.11.2008: Erstellt aus Vorjahresbeispiel und angepaßt an Eclipse 3.4 / JBoss 5.0
30.11.2008: Abschnitt "Unit-Test mit Security" freigeschaltet, die nicht mehr funktionierende Variante "org.jboss.security.jndi.JndiLoginInitialContextFactory" ausgebaut.