Model-to-Code Teil 4

Die Möglichkeiten von Model-to-Code ausschöpfen

Sind Sie neugierig, welche weiteren Möglichkeiten Model-to-Code bietet?
In diesem Abschnitt zeige ich Ihnen, wie Sie die benötigten Klassen aus dem Metamodell erzeugen lassen können. Und wie Sie die erzeugten Klassen (Modellklassen) um selbst definierte Methoden erweitern  können.

 

 

Als Anwendungsfall habe ich mir "Testdaten und Testcode erzeugen" ausgesucht. Die generierten Dateien werden in das Parser-Projekt kopiert, damit es weiterhin von Eclipse Epsilon und EMF unabhängig bleibt.

1 Modelle lesen, erzeugen und schreiben mit Java

In diesem Abschnitt zeige ich, wie Sie aus einem Metamodell die Klassen zum Lesen, Erzeugen und Schreiben von Modellen generieren lassen können.
Zusätzlich zeige ich, wie Sie die generierten Klassen (Modellklassen) um selbst definierte Methoden erweitern können.
Die Modellklassen verwende ich für das Erzeugen von Testfällen aus dem Modell.
Aus diesen Testfällen werden dann die Testdaten und der Testcode erzeugt.
Ich konzentriere mich auf die wesentlichen Teile des Programmcodes. Die vollständigen Quelltexte finden Sie in github.

Die Testfälle aus dem Modell des Parsers erzeugen

Die Testfälle werden durch eine Model-to-Model-Transformation generiert, die in Java programmiert ist.
Das Modell des Parsers wird ausgelesen und das Modell der Testfälle wird geschrieben. Um das Metamodell des Parsers "sauber" zu halten, habe ich für die Testfälle ein eigenes Metamodell erstellt.

Struktur des Projekts "Testfälle erzeugen"

Beim Erzeugen der Testfälle gibt es neben den (erzeugten) Modellklassen auch selbst geschriebene Klassen. Selbst geschriebene Klassen und Modellklassen verwenden sich gegenseitig.
Auf diesem Weg können Sie die erzeugten Modellklassen mit Funktionalität erweitern, die bereits in einem Projekt vorhanden ist, z.B. Fachlogik oder Hilfsklassen.

Die Modellklassen aus dem Metamodell erzeugen

Das Erzeugen der Modellklassen geschieht in mehreren Schritten.

Ich empfehle Ihnen die Hilfeseite zu Emfatic zum Nachschlagen und Vertiefen "griffbereit" zu haben.

Schritt 1: Das Metamodell erweitern

Um auf selbst geschriebene Klassen zugreifen zu können, definieren wir sie als datatype, unter Angabe ihres vollqualifizierten Namens. In diesem Fall liegt die Java-Klasse ListOfPermutation im Java-Package "flexml.collections". Daraus ergibt sich folgende datatype-Definition.

datatype ListOfPermutation : flexml.collections.ListOfPermutation;

Dann können wir diese Klasse in der Definition einer "operation" verwenden. Diese Operation entspricht einer selbst definierten Methode in der Modellklasse. In diesem Fall "Child".

class Child {
    attr Multiplicity multiplicity; // Multiplicity of this child type
    ref Element child;
    
    // Operations for creating test cases
    op ListOfPermutation getPermutations();    
}

Hinweis

Wenn Sie auf datatype-Definitionen aus anderen "packages" (anderen ecore-Dateien) zugreifen, müssen Sie dem Namen des datatype den Namen des Packages voranstellen.

Schritt 2: ecore erzeugen

Dies geschieht wie in Teil 2 des Tutorials beschrieben.

Schritt 3: genmodel erzeugen

Die .ecore-Datei auswählen

Rechter Mausklick → Eugenia → Generate EMF Genmodel

Eclipse erzeugt eine Datei mit der Endung .genmodel.

Schritt 3a: genmodel anpassen

