~/home of geeks

XPath Assertions

· 1799 Wörter · 9 Minute(n) Lesedauer

Html Unit ist ein sehr hilfreiches Framework, um Oberflächentests für HTML-Seiten zu schreiben. Im täglichen Einsatz jedoch, hat man oft das Problem, dass der gerade geschriebene XPath-Ausdruck, um ein Element zu testen, nicht greift. Bei sehr verschachtelten Strukturen kann so ein XPath-Ausdruck sehr lange sein, und man muss mit Firebug ausprobieren, an welcher Stelle der Ausdruck nicht mehr passt.

Um das Debuggen von fehlschlagenden XPath-Ausdrücken zu vereinfachen, hatte ich mir einen Builder gebaut, welcher das einfache Zusammenbauen von XPath-Ausdrücken erlaubt und dabei beim Zusammenbauen schon die einzelnen Pfade des Ausdrucks auf Existenz prüft, um genau an der Stelle, wo der Ausdruck nicht mehr greift, eine Warnung auszugeben.

Angenommen ich wollte die Titel der Artikel auf meinem Blog testen und hätte einen XPath-Ausdruck hierfür geschrieben:

//div[@id='primary']/main[@id='min']/article/header/h3/a

Dabei hat sich der Fehlerteufel eingeschlichen und mein Ausdruck müsste /main[%%%%%CODE136%%%%%id='min']. Mein falscher Ausdruck schlägt fehl und ich muss im Firebug den XPath-Ausdruck Pfad für Pfad kürzen, um die Stelle mit dem Fehler zu finden.

Mit einem Builder für XPath-Ausdrücke, der bei Eingabe eines Pfadanteiles den aktuellen Pfad prüft kann ich den Gesamtausdruck iterativ aufbauen und stoße, sobald ich einen falschen Pfadanteil hinzufüge, auf einen Fehler.

Hierzu habe ich zuerst die verwendbaren XPath-APIs gekapselt, da ich letztendlich lediglich prüfen können muss, ob ein (Teil-) XPath gültig ist:

/**
 * Interface zur Abstraktion von der verwendeten XPath-Implementierung.
 * 
 * @author Serhat Cinar
 */
public interface IXPathApi {
    /**
     * Prüft, ob der XPath-Ausdruck ein Ergebnis liefert.
     * 
     * @param xpath
     * @return true, wenn der XPath ein gültigen Treffer landet.
     */
    public boolean hasElement(String xpath);
}
import com.gargoylesoftware.htmlunit.html.DomNode;

/**
 * Implementierung der Schnittstelle für XPath-APIs, hier durch
 * <a href="http://htmlunit.sourceforge.net/">Html Unit</a>.
 * 
 * @author Serhat Cinar
 */
public class HtmlUnitXPathApi implements IXPathApi {

    /**
     * DomNode repräsentation der Seite, die verarbeitet wird.
     */
    private final DomNode node;

    /**
     * Konstruktor mit Übergabe der zu testenden Seite.
     * 
     * @param node
     */
    public HtmlUnitXPathApi(final DomNode node) {
        if (node == null) {
            throw new IllegalArgumentException("Node may not be null.");
        }
        this.node = node;
    }

    @Override
    public boolean hasElement(final String xpath) {
        final DomNode targetNode = node.getFirstByXPath(xpath);
        return targetNode != null;
    }
}
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;

import org.w3c.dom.Document;
import org.w3c.dom.NodeList;

/**
 * Implementierung der Schnittstelle für XPath-APIs, hier durch
 * die Java-API interne org.w3c und javax.xml.xpath APIs.
 * 
 * @author Serhat Cinar
 */
public class W3cDomXPathApi implements IXPathApi {
    private final Document doc;
    private final XPath xPath;

    /**
     * Konstruktor mit Übergabe des zu testenden Dokumentes.
     * 
     * @param node
     */
    public W3cDomXPathApi(final Document doc) {
        if (doc == null) {
            throw new IllegalArgumentException("Document may not be null.");
        }
        this.doc = doc;
        xPath = XPathFactory.newInstance().newXPath();
    }

    @Override
    public boolean hasElement(final String xpath) {
        NodeList nodes = null;
        try {
            nodes = (NodeList) xPath.evaluate(xpath, doc.getDocumentElement(), XPathConstants.NODESET);
        } catch (final XPathExpressionException e) {
            throw new RuntimeException(e);
        }
        return nodes != null && nodes.getLength() > 0;
    }

}

Anschließend wird der Builder selbst geschrieben, der mit einer praktischen Fluent-Schnittstelle allerlei Hilfsmethoden zum einfachen Zusammenbau mitbringt.

