Comment tester les interractions avec le monde extérieur (via HTTP)

Il m’arrive fréquemment d’avoir à écrire du code qui doit parler avec un serveur HTTP externe, par exemple, sur mon projet actuel, nous interagissons avec :

  • un serveur de paiement
  • un serveur de publicités
  • un streamer vidéo
  • un serveur d’options client

J’aime bien écrire mon code de test avant d’écrire mon code de production, et j’imagine que vous en faites autant. C’est ainsi que nous avons développé au fil du temps une librairie de tests qui nous permet de simuler un serveur HTTP. Cet librairie lance un véritable serveur qui écoute sur une socket. Nous pouvons lui donner des réponses standard (réponse 200 avec contenu OK, réponse 40x ou 50x, redirect etc) mais également lui donner un comportement plus avancé en lui passant un TraiteurDeReponse. Cela nous permet de simuler tous les intervenants extérieurs à notre produit pour nos tests unitaires mais aussi pour nos tests d’acceptance.

Voyons tout de suite un exemple de test qui utilise ce MockHttpServer. Notez que l’ouverture/fermeture d’une socket prend du temps, aussi le serveur n’est lancé une fois en début de test (@BeforeClass) et fermé après tous les tests de la classe (@AfterClass)

public class TesterUnServiceHttp {
  private static MockHttpServer serveurDeTest;
  private final static int PORT = 8084;

  @BeforeClass public static void demarreLeMockServeurHttp()
  throws Exception {
    // la creation du serveur provoque son démarrage
    serveurDeTest = new MockHttpServer(PORT);
  }

  @AfterClass public static void arreteLeMockServeurHttp()
  throws Exception {
    serveurDeTest.close();
  }
  
  @Test public void urlAppeleeParLeService() throws Exception {
    serveurDeTest.setReponse(TraiteurDeReponseFactory.repondOk());
    
    ServiceQuiTapeSurHttp service =
                 new ServiceQuiTapeSurHttp("127.0.0.1", PORT);
    
    assertEquals("url attendue (avec param)"
          serveurDeTest.getLastUrl());
  }

  // plein d'autres tests qui utilisent ce serveur HTTP
}

Dans cet exemple, je ne vérifie que l’URL appelée, mais je peux aussi vérifier les headers passés, le contenu que je lui ai envoyé. Ainsi, je fige dans mes tests le contrat d’interface entre notre produit et les produits externes. Le serveur de test répond par défaut 200/OK :

public class MockHttpServer {

  public enum HttpMethod {GET, POST, PUT, DELETE};
  // je ne peux montrer ici le code de cette classe :
  private final Serveur serveur;
  
  public MockHttpServer(final int port) throws IOException {
    this(port, "defaultId");
  }
  
  public MockHttpServer(final int port) throws IOException {
    this(port, TraiteurDeReponseFactory.repondOk());
  }
  
  public MockHttpServer(final int port,
    TraiteurDeReponse traiteurDeReponse) throws IOException {
    serveur = new Serveur(port, traiteurDeReponse);
    Executors.newSingleThreadExecutor().execute(serveur);
  }

  public void close() { serveur.close(); }

  public String getLastUrl() { return serveur.lastUrl; }
  public String getLastBody() { return serveur.lastBody; }
  public Map<String, String> getLastHeaders() { return serveur.lastHeaders; }
  public HttpMethod getLastMethod() { return serveur.lastMethod; }
}

mais on peut lui passer d’autres types de réponses. Les plus communes sont 200/OK, 500/KO, 30x/redirect, aussi une factory nous permet d’accéder rapidement à ce type de réponses (et de les factoriser) :

public class TraiteurDeReponseFactory {

  public static TraiteurDeReponse repondBadRequest() {
    return new TraiteurDeReponse(HttpStatus.SC_BAD_REQUEST,
                                 "Bad Request");
  }

  public static TraiteurDeReponse repondOk() {
    return new TraiteurDeReponse(HttpStatus.SC_OK, "OK");
  }
  
  public static TraiteurDeReponse redirect(String urlDestination) {
    final TraiteurDeReponse redirect =
          new TraiteurDeReponse(HttpStatus.SC_MOVED_TEMPORARILY, "");
    redirect.addHeader("Location", urlDestination);
    return redirect;
  }
  
}