In dieser Datei können Sie die Einstellungen zur Code-Erzeugung vornehmen.
Öffnen Sie die Datei FlexibleXmlParser.genmodel und gehen Sie mit der Maus über das oberste Element mit dem Namen "Flexml".
Öffnen Sie die Properties-View und setzen Sie unter "Model" den Eintrag "Model Directory" auf den Wert "/Flexml.Generator.Ch4.1/src-gen/main/java".
Damit setzen Sie das Verzeichnis in dem der Sourcecode erzeugt wird. Im Parser-Projekt ist dieses Verzeichnis als Sourcecode-Verzeichnis eingestellt.

Schritt 4: Modellklassen erzeugen

Wählen Sie das Element Flexml auf der zweiten Ebene aus

Rechter Mausklick → Generate Model Code

Es werden die folgenden Klassen erzeugt:

  • 4a: Eine Factory zum Erzeugen von Instanzen
  • 4b: Interfaces für die Modellklassen
  • 4c: Implementierungen der Modellklassen

Die Implementierungen enthalten für jede selbst definierte Operation eine "leere" Implementation, die überschrieben werden muss.

Schritt 5: Methoden überschreiben

Die Implementation der Klasse "child" finden Sie in der Datei ChildImpl.java.

Um eine Methode, z.B. "getPermutations" selber zu implementieren, sind zwei Schritte notwendig:

  1. Im Kopf die Annotation "@generated" durch "@generated NOT" ersetzen
  2. Den generierten Code durch eigenen Code ersetzen

Nach der oben beschriebenen Überarbeitung sollte der Code wie folgt aussehen:

/**
     * <!-- begin-user-doc -->
     * <!-- end-user-doc -->
     * @generated NOT
     */
    @Override
    public ListOfPermutation getPermutations() {
        ListOfPermutation result = new ListOfPermutation();
        
        if ((getMultiplicity() == Multiplicity.ZERO_OR_ONE) || (getMultiplicity() == Multiplicity.ZERO_OR_MANY)) {
            Permutation perZero = new Permutation();
            result.getList().add(perZero);
        }
        
        Permutation perOne = new Permutation();
        perOne.add(getChild());
        result.add(perOne);
        
        if ((getMultiplicity() == Multiplicity.ZERO_OR_MANY) || (getMultiplicity() == Multiplicity.ONE_OR_MANY)) {
            Permutation perTwo = new Permutation();
            perTwo.add(getChild());
            perTwo.add(getChild());
            result.getList().add(perTwo);
        }    
        return result;
    }

Hinweise

  • Die Operationen, die überschrieben werden müssen, kann man leicht im Quelltext finden, da sie mit "TODO" markiert sind.  Zudem löst die vom Code-Generator erzeugte Standard-Implementierung eine Exception vom Typ "UnsupportedOperationException" aus.
  • Wenn "@generated" nicht durch "@generated NOT" ersetzt wird, wird der Code der Methode beim nächsten Aufruf von "Generate Model Code" erneut durch die Standard-Implementierung überschrieben.

Die Metamodelle und die abgeleiteten Dateien finden Sie im Eclipse-Projekt "Flexml.Generator.Ch4.1".

Zusammenfassung

Sie haben die Metamodelle um selbst definierte Operationen erweitert und externe Klassen eingebunden.
Sie haben gesehen, wie die Standard-Implementierungen in generierten Klassen überschrieben werden.
Im nächsten Schritt verwenden wir diese Technik um Testfälle aus dem Modell zu erzeugen.

2 Testdaten und Testcode erzeugen

Um das Projekt "rund" zu machen, etwas, das mich wirklich begeistert: Das Erzeugen von Testdaten und Testcode aus dem Modell!
Dafür verwenden wir den XML-Parser, der in Kapitel 3 beschrieben wurde.
Auch im Bereich des Testens spart dieses Vorgehen viel Handarbeit. Und es sorgt dafür, dass bei Änderungen am Modell die Testdaten und der Testcode automatisch angepasst werden!