Es sei jedoch darauf hingewiesen, dass dieser Builder die Laufzeit derart beeinflusst, dass alle Aufrufe jeweils einen XPath auswerten.

Die Benutzung ist recht simpel. Für das Beispiel von oben würde der Code wie folgt aussehen:

final XPathAssertativeBuilder xpab = XPathAssertativeBuilder.assertThat(page);
xpab.add("div[@id='primary']").add("main[@id='min']").add("article").add("header").h(3).a();

Alternativ kann auch die vararg Fähigkeit der add-Methode benutzt werden:

final XPathAssertativeBuilder xpab = XPathAssertativeBuilder.assertThat(page);
xpab.add("div[@id='primary']", "main[@id='min']", "article", "header").h(3).a();

Für einige Use-Cases habe ich spezielle Methoden hinzugefügt (Anchor-Tag, Header-Tag etc.). Da ich im Konkreten Einsatzfall sehr viele verschachtelte DIVisions mit Class-Attributen hatte, habe ich diese als primäre Parameter der div-Methode gewählt, und nicht ein ID-Attribut.

Natürlich erlaubt der Builder Ausdrücke auch auf anderen Wegen zusammen zu bauen:

final XPathAssertativeBuilder xpab = XPathAssertativeBuilder.assertThat(page);
xpab.div().id("primary"]).add("main[@id='min']", "article", "header").h(3).a();

oder

final XPathAssertativeBuilder xpab = XPathAssertativeBuilder.assertThat(page);
xpab.add("div[@id='primary']/main[@id='min']/article/header/h3/a");

Die letztere Variante fügt aber den gesamten Ausdruck auf einmal hinzu und testet erst diesen. Daher wird er nicht hilfreich dabei sein, die Fehlerstelle zu ermitteln.

import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;

import org.apache.commons.lang.StringUtils;
import org.fest.assertions.Assertions;

/**
 * XPath Builder mit Fluent-Schnittstelle und Überprüfung der hinzugefügten Teilausdrücke.<br/>
 * Hilft einen XPath-Pfad zu debuggen,
 * in dem ein langer XPath-Ausdruck in kleinen Teilen zusammengebaut und
 * mit jedem Teil getestet werden kann.<br/>
 * Beispiel:
 * Der XPath "//div[@id='main']/div[@class='myfirst']/div[@class='sub']/img"
 * funktioniert nicht. Mit dem Auge und XFire ist kein Fehler zu erkennen.<br/>
 * Auch nicht, wo genau das Ding fehlschlägt. Mit folgendem Aufruf kann man genau nachvollziehen, wo der XPath nichts mehr liefert:<br/>
 * 
 * final XPathAssertativeBuilder xpab = XPathAssertativeBuilder.assertThat(page);
 * final String xpath = xpab.add(&quot;//div[@id='main']/div[@class='myfirst']&quot;).add(&quot;/div[@class='sub']&quot;).add("img").getXPath();
 * 
 * <p>
 * z. B. mit dem Ergebnis
 * </p>
 * 
 * java.lang.AssertionError: [XPath "//div[@id='main']/div[@class='myfirst']" lieferte kein Ergebnis.] 
 *   expecting actual value not to be null
 *   at org.fest.assertions.Fail.failure(Fail.java:228)
 * ...
 * 
 * <p>
 * Die folgenden Aufrufe liefern das gleiche XPath:
 * </p>
 * 
 * xpab.add(&quot;//div[@class='myfirst']/div[@class='sub']/img&quot;);
 * xpab.div().id(&quot;main&quot;).div().cls(&quot;myfirst&quot;).div().cls(&quot;sub&quot;).img().cls(&quot;img&quot;);
 * xpab.div().id(&quot;main&quot;).div(&quot;myfirst&quot;).div(&quot;sub&quot;).img();
 * xpab.div().id(&quot;main&quot;).div(&quot;myfirst&quot;, &quot;sub&quot;).img();
 * 
 * Die erste Variante testet jedoch nur auf den angegebenen gesamten Ausdruck, während alle anderen Varianten auf jeden Schritt testen.
 * 
 * @author Serhat Cinar
 */
public class XPathAssertativeBuilder {
    private final IXPathApi xpathApi;
    private final StringBuffer xpathBuffer = new StringBuffer();

    private XPathAssertativeBuilder(final IXPathApi xpathApi) {
        if (xpathApi == null) {
            throw new IllegalArgumentException("XPath API darf nicht null sein.");
        }
        this.xpathApi = xpathApi;
    }

    private static final Set<Character> TOKEN_CHARACTERS = new HashSet<>(Arrays.asList('[', '/', '.', '(', ':'));

