C’est cet article datant d’octobre 2000 (!) qui m’a montré la voie. Il explique en détail le mécanisme utilisé par la JVM pour invoquer le bon composant permettant d’interpréter correctement un protocole décrit dans une java.net.URL.

Pour montrer l’utilité de ce mécanisme dans un cas de programmation orientée par les tests, je vais prendre l’exemple de l’écriture d’une classe permettant de géocoder une adresse à travers l’API Google Maps. Cette API est accessible à travers le protocole HTTP. Pour ne pas solliciter constamment le service web nous allons donc mocker l’appel en se basant sur la documentation du service.

Remplacer le composant HTTP standard

Lors de l’appel à java.net.URL.openStream() la JVM interprète le protocole contenu dans l’URL. Si c’est "http" la JVM instanciera par défaut une sun.net.www.protocol.http.HttpURLConnection qui gèrera le dialogue avec le serveur. En définissant la propriété java.protocol.handler.pkgs avec un nom de package, la JVM introspectera ce package suivi du nom du protocole et de la classe nommée Handler et devant étendre java.net.URLStreamHandler.

Cela se traduit par le code suivant dans notre test unitaire :

@BeforeClass
public static void setupMockHTTP() {
    System.setProperty("java.protocol.handler.pkgs", "name.lemerdy.sebastian.mock");
}

Et par les deux classes suivantes dans le package name.lemerdy.sebastian.mock.http :

package name.lemerdy.sebastian.mock.http;

import java.io.IOException;
import java.net.URL;
import java.net.URLStreamHandler;

public class Handler extends URLStreamHandler {

    @Override
    protected URLConnection openConnection(URL url) throws IOException {
        return new URLConnection(url);
    }

}
package name.lemerdy.sebastian.mock.http;

import java.io.IOException;
import java.io.InputStream;
import java.net.URL;

public class URLConnection extends java.net.URLConnection {

    protected URLConnection(URL url) {
        super(url);
    }

    @Override
    public void connect() throws IOException {
        connected = true;
    }

    @Override
    public InputStream getInputStream() throws IOException {
        if (!connected) {
            connect();
        }
        return this.getClass().getResourceAsStream(url.getPath());
    }

}

Cette seconde classe ouvre un fichier dont le chemin dans le classpath est la même que celle de l’URL.

Mise en oeuvre dans l’exemple

Maintenant qu’on a remplacé l’envoi d’une requête HTTP par l’ouverture d’un fichier du classpath on peut donc tester notre service dont voici un extrait du code :

public static double[] geocode(String address) {
    double[] coordinates = null;
    if (address != null && !address.isEmpty()) {
        try {
            address = URLEncoder.encode(address, Charset.defaultCharset().name());
            final URL url = new URL("http://maps.googleapis.com/maps/api/geocode/xml?address=" + address + "&sensor=false");
            final XPath xPath = XPathFactory.newInstance().newXPath();
            XPathExpression xPathExpression = xPath.compile("/GeocodeResponse/result/geometry/location/lat|/GeocodeResponse/result/geometry/location/lng");
            final NodeList location = (NodeList) xPathExpression.evaluate(new InputSource(url.openStream()), XPathConstants.NODESET);
            if (location != null) {
                coordinates = new double[2];
                for (int i = 0; i < 2; i++) {
                    if (location.item(i) != null) {
                        coordinates[i] = Double.parseDouble(location.item(i).getTextContent());
                    }
                }
            }
        } catch (UnsupportedEncodingException e) {
        } catch (MalformedURLException e) {
        } catch (IOException e) {
        } catch (XPathExpressionException e) {
        }
    }
    return coordinates;
}

Enfin voici le test du code ci-dessus :

@Test
public void testGeocode() {
    assertThat(GeocodeService.geocode("1600 Amphitheatre Parkway, Mountain View, CA"), is(new double[] { 37.4217550d, -122.0846330d }));
}

Puisque le code à tester se connecte à l’URL http://maps.googleapis.com/maps/api/geocode/xml il suffit donc maintenant de créer un fichier nommé xml dans le package maps.api.geocode et dont le contenu sera envoyé lors de l’éxécution du test. Ce fichier XML pourra donc ressembler à ceci :

<GeocodeResponse>
    (...)
    <result>
    (...)
        <geometry>
            <location>
                <lat>37.4217550</lat>
                <lng>-122.0846330</lng>
            </location>
            (...)
        </geometry>
        (...)
    </result>
    (...)
</GeocodeResponse>