
14. Januar 2025
QF-Test Assertion API erweitern – Eine praktische Einführung
Seit QF-Test 8 gibt es einen neuen und komfortablen Weg, in Skripten Bedingungen zu prüfen: Die QF-Test Assertion API, die sich lose an der von Javascript bekannten Bibliothek chai.JS orientiert.
In einem Groovy Script können wir so nun zum Beispiel schreiben:
def foo = 'bar'
def beverages = [ tea: [ 'chai', 'matcha', 'oolong' ] ]
expect(foo).to.be.a('String')
foo.should.be.equal('bar')
expect(foo).to.have.lengthOf(3)
expect(beverages).to.have.property('tea').with.lengthOf(3)
Die API der sprechenden Prüfausdrücke ist reichlich ausgestattet, so dass die meisten Prüfungen ohne Weiteres umgesetzt werden können. In Spezialfällen möchte man die API aber gerne erweitern, so wie es mit chai.JS-Plugins möglich ist. Zum Beispiel wäre es doch schön, wenn man mit folgendem Ausdruck prüfen könnte, ob eine gegebene Zeichenkette ein gültiges JSON-Objekt repräsentiert:
def validJson = '[1,2,3,"s",{}"key":"value"}]'
def invalidJson = '[{]'
validJson.should.be.json()
invalidJson.should.not.be.json()
Auch für solche Erweiterungen der Assertions API bietet QF-Test eine Schnittstelle, die wir im Rahmen dieses Blog-Beitrags vorstellen möchten.
Um eine dynamische Erweiterung der API möglich zu machen, wird unter der Haube beim Ausführen eines Prüfausdrucks für jede Assertion ein Proxy-Objekt erstellt. Dieses unterstützt zusätzlich zu den Standard-Methoden jeder Assertion auch die Methoden, die zwischenzeitlich als Erweiterung registriert wurden. Für diese Erweiterungen wird zunächst eine Schnittstellen-Definition in Form eines Interfaces benötigt:
package de.qfs.lib.assertions.external;
import de.qfs.lib.assertions.Assertion;
publicinterfaceJsonAssertionextendsAssertion {
Assertion json();
}
Zu Beachten ist,
- dass das Interface normalerweise im
de.qfs.lib.assertions.external
-Package platziert wird, - dass es das bestehende
Assertion
-Interface erweitert - und dass jede neue API-Methode wiederum das Assertion-Objekt selbst zurück liefert.
Dies ermöglicht ein Verkettung der Methoden-Aufrufe. Der auszuführende Code kann nun in einer eigenen Klasse definiert werden, die das Interface implementiert und von de.qfs.lib.assertions.AssertionExtension
ableitet. Einfacher geht es aber direkt in der Interface-Definition als default
-Implementierung:
default Assertion json() {
return self();
}
Die Implementierung der Methode macht nun noch nicht besonders viel, sie gibt lediglich, wie gefordert, eine Referenz auf das eigene Assertion-Objekt zurück. Zur Durchführung bzw. dem Reporting der eigentliche Prüfung muss die geerbte Methode doAssert(condition, message, negatedMessage, expected, actual)
aufgerufen werden:
condition
ist ein bool’scher Wert oder eine Funktion mit bool’schem Rückgabewert, die das Ergebnis der Prüfung enthält bzw. zurückgibt.message
ist ein String, der für eine eventuelle Fehlermeldung bzw. für den in QF-Test durchgeführten Check verwendet wird – dabei werden der Platzhalter#{this}
durch das geprüfte Objekt,#{exp}
durch den erwarteten Wert und#{act}
durch den tatsächlichen Wert (meist identisch zum geprüften Objekt) ersetzt.negatedMessage
enthält die Nachricht, die bei Prüfungen verwendet wird, deren Aufrufkette einnot
enthält.expected
enthält den erwarteten Wert.actual
enthält den tatsächlichen Wert.
Dabei sind alle Parameter mit Ausnahme von condition
optional und können am Ende der Parameterliste weggelassen werden.
Unsere Assertion-Methode könnte nun so aussehen:
default Assertion json() {
doAssert(this::isObjectJson,
"expected #{this} to be a json string",
"expected #{this} not to be json string");
return self();
}
Die eigentliche Prüfung haben wir dabei in eine eigene Methode ausgelagert, in der wir versuchen, die Eingabe als JSON auszulesen und im Erfolgsfall true
, im Fehlerfall false
zurückgeben:
defaultbooleanisObjectJson() {
try {
final Object s = _obj();
if (s instanceof String) {
Json.parse((String) s);
returntrue;
}
returnfalse;
} catch (final ParseException ex) {
returnfalse;
}
}
Dabei wurde mit dem Ausdruck _obj()
auf das zu prüfende Objekt zurückgegriffen.
Um nun unsere neue Methode in Prüfausdrücken verwenden zu können, muss diese einmalig bei der AssertionFactory
registriert werden. Diese ist als Singleton realisiert und stellt dafür die Instanz-Methode registerExtension(extension, overwrite)
zur Verfügung:
extension
ist das neue Assertion-Interface der Erweiterung. Alternativ kann hier auch eine Klasse die das Interface implementiert, oder ein Objekt einer solchen Klasse angegeben werden.overwrite
bestimmt, wie mit bereits registrierten Methoden gleichen Namens verfahren wird. Beitrue
wird die Methode der neu registrierten Erweiterung bevorzugt, beifalse
die zuvor registrierte.
Am einfachsten kann man die Registrierung der Erweiterung bei der Initialisierung eines QF-Test Plugins durchführen. Eine allgemeine Beschreibung von QF-Test Plugins findet sich im vorausgegangenen Blog-Artikel. Für das Plugin, welches unsere Assertion-API-Erweiterung zur Verfügung stellt, könnte die entsprechende Plugin-Klasse so aussehen:
import de.qfs.apps.qftest.shared.QFTestPlugin;
import de.qfs.lib.assertions.AssertionFactory;
import de.qfs.lib.assertions.external.JsonAssertion;
publicclassJsonAssertionPluginimplementsQFTestPlugin {
@Overridepublicvoidinit()
{
AssertionFactory.instance()
.registerExtension(JsonAssertion.class, false);
}
}
Möchte man die Aufrufkette um “Properties” erweitern, so ist zu beachten, dass die zugehörigen Methoden jeweils ein get
vorangestellt haben müssen – die Skript-Engines Jython oder Groovy wandeln dann die Properties im Prüfausdruck automatisch in den entsprechenden Methodenaufruf um.
Als Beispiel wollen wir unsere neue API noch um die Prüfung auf “primitive” JSON-Objekte erweitern:
def primitiveObj = '"String"'
def nonPrimitiveObj = '[1,2,3]'
primitiveObj.should.be.primitive.json()
nonPrimitiveObj.should.not.be.primitive.json()
Dann ergänzen wir unser JsonAssertion
Interface um die entsprechende getPrimitive
-Methode:
default Assertion getPrimitive() {
self().flag("primitive", true);
return self();
}
und ergänzen die Prüfmethode:
defaultbooleanisObjectJson() {
finalboolean primitive = AssertionUtil.flagAsBoolean(self(), "primitive");
try {
final Object s = _obj();
if (s instanceof String) {
final JsonValue value = Json.parse((String) s);
if (primitive) {
finalboolean valueIsObjectOrArray =
value.isObject() || value.isArray();
return ! valueIsObjectOrArray;
}
returntrue;
}
returnfalse;
} catch (final ParseException ex) {
returnfalse;
}
}
Dabei wird ein typisches Schema der Assertion API verwendet: in der Property-Methode wird im Assertion-Objekt mit self().flag(name, value)
ein Zustands-Wert gesetzt, der dann bei der Auswertung wieder abgerufen wird. Dazu liefert die Methode self().flag(name)
den entsprechenden Zustandswert wieder zurück. Zur Vereinfachung bietet die Klasse de.qfs.lib.assertions.AssertionUtil
die statischen Hilfsmethoden flagAsBoolean(assertion, name)
und flagAsString(assertion, name)
an.
Auch die Meldung für den Fehlerfall sollte an den Zustand angepasst werden:
default Assertion json() {
finalboolean primitive = AssertionUtil.flagAsBoolean(self(), "primitive");
final String descriptor = primitive ? "primitive " : "";
doAssert(this::isObjectJson,
"expected #{this} to be a " + descriptor + "json string",
"expected #{this} not to be " + descriptor + "json string");
return self();
}
Um schließlich die internen Details im Stacktrace des im Fehlerfall geworfenen AssertionError
s zu verbergen, kann zu Beginn der Prüfmethode diese als Einstiegspunkt definiert werden (ssfi steht hierbei für start stack function indicator):
default Assertion json() {
_ssfi(JsonAssertion.class,"json");
(...)
}
Aufgepasst: Aktuell gibt es aufgrund der verwendeten Classloader die Einschränkung, dass die Erweiterungen nur in Server-Skripten und Groovy SUT-Skripten zur Verfügung stehen.
Der Code der gesamten Erweiterung ist auf GitLab abrufbar.