    /**
     * Fügt einen weitern Teil zum vorhandenen XPath hinzu.
     * Liefert der XPath kein Ergebnis, wird ein fail erzeugt.
     * Es sollten Teile immer so hinzugefügt werden, dass dabei ein neuer
     * und <i>gültiger</i> Xpath-Ausdruck entsteht.
     * <p/>
     * Also ein {@code add("/test/")} wird zu Fehlern führen, da ein Postfix "/" alleine nicht erlaub ist.
     * 
     * @param xpath
     * @return
     */
    public XPathAssertativeBuilder add(final String xpath) {
        if (StringUtils.isNotBlank(xpath)) {
            String tmpXpath = xpath;
            // Bestimmt, ob das letzte Zeichen des vorhandenen XPath ein Steuerzeichen ('/', '.' etc) ist.
            boolean bufferLastCharIsToken = false;
            if (xpathBuffer.length() > 0 && TOKEN_CHARACTERS.contains(xpathBuffer.charAt(xpathBuffer.length() - 1))) {
                bufferLastCharIsToken = true;
            }
            // Bestimmt, ob das erste Zeichen des neuen XPath-teiles ein Steuerzeichen ('/', '.' etc) ist.
            boolean tempFirstCharIsToken = false;
            if (TOKEN_CHARACTERS.contains(tmpXpath.charAt(0))) {
                tempFirstCharIsToken = true;
            }
            // Hier kommt ein "/xxx" mit einem "yyy" zusammen, da muss ein "/" dazwischen.
            if (!bufferLastCharIsToken && !tempFirstCharIsToken) {
                tmpXpath = "/" + tmpXpath;
            }

            if (xpathBuffer.length() == 0 && tmpXpath.startsWith("/") && !tmpXpath.startsWith("//")) {
                // ist der erste Aufruf ein div("myclass") so darf nicht "/div[@class..." angehangen werden,
                // sondern ein "//div[@class...", also ein Doppelslash.
                xpathBuffer.append("/");
            }
            xpathBuffer.append(tmpXpath);
            final String currentXpath = xpathBuffer.toString(); 
            Assertions.assertThat(xpathApi.hasElement(currentXpath)).as("XPath \\"" + currentXpath + "\\" lieferte kein Ergebnis.").isTrue();
        }
        return this;
    }

    /**
     * Fügt einen weitern Teil zum vorhandenen XPath hinzu.
     * Liefert der XPath kein Ergebnis, wird ein fail erzeugt.
     * Es sollten Teile immer so hinzugefügt werden, dass dabei ein neuer
     * und <i>gültiger</i> XPath-Ausdruck entsteht.
     * <p/>
     * Also ein {@code add("/test/")} wird zu Fehlern führen, da ein Postfix "/" alleine nicht erlaub ist.
     * 
     * @param xpath
     * @return
     */
    public XPathAssertativeBuilder add(final String... xpaths) {
        if (xpaths!=null){
            for (final String xpath:xpaths){
                add(xpath);
            }
        }
        return this;
    }
    
    /**
     * Fügt einen weitern Teil zum vorhandenen XPath hinzu.
     * Es wird keine Prüfung des Ausdrucks gemacht.
     * Es sollten aber dennoch immer Teile so hinzugefügt werden, dass dabei ein neuer
     * und <i>gültiger</i> Xpath-Ausdruck entsteht.
     * 
     * @param xpath
     * @return
     */
    public XPathAssertativeBuilder addSilent(final String xpath) {
        xpathBuffer.append(xpath);
        return this;
    }

    /**
     * Fügt einen weitern Teil zum vorhandenen XPath hinzu.
     * Es wird keine Prüfung des Ausdrucks gemacht.
     * Der hinzuzufügende XPath wird über {@link #getXPath()} am übergebenen Objekt ermittelt.
     * 
     * @param xpath
     * @return
     */
    public XPathAssertativeBuilder addSilent(final XPathAssertativeBuilder xpab) {
        if (xpab != null) {
            return addSilent(xpab.getXPath());
        }
        return this;
    }

    /**
     * Fügt ein Element mehrfach mit verschiedenen Style-Klassen hinzu.<br/>
     * Bsp.:<br/>
     * 
     * xpab.addMultipleElementWithClasses(&quot;div&quot;, &quot;modCon&quot;, &quot;modE&quot;, &quot;modX&quot;)
     * 
     * Erzeugt den XPath
     * 
     * div[@class='modCon']/div[@class='modE']/div[@class='modX']
     * 
     * @param element
     * @param styleClasses
     * @return
     */
    public XPathAssertativeBuilder addMultipleElementWithClasses(final String element, final String... styleClasses) {
        if (styleClasses == null || styleClasses.length == 0) {
            add(element);
        } else {
            for (final String styleClass : styleClasses) {
                add(element).cls(styleClass);
            }
        }
        return this;
    }

    /**
     * Fügt "//" ohne Prüfungen hinzu.
     * (da nach dem hinzufügen der Ausdruck nicht komplett ist)
     * 
     * @return
     */
    public XPathAssertativeBuilder descendant() {
        return addSilent("//");
    }

    /**
     * Fügt "/.." ohne Prüfungen hinzu.
     * (da nach dem hinzufügen der Ausdruck nicht komplett ist)
     * 
     * @return
     */
    public XPathAssertativeBuilder parent() {
        return addSilent("/..");
    }

    /**
     * Fügt "/." ohne Prüfungen hinzu.
     * (da nach dem hinzufügen der Ausdruck nicht komplett ist)
     * 
     * @return
     */
    public XPathAssertativeBuilder self() {
        return addSilent("/.");
    }

    /**
     * {@link #addMultipleElementWithClasses(String, String...)} mit "div" als Element.
     * 
     * @param styleClasses
     * @return
     * @see #addMultipleElementWithClasses(String, String...)
     */
    public XPathAssertativeBuilder div(final String... styleClasses) {
        return addMultipleElementWithClasses("div", styleClasses);
    }

    public XPathAssertativeBuilder a() {
        return add("a");
    }

    /**
     * {@link #addMultipleElementWithClasses(String, String...)} mit "img" als Element.
     * 
     * @param styleClasses
     * @return
     * @see #addMultipleElementWithClasses(String, String...)
     */
    public XPathAssertativeBuilder img(final String... styleClasses) {
        return addMultipleElementWithClasses("img", styleClasses);
    }

    /**
     * Fügt eine class-Identität hinzu, wie "[@class='value']".
     * 
     * @param styleClass
     * @return
     */
    public XPathAssertativeBuilder cls(final String styleClass) {
        return add("[@class='" + styleClass + "']");
    }

    /**
     * Fügt eine Identität hinzu, wie "[@id='value']".
     * 
     * @param id
     * @return
     */
    public XPathAssertativeBuilder id(final String id) {
        return add("[@id='" + id + "']");
    }

    /**
     * Fügt eine contains-Prüfung hinzu, wie "[contains(@field, 'value')]".
     * 
     * @param field
     * @param value
     * @return
     */
    public XPathAssertativeBuilder contains(final String field, final String value) {
        return add("[contains(@" + field + ", '" + value + "')]");
    }

    /**
     * Fügt eine contains-Prüfung für das class-Attribut hinzu, wie "[contains(@class, 'value')]".
     * 
     * @param value
     * @return
     */
    public XPathAssertativeBuilder containsClass(final String value) {
        return contains("class", value);
    }

    /**
     * Fügt eine contains-Prüfung für das id-Attribut hinzu, wie "[contains(@id, 'value')]".
     * 
     * @param value
     * @return
     */
    public XPathAssertativeBuilder containsId(final String value) {
        return contains("id", value);
    }

    public XPathAssertativeBuilder h(final int level) {
        return add("h" + level);
    }

    /**
     * {@link #addMultipleElementWithClasses(String, String...)} mit "ul" als Element.
     * 
     * @param styleClasses
     * @return
     * @see #addMultipleElementWithClasses(String, String...)
     */
    public XPathAssertativeBuilder ul(final String... styleClasses) {
        return addMultipleElementWithClasses("ul", styleClasses);
    }

    /**
     * {@link #addMultipleElementWithClasses(String, String...)} mit "li" als Element.
     * 
     * @param styleClasses
     * @return
     * @see #addMultipleElementWithClasses(String, String...)
     */
    public XPathAssertativeBuilder li(final String... styleClasses) {
        return addMultipleElementWithClasses("li", styleClasses);
    }

    /**
     * {@link #addMultipleElementWithClasses(String, String...)} mit "p" als Element.
     * 
     * @param styleClasses
     * @return
     * @see #addMultipleElementWithClasses(String, String...)
     */
    public XPathAssertativeBuilder p(final String... styleClasses) {
        return addMultipleElementWithClasses("p", styleClasses);
    }

    /**
     * Liefert den aktuellen Stand (komplett) des XPaths.
     * 
     * @return
     */
    public String getXPath() {
        return xpathBuffer.toString();
    }

    /**
     * Erzeugt eine neue Instanz.
     * 
     * @param node
     * @return
     */
    public static final XPathAssertativeBuilder assertThat(final IXPathApi xpathApi) {
        return new XPathAssertativeBuilder(xpathApi);
    }
}