Der Gesamtablauf

Der Ablauf der Generierung wird geändert:

    • Im Schritt 1 werden aus dem Parser-Modell die Testfälle erzeugt und im Testfall-Modell gespeichert.
    • In Schritt 2 werden aus dem Testfall-Modell die Testdaten und der Testcode erzeugt.
    • Im Schritt 3 wird der Parser-Code generiert.
    • Im Schritt 4 wird der Code in das Parser-Projekt kopiert.

Schritt 1: Aus dem Parsermodell die Testfälle erzeugen

Die folgende Grafik zeigt den Ablauf der Testfall-Erzeugung:

Die Hauptarbeit geschieht in Schritt 1, der Model-to-Model-Transformation. Dort erzeuge ich für jeden Testfall eine gültige Baumstruktur.
In den Schritten 2a und 2b wandle ich jede Baumstruktur in eine XML-Datei bzw. in Testcode um. Dafür reichen einfache Model-to-Text-Transformationen.

Schritt 1: Mit Model-to-Model das Modell der Testfälle erzeugen

Mein Ziel ist es, anhand des Modells eine Auswahl an Bäumen zu erzeugen. Dafür erzeuge ich für ein Element in den verschiedenen Testfällen unterschiedliche Anzahlen an Kindern.
Ich orientiere mich dabei lose an einer "Schleifenüberdeckung", d.h. ich prüfe "an den Rändern". Da der Parser dynamische Listen verwendet, kann ich nicht an der "Obergrenze" testen. Daher beschränke ich mich auf die "Untergrenze".
Die 2 in der Spalte "Maximale Anzahl Kinder" ist hier nicht als echtes Maximum zu verstehen, sondern als Repräsentation von "mehr als ein Kind-Element" für die Testfälle.

Multiplizität des Kinds

Minimale Anzahl Kinder

Maximale Anzahl Kinder

ZeroOrOne

0 1

One

1 1

ZeroOrMany

0 2

OneOrMany

1 2

Offen gestanden hatte ich keine Idee, wie viele Testfälle durch diesen Ansatz erzeugt werden würden.
Glauben Sie, die ungefähre Antwort zu kennen? Die Lösung finden Sie am Ende des Artikels.

Verwendete Symbole

Um die Testfälle zu erzeugen, verwende ich Rekursion, Permutationen und Listen von Permutationen. Um es ein wenig anschaulicher erklären zu können, verwende ich die folgenden Symbole.

Das Erzeugen der Testfälle im Detail

Für jedes Element E erzeuge ich alle Permutationen P1, …, Pn seiner Kinder. Und für jedes Kind erzeuge ich wieder alle Permutationen seiner Kinder, und so weiter.
Permutationen einer Tabelle sind z.B. eine Tabelle mit einer Zeile und eine Tabelle mit zwei Zeilen.
Ich verdeutliche das Vorgehen an einem Element mit zwei Kindern A und B.

Die einzelnen Kinder permutieren

Für die beiden Kinder erzeuge ich dann anhand ihrer Multiplizitäten die folgende Permutationen: Eine Menge mit zwei Kind-Mengen und eine Menge mit drei Kind-Mengen.

Die Kind-Permutationen permutieren

Dann erzeuge ich alle Kombinationen dieser zwei Kind-Mengen:

Für diese Kind-Mengen bestimme ich alle möglichen Unterbäume von A und B und multipliziere ein weiteres Mal aus.
Eine Besonderheit ist M1: Eine Menge mit zwei leeren Mengen als Elemente. Das führt zu einem Element ohne Kinder. Auch dies ist ein wichtiger Testfall.
Der Einfachheit halber zeige ich das für die Kind-Menge M5, in der genau ein A und ein B enthalten ist:

Bilden der Unterbäume für die aktuelle Permutation

Für dieses Beispiel gehe ich davon aus, dass es zwei mögliche Unterbäume von A (A1, A2) und drei mögliche Unterbäume von B (B1, B2, B3) gibt:

