JUnit und Proxy: Private Methoden testen

Martin Kompf

Nach den einführenden Beispielen zum Testen mit JUnit 4 und der Verwendung des Parameterized Testrunners soll im Mittelpunkt dieses Artikels eine praktische Anwendung stehen, nämlich der Test einer bereits vorhandenen Klasse. Schwerpunkt ist dabei die Testbarkeit privater Methoden.

Nicht testbar!?

Ziel ist es, die im Artikel DOM API vorgestellte Klasse GpxDOMEditor zu testen. Die Klasse dient zur Demonstration der Verwendung des Document Object Model (DOM) in einem Java Programm. Der dort vorgestellte beispielhafte Anwendungsfall liest, konvertiert und schreibt eine XML Datei im GPX Format.

Bei der Analyse der Klasse GpxDOMEditor hinsichtlich ihrer Testbarkeit zeigt sich, dass sehr wohl eine gute Kapselung der verschiedenen Prozessschritte in einzelne Methoden erfolgte. So existieren Methoden wie copyAttributes oder createElementWithText, die eine klar definierte und abgeschlossene Aufgabe haben und sich daher gut für einen JUnit Test eignen.

Allerdings sind alle diese Methoden als private deklariert! Das mag zwar aus Sicht der Kapselung in Ordnung sein, nur lassen sich diese Methoden dann nicht mehr von außerhalb ansprechen und demzufolge leider auch nicht testen. Ist GpxDOMEditor demzufolge untestbar?

Proxy und Invocation Handler

Es gibt einen Ausweg, wie man einem JUnit Test doch Zugriff auf die privaten Methoden der Class under Test (CuT) erlauben kann. Das Zauberwort heißt Reflection; die Werkzeuge sind die Klasse Proxy und das Interface InvocationHandler aus der Package java.lang.reflect der Standard Java API.

Als vorbereitende Maßnahme erstellt man zunächst ein Interface, welches die (privaten) Methoden der CuT enthält. Die Methodensignatur muss dabei exakt - inklusive throws Klausel - übereinstimmen; nur auf das Schlüsselwort private verzichtet man:

/**
 * Interface that mimics the class under test.
 */
interface GpxDOMEditorProxy {
  Document parseFile(File file) throws ParserConfigurationException, SAXException, IOException;
  void writeFile(Document doc, File file) throws TransformerException;
  void createRouteFromWaypoints(Document gpxDoc) throws SAXException;
  void copyAttributes(Element src, Element dst);
  Element createElementWithText(Document doc, String tagName, String text);
}

Alle Testmethoden verwenden nun dieses Interface anstelle der CuT. Das erlaubt erst einmal das fehlerfreie Kompilieren, da alle Interfacemethoden public sind. Ein Test der Methode createElementWithText könnte dann etwa so aussehen:

package de.kompf.javaxml;

import static org.junit.Assert.*;
import org.junit.*;
import java.lang.reflect.*;
// import ...

public class GpxDOMEditorTest {
  private GpxDOMEditorProxy domEditorProxy;

  @Test
  public void testCreateElementWithText() throws Exception {
    Document doc = DocumentBuilderFactory.newInstance()
      .newDocumentBuilder().newDocument();
    final String tagName = "TestElement";
    final String text = "Text ÄÖÜß";

    Element ele = domEditorProxy.createElementWithText(doc, tagName, text);

    assertNotNull(ele);
    assertEquals(tagName, ele.getNodeName());
    NodeList children = ele.getChildNodes();
    assertEquals(1, children.getLength());
    Node childNode = children.item(0);
    assertEquals(Node.TEXT_NODE, childNode.getNodeType());
    assertEquals(text, ((Text) childNode).getData());
  }

  // ...
  // createProxy - see below
}

Beim Laufenlassen dieses Tests bekommt man natürlich eine NullPointerException - was fehlt, ist die Instanzierung von domEditorProxy. Hierfür existiert zwar die Interfacedefinition GpxDOMEditorProxy, jedoch mangelt es an einer Klasse, die dieses Interface implementiert. An dieser Stelle kommt nun java.lang.reflect.Proxy ins Spiel, mit dessen Hilfe sich eine Instanz eines Interface per Reflection erzeugen lässt. Diese Instanz dient dann als Stellvertreter Proxy für die die eigentliche CuT. Die Instanzierung des Proxy legt man in eine Methode mit der Annotation @Before, damit sie automatisch vor jeder Testmethode zur Ausführung kommt:

  @Before
  public void createProxy() {
    InvocationHandler handler = new PrivateMethodInvocationHandler(new GpxDOMEditor());
    domEditorProxy = (GpxDOMEditorProxy) Proxy.newProxyInstance(
        GpxDOMEditorProxy.class.getClassLoader(),
        new Class[] { GpxDOMEditorProxy.class },
        handler);
  }

Proxy.newProxyInstance bekommt als Parameter das zu implementierende Interface und seinen Classloader sowie einen InvocationHandler übergeben. Der InvocationHandler übernimmt die Vermittlung zwischen Proxy und eigentlichem Objekt - der CuT.

Private Methoden sichtbar schalten

Im konkreten Fall ist seine Aufgabe also die Weiterleitung der Methodenaufrufe an die CuT, wobei die private Sichtbarkeit zu ignorieren ist. Die vollständige Implementierung ist in der Klasse PrivateMethodInvocationHandler gekapselt:

/**
 * Invocation handler to expose the private methods of an object.
 */
public class PrivateMethodInvocationHandler implements InvocationHandler {
  private Object wrappedObject;
  private Class<?> wrappedClass;

  /**
   * Create a new invocation handler.
   * @param wrappedObject The object which private methods should be exposed.
   */
  public PrivateMethodInvocationHandler(Object wrappedObject) {
    this.wrappedObject = wrappedObject;
    this.wrappedClass = wrappedObject.getClass();
  }

  @Override
  public Object invoke(Object proxy, Method method, Object[] args)
      throws Throwable {
    Method wrappedMethod = wrappedClass.getDeclaredMethod(
      method.getName(), method.getParameterTypes());
    wrappedMethod.setAccessible(true);
    
    return wrappedMethod.invoke(wrappedObject, args);
  }
}

Der PrivateMethodInvocationHandler hält Referenzen auf das zu testende Objekt und seine Klasse als Membervariablen. Die Methode invoke bestimmt dann aus der vom Proxy übergebenen Methodensignatur die konkrete Methode der CuT und führt sie per invoke aus. Vorher schaltet sie noch mittels wrappedMethod.setAccessible(true) die Methodensichtbarkeit um.

Design for Testability!

Mit gutem Willen und tiefergehenden Kenntnissen der Java API ist es also doch möglich, einen Test für eine auf den ersten Blick untestbar erscheinende private Methode zu programmieren. Diesen Aufwand könnte man sich natürlich sparen, wenn schon der Programmierer von GpxDOMEditor die Testbarkeit seines Produktes bedacht hätte. Diese lässt sich im Beispiel durch eine default Sichtbarkeit der Methoden erreichen.

Um die Testbarkeit von Klassen und Methoden schon während ihrer Entwicklung zu gewährleisten, kann es sich als sinnvoll erweisen, als erstes die JUnit Tests zu schreiben. Dieser Ansatz ist als Test-driven development bekannt. Bei komplexen Systemen sollte man die Testbarkeit als festen Bestandteil der Architektur schon beim Entwurf berücksichtigen.