Ainsi, on peut tester comment notre application réagit quand le service en face ne répond pas ce qui est défini dans le contrat d’interface ou si il met trop de temps à répondre. Par exemple, pour tracer les défaillances des services externes et expliquer comment cela impacte notre qualité de service, nous testons nos logs avec notre LoggueurEspion (qui fera l’objet d’un article à paraître prochainement). Oui, c’est la technique dite du parapluie. Enfin, il est possible de créer soi-même un type de réponse très spécifique et qui dépend des requêtes reçues. On peut écrire un serveur avec un peu de mémoire en implémentant un autre TraiteurDeReponse dont voici une partie du code :

public class TraiteurDeReponse {

  private final static String CRLF = "\r\n";
  
  public int statusHttp;
  public final String corpsDeLaReponse;
  public final Map<String, String> headers;
  public final long delaiDAttenteMillis;
  
  public TraiteurDeReponse(int statusHttp) {
    this(statusHttp, "pas utilise", 0);
  }
  
  public TraiteurDeReponse(int statusHttp, String corpsDeLaReponse) {
    this(statusHttp, corpsDeLaReponse, 0);
  }

  public TraiteurDeReponse(int statusHttp, String corpsDeLaReponse,
                           long delaiDAttenteMillis) {
    this.statusHttp = statusHttp;
    this.corpsDeLaReponse = corpsDeLaReponse;
    this.headers = new HashMap<String, String>();
    this.delaiDAttenteMillis = delaiDAttenteMillis;
  }
  
  public void setContentType(String contentType) {
    addHeader("Content-Type", contentType);
  }
  
  public void addHeader(String clef, String valeur) {
    headers.put(clef, valeur);
  }

  /* du code que je ne peux ouvrir pour le moment */
  
}

Ces classes sont si faciles à utiliser que nos tests d’acceptance (qui tournent sous Fitnesse) les utilisent aussi. Comme pour les tests unitaires, nous démarrons et arrêtons les serveur HTTP en début et en fin de suite : non seulement cela permet d’exécuter plus rapidement les tests, mais cela s’approche au plus de la réalité de ce que nous simulons dans laquelle ces services sont déjà présents et tournent en permanence. Pour réaliser cela avec Fitnesse, nous utilisons le setUp/tearDown de suite qui appele notre DemarreurDeSuite :

public class DemarreurDeSuite extends DoFixture {

  private static final Logger logger = Logger.getLogger(DemarreurDeSuite.class);

  public boolean demarre() throws Exception {
    logger.info("demarrage de tous les mock serveurs");
    demarreStreamer();
    demarrePublicite();
    demarrePaiement();
    demarreOptionsClient();
    logger.info("fin demarrage de tous les mock serveurs");
    return true;
  }
  
  public boolean arrete() throws Exception {
    logger.info("arret de tous les mock serveurs");
    arreteStreamer();
    arretePublicite();
    arretePaiement();
    arreteOptionsClient();
    logger.info("fin arret de tous les mock serveurs");
    return true;
  }

  /* implementation triviale */
  
}

Enfin, nous pouvons packager ces classes créées pour nos tests d’acceptance pour lancer des bouchons HTTP sur nos environnements d’intégration. Le code a donc été utilisé 3 fois :

  • dans les tests unitaires
  • dans les tests d’acceptance
  • sur les environnement d’intégration

La différence avec une approche où l’on mockerait les appels tient en trois points :

  • le code est réutilisable à de multiples niveaux (unitaire, acceptance, intégration)
  • le test est non intrusif et n’influence pas l’implémentation. Ainsi, vous pouvez changer de client HTTP (passer de l’API standard Sun à celle proposée par HttpClient d’Apache) sans avoir à toucher vos tests (oui, bien souvent l’approche “mock” des appels fige le design)
  • il est possible de tester des cas d’erreur très difficiles à traiter autrement (un read timeout par exemple)

Voilà ! J’espère que cet article vous a ouvert de nouvelles perspectives de tests. Nous n’avons pas de soucis de socket qui reste ouverte et qu’on ne peut réutiliser, mais avant d’en arriver là, il nous a fallu peaufiner et peaufiner encore notre code.

Comme pour la simulation du temps dans les tests, nous voudrions ouvrir le code de cette librairie, mais il faut que nous voyions cela auprès de notre employeur. Restez branchés !

Et vous, comment testez-vous vos interractions avec le monde HTTP ?


ps : Pascal Grange a également écrit sur le sujet et propose une solution très différente mais également intéressante : Tests unitaires, HTTP et Java

Réactions