Die Unterbäume in das aktuelle Element einhängen

Dann erzeuge ich für jede dieser möglichen Kombinationen von Unterbäumen eine Instanz von E mit dem entsprechenden Unterbaum:

Dieses Verfahren wende ich rekursiv an um aller Permutationen der Teilbäume zu erzeugen.

Model-to-Model-Transformation mit Java

Für die Model-to-Model-Transformation verwende ich Java-Klassen statt EOL. Es war für mich eine echte Erleichterung, den Code im Debugger laufen zu lassen. Und es zeigt, wie einfach es ist, von Java aus Modelle zu lesen und zu schreiben. Und wie einfach man die Modellklassen um eigene Methoden erweitern kann.

Das eigentliche Java-Programm GenTestCases im Ordner src/main/java/flexml/gentest ist recht kurz:

// Load domain model
EMFModelLoad loader = new EMFModelLoad();
Definition myDef = loader.load();
        
// Generate all test cases
Element root = myDef.getRoot();
ListOfSubtree los = root.createTestdataSubtrees();
        
ListOfTestcase lot = tcFactory.createListOfTestcase(); 
        
int cnt = 1;
for (TcElement curRoot : los.getList()) {
    String tcName = String.format("Testcase%03d", cnt ++);
    Testcase tc = tcFactory.createTestcase();
    tc.setName(tcName);
    tc.setRoot(curRoot);
    lot.getTestcase().add(tc);
}
System.out.println(String.format("Count %d",cnt-1));
EMFModelSave.save(lot);

Schritt 2a: Mit Model-to-Text die Testfälle in Testdaten umwandeln

Um einen Testfall in XML umzuwandeln reicht ein ganz einfaches Template. Der Übersichtlichkeit halber habe ich die Import weggelassen.

<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
[%= testcase.toXml() %]

Schritt 2b: Mit Model-to-Text die Testfälle in Testcode umwandeln

Das Erzeugen des Testcodes ist ähnlich einfach. Auch hier habe ich die Import weggelassen.
Um den gesamten Baum zu testen, werden die  Methoden callTest() und genTestmethodBody() rekursiv auf den einzelnen Knoten und ihren Kindern aufgerufen.
Um die Methodennamen eindeutig zu halten, trägt die Methode mit der eine Instanz von "Element" getestet wird, einen Identifier im Namen. Um nicht selber einen Identifier erzeugen und verwalten zu müssen, habe ich jeder Instanz von Element eine Object-Id gegeben.

public class [%= testcase.getClassname() %] {
@Test
public void test() {
    File input = new File("xml/[%= testcase.name %].xml");
    XmlParser parser = new XmlParser(input.getAbsolutePath());
    FileElement fe = parser.parse();
    Assertions.assertEquals(true, parser.isValid());
[%= testcase.root.callTest("fe") %]
}
    
[%= testcase.root.genTestmethodBody() %]        
}

Schritt 3: Den Parser-Code erzeugen

Dieser Schritt geschieht wie in Kapitel 3 beschrieben.

Schritt 4: Testdaten, Testcode und generierten Code in das Parser-Projekt kopieren

Nur die Testdaten und der Testcode werden in das Parser-Projekt kopiert. Die Modellklassen, die von EMF abhängen, werden nicht kopiert.
Generierter Code, der nur im Generator-Projekt verwendet wird, liegt im Verzeichnis "src-gen".
Code und Testdaten, die in das Parser-Projekt kopiert werden, liegen unter Verzeichnis "export/current".

Den gesamten Ablauf ausführen

Das Generator-Projekt finden Sie im Eclipse-Projekt "Flexml.Generator.Ch4.2".
Das Parser-Projekt finden Sie im Eclipse-Projekt "Flexml.Parser.Ch4".

Führen Sie im Generatorprojekt die folgenden Schritte aus:

  1. Erzeugen Sie für FlexibleXmlTestcases.emf die ecore-Datei und registrieren Sie die EPackages.
  2. Erzeugen Sie für FlexibleParser.emf die ecore-Datei und registrieren Sie die EPackages. Die Reihenfolge ist wichtig, da in FlexibleParser.emf auf das Modell der Testfälle verwiesen wird.
  3. Führen Sie build.xml mit "Run as Ant Build..." aus.
    1. Wählen Sie unter dem Reiter "JRE" den Punkt "Run in the same JRE as the workspace" aus.
    2. Wählen Sie unter dem Reiter "Targets" den Punkt "gen-src" aus.
    3. Führen Sie einen Refresh auf das Generator-Projekt aus.
  1. Starten Sie das Programm GenTestCases im Ordner src/main/java/flexml/gentest. Das Programm speichert die erzeugten Testfälle in der Datei FlexibleXmlTestcasesJava.model im Verzeichnis model. Führen Sie einen Refresh auf das Generator-Projekt aus.
  2. Führen Sie build.xml mit "Run as Ant Build..." aus und wählen Sie unter dem Reiter "Targets" den Punkt "gen-test" aus. Sie finden die erzeugten Testdaten im Verzeichnis export/current/src-gen/test/resources und den erzeugten Testcode im Verzeichnis export/current/src-gen/test/java/xml/test.
  3. Führen Sie einen Refresh auf das Generator-Projekt und das Parser-Projekt aus.

Zusammenfassung

Herausforderungen

Die wesentlichen Herausforderungen in diesem Projekt liegen meines Erachtens im Algorithmus. Top-down, bottom-up und zwischendurch ein bisschen Listen und Bäume permutieren: Das erfordert ein gutes Maß an Planung und Konzentration.
Ich hoffe, ich konnte in meiner Beschreibung durch die Symbole und Beispiele für Klarheit sorgen.
Die wesentliche Arbeit wird bei der Erzeugung der Testfälle geleistet. Die Umwandlung der Testfälle in Testdaten und Testcode ist einfach zu durchschauen.

Bewertung

Durch die Verwendung von Java hatte ich bewährte Tools wie Debugger und Unit Tests zur Verfügung.
Den Algorithmus zur Erzeugung der Testfälle zu entwerfen und umzusetzen erforderte ein gutes Maß an Hingabe.
Für gut gelungen halte ich:

  • Das von mir gewählte Verfahren liefert 197 Testfälle. Die zugehörigen Testdaten und Testcode von Hand zu erzeugen, halte ich für aufwändig und fehleranfällig. Und wenn sich das Modell bzw. die Anwendung ändert: Wer meldet sich freiwillig, um von Hand diese Arbeit noch einmal zu erledigen?
  • Durch die Code-Erzeugung habe ich meinen Ansatz zur Testfall-Erstellung dokumentiert. Damit kann er von anderen Team-Mitgliedern nachvollzogen und seine Korrektheit und Vollständigkeit bewertet werden. Das halte ich generell für ein gutes Vorgehen. Die Erzeugung der Testfälle ist in diesem Fall recht komplex. Daher halte ich die Nachvollziehbarkeit für umso wichtiger.

Zu verbessern wäre:

  • Für diesen sehr einfach gehaltenen Parser sind knapp 200 Testfälle zu viel. In der Realität würde man die Menge der zu testenden Konstellationen ("Eine Tabelle", "Keine Tabelle", "Tabelle mit Header", "Tabelle ohne Header") erstellen, und dann eine minimale Menge an Testfällen auswählen, die alle diese Konstellationen testet.
  • Sich klar zu machen und zu dokumentieren, wie man zu dieser Auswahl kommt, halte ich für eine weitere Verbesserung im Bereich Test. Ich habe allerdings auf diese Auswahl verzichtet, da dies den Rahmen und die Zielsetzung dieser Einführung sprengen würde.

Jetzt teilen: