Pages

dimanche 2 septembre 2012

Comment écrire des tests unitaires pour tester les appels à des webservices via Spring-ws ? (1/2)

Spring fournit un module très pratique spring-ws pour vous aider
  • à appeler un webservice distant (on dira que vous êtes client), 
  • à exposer des services de votre application via webservice à l’extérieur (dans ce cas vous êtes serveur). 
Nous allons voir dans cet article comment configurer un client et la partie serveur. Nous allons également voir comment simuler l’interaction avec le serveur en utilisant la librairie spring-ws-test. Cette dernière vous permet de créer des tests unitaires de vos webservices sans avoir à mettre en place un serveur d’application ou autre subterfuge.

Fonctionnellement le web service mis en place permet de donner de manière aléatoire la météo qu’il fait pour une date et une ville données. Tous les exemples de code sont disponibles dans le projet fdm-services disponible sur Github https://github.com/javamind/annexe/tree/master/fdm-webservices

Initialiser le projet

Si vous utilisez maven vous pouvez ajouter les dépendances suivantes
                <dependency>
            <groupId>org.springframework.ws</groupId>
            <artifactId>spring-ws-test</artifactId>
            <version>2.1.0.RELEASE</version>
            <scope>test</scope>
        </dependency>
       <dependency>
            <groupId>org.springframework.ws</groupId>
            <artifactId>spring-ws-core</artifactId>
            <version>2.1.0.RELEASE</version>
        </dependency>


Pour ce test j’ai également utilisé les librairies
  • org.apache.ws.commons.axiom:axiom-impl:1.2.11 
  • commons-httpclient:commons-httpclient :3.1 
  • javax.mail :mail :1.4.4 
  • javax.servlet:servlet-api :2.5 
  • junit :junit :4.8.2 
  • com.google.guava :guava :12.0 
Contract first : commencer par écrire le wsdl
La base d’un webservice est à mon sens le wsdl (web service description language) qui décrit le contrat du service que l’on expose. Il existe plusieurs écoles ou certains préfèreront travailler d’abord sur le code avant de générer le WSDL. Pour ma part, que ce soit pour du code classique ou des webservices, je suis un fervent partisan de spécifier le contrat avant de commencer à écrire les implémentations
  • on écrit l’interface et la classe de test avant d’écrire une classe concrète de notre application, 
  • on écrit le wsdl et la classe de test avant d’écrire l’implémentation d’un webservice 

meteo-1.0.wsdl décrit
<?xml version="1.0" encoding="UTF-8"?>
<wsdl:definitions name="meteo" targetNamespace="http://com.meteo.fake/meteo/"
      xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/" xmlns:tns="http://com.meteo.fake/meteo/"
      xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/">

      <wsdl:types>
            <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema"
                  targetNamespace="http://com.meteo.fake/meteo/">
                  <xsd:element name="AppelWsMeteo" type="tns:AppelWsMeteoType" />
                  <xsd:element name="ReponseWsMeteo" type="tns:ReponseWsMeteoType" />
                  <xsd:complexType name="AppelWsMeteoType">
                        <xsd:attribute name="ville" type="xsd:string" />
                        <xsd:attribute name="date" type="xsd:date"/>
                  </xsd:complexType>
                  <xsd:complexType name="ReponseWsMeteoType">
                        <xsd:attribute name="ville" type="xsd:string" />
                        <xsd:attribute name="indiceDeConfiance" type="xsd:integer"/>
                        <xsd:attribute name="tempsAnnonce" type="xsd:string" />
                        <xsd:attribute name="date" type="xsd:date"/>
                  </xsd:complexType>
        </xsd:schema>
      </wsdl:types>
      <wsdl:message name="meteovilleRequest">
            <wsdl:part element="tns:AppelWsMeteo" name="parameters" />
      </wsdl:message>
      <wsdl:message name="meteovilleResponse">
            <wsdl:part element="tns:ReponseWsMeteo" name="parameters" />
      </wsdl:message>
      <wsdl:portType name="MeteoPortType">
            <wsdl:operation name="readMeteoVille">
                  <wsdl:input message="tns:meteovilleRequest" />
                  <wsdl:output message="tns:meteovilleResponse" />
            </wsdl:operation>
      </wsdl:portType>
      <wsdl:binding name="MeteoBinding" type="tns:MeteoPortType">
            <soap:binding style="document" transport="http://schemas.xmlsoap.org/soap/http"/>
            <wsdl:operation name="readMeteoVille">
                  <soap:operation soapAction="http://com.meteo.fake/meteoville/1.0/read" />
                  <wsdl:input>
                        <soap:body use="literal" />
                  </wsdl:input>
                  <wsdl:output>
                        <soap:body use="literal" />
                  </wsdl:output>
            </wsdl:operation>
      </wsdl:binding>
      <wsdl:service name="meteoville-1.0">
            <wsdl:port name="meteo-port" binding="tns:MeteoBinding">
                  <soap:address location="http://com.meteo.fake/process/meteo" />
            </wsdl:port>
      </wsdl:service>
</wsdl:definitions>

L’écriture de ce fichier peut paraître déroutante mais les IDE comme (Spring Tool Suite basé sur Eclispe) par exemple proposent des assistants pour vous aider

Objets passés en arguments au webservice, objet retourné
Passons maintenant côté Java et nous allons commencer à transposer la déclaration des objets déclarés dans le WSDL en Java. Un webservice permet à deux applications hétérogènes d’échanger des informations au format XML.

Pour transcrire les objets Java en XML et faire l’opération inverse nous devons utiliser un marshaller. Prenons par exemple le marshaller Jaxb fournit par Spring. Un marshaller se configure de deux manières. Soit nous lui passons la liste des classes à prendre en compte dans son contexte soit nous définissons comme dans notre exemple les packages où se trouvent les DTO.

     <bean id="jaxb2Marshaller"
          class="org.springframework.oxm.jaxb.Jaxb2Marshaller">
        <property name="contextPath" value="com.ehret.scoresheet.ws.dto"/>
    </bean>

Lors de l’initialisation du contexte Jaxb via packages, nous devons soit définir une ObjectFactory soit ajouter un fichier jaxb.index pour que Jaxb sache quels objets prendre en compte. Nous créons donc par exemple un package com.ehret.scoresheet.ws.dto dans lequel nous ajoutons le fichier jaxb.index contenant les entrées
AppelWsMeteo
ReponseWsMeteo


Ce que je décris ici manuellement peut aussi être généré automatiquement via Maven et son plugin Jaxb. Regardons maintenant comment rendre notre DTO « manipulable » par Jaxb

@XmlRootElement (1)
@XmlAccessorType(XmlAccessType.FIELD) 
(2) 
public class AppelWsMeteo {
   @XmlAttribute 
(3) 
   private String ville;
   @XmlAttribute
   private Date date;

   public AppelWsMeteo(){ (4)
        super();
   }

   public AppelWsMeteo(String ville, Date date) {
      super();
      this.ville = ville;
      this.date = date;
   }
   public String getVille() {
      return ville;
   }
   public void setVille(String ville) {
      this.ville = ville;
   }
   public Date getDate() {
      return date;
   }
   public void setDate(Date date) {
      this.date = date;
   }
}


(1) @XmlRootElement permet de déclarer le fait que cet objet est mappé à un fichier XML
(2) @XmlAccessorType permet de spécifier si ce sont les propriétés ou les getter/setter qui sont pris en compte lors des transformations Java=> XMl ou inverse
(3) XmlAttribute permet d’indiquer qu’une propriété doit être prise en compte par le marshaller. Pour que ce ne soit pas le cas vous pouvez utiliser l’annotation @XmlTansient

(4) Chaque objet traité par Jaxb doit avoir au moins un constructeur vide

Nous faisons la même chose pour l’objet retourné par le webservice, ReponseWsMeteo.

Méthodes de la couche métier exposées via webservices

Au niveau de la classe effectuant le traitement nous allons utiliser plusieurs annotations
  • @EndPoint pour indiquer que cette classe doit être prise en compte dans le contexte Spring-ws 
  • @SoapAction pour raccrocher une méthode à la soap action définie dans le fichier WSDL. Il existe également l’annotation @PayloadRoot qui permet une déclaration plus fine mais je trouve l’annotation @SoapAction plus claire 
  • @ResponsePayload pour inquer quel est l’objet retourné est le message de réponse du webservice 
  • @RequestPayload pour indiquer quel est l’objet qui contient les données d’appel 
@Endpoint
public class MeteoServerWs {
   @SoapAction("http://com.meteo.fake/meteoville/1.0/read")
   @ResponsePayload
   public ReponseWsMeteo getMeteoVille(@RequestPayload AppelWsMeteo param) {
      Preconditions.checkNotNull(param, "Aucun paramaètre d'appel");
      Preconditions.checkArgument(StringUtils.isNotEmpty(param.getVille()),"Renseignez la ville!");
      Preconditions.checkNotNull(param.getDate(), "Saisissez la date d'interrogation!");
      
      ReponseWsMeteo reponse = new ReponseWsMeteo(param.getVille(), param.getDate());
      //comme la météo est un grand hazard
      int random = Double.valueOf(Math.random()*100).intValue();
      reponse.setIndiceDeConfiance(random);
      switch(random % 6){
         case 0:
            reponse.setTempsAnnonce("NEIGE");
            break;
         case 1:
            reponse.setTempsAnnonce("PLUIE");
            break;
         case 2:
            reponse.setTempsAnnonce("VARIABLE");
            break;
         case 3:
            reponse.setTempsAnnonce("COUVERT");
            break;
         default:
            reponse.setTempsAnnonce("BEAU");
            break;
      }
      return reponse;
   }
}


Côté configuration Spring nous devons spécifier les packages à scanner pour trouver nos classes EndPoint
      <web-services:annotation-driven />
      <context:annotation-config />
      <context:component-scan base-package="com.ehret.scoresheet.ws"/>
      <bean id="soapEndpointMapping" class="org.springframework.ws.soap.server.endpoint.mapping.SoapActionAnnotationMethodEndpointMapping">
            <property name="interceptors">
                  <list>
                  <bean class="org.springframework.ws.soap.server.endpoint.interceptor.SoapEnvelopeLoggingInterceptor" />
                <bean class="org.springframework.ws.server.endpoint.interceptor.PayloadLoggingInterceptor" />
                  </list>
            </property>
            <property name="order" value="1" />
      </bean>


La message factory qui permet de construire les différents messages échanges
      <bean id="messageFactory"
            class="org.springframework.ws.soap.axiom.AxiomSoapMessageFactory">
            <property name="payloadCaching" value="true" />
            <property name="attachmentCaching" value="true" />
      </bean>


L’emplacement du wsdl
        <bean id="webService"
            class="org.springframework.ws.wsdl.wsdl11.SimpleWsdl11Definition">
            <property name="wsdl" value="classpath:/meteo-1.0.wsdl" />
      </bean>


Et comment traiter les messages
      <bean class="org.springframework.ws.transport.http.WebServiceMessageReceiverHandlerAdapter">
            <property name="messageFactory" ref="messageFactory" />
      </bean>
      <bean id="messageDispatcher"
            class="org.springframework.ws.soap.server.SoapMessageDispatcher">
            <property name="endpointMappings">
                  <list>
                        <ref local="soapEndpointMapping" />
                  </list>
            </property>
      </bean>

Comment tester unitairement ce webservice (côté serveur)
J’ai souvent vu une couverture du code des webservices par les tests assez succincte. SoapUi offre par exemple une solution pour mettre facilement en place des tests d’intégration mais nous devons pour qu’ils fonctionnent utiliser un serveur d’application. Jetty qui se lance très vite et qui est bien géré via le plugin maven est une réponse à cette problématique mais ce type de test ne vérifie que les cas nominaux. Il est important de savoir quel est le comportement d’une application si une SoapFaultException est levé…

La solution à notre problème sont le module spring-ws-test et les mocks mis à disposition MockWebServiceClient et MockWebServiceServer.

Dans le cas des TU de notre webservice côté serveur nous avons besoin de simuler un client webservice qui nous appelle. Voici comment écrire notre classe et utiliser MockWebServiceClient. Le fonctionnement est simple on indique quel message est envoyé et ce que l’on attend comme retour (un message, une execption…). Le mock appelera la classe MeteoServerWs et vérifiera le résultat au comportement attendu que vous avez spécifié

@ContextConfiguration(locations = { "/communContext.xml","/serverWsContext.xml" })
@RunWith(SpringJUnit4ClassRunner.class)
public class MeteoServerWsTest {
   @Autowired
   private ApplicationContext applicationContext;
   private MockWebServiceClient mockClientWs;

   @Before
   public void setUp(){
      mockClientWs = MockWebServiceClient.createClient(applicationContext);
   }
   @Test
   public void testGetMeteoVilleOk() throws SOAPException{
      Source requestPayload = new StringSource("<appelWsMeteo ville=\"LYON\" date=\"2012-07-16T00:05:26.203\"/>");
      mockClientWs.sendRequest(RequestCreators.withPayload(requestPayload))
             .andExpect(noFault());
   }
}


Mais si vous lancez ce code vous avez l’exception suivante
java.lang.AssertionError: No endpoint can be found for request [AxiomSoapMessage appelWsMeteo]

En effet Spring n’est pas capable avec le message spécifié de retrouver la classe correspondant à la SOAP action. Lorsque l’on utilise l’annotation @SoapAction le message Soap doit contenir dans son header cette description de l’opération à effectuer. C’est un petit manque de spring-ws-test car la librairie ne propose pas de RequestCreators pour gérer ce cas. Nous devons créer notre propre RequestCreator qui gérera cette SoapAction

public abstract class SoapRequestCreators {
    public static RequestCreator withPayload(Source payload, String soapAction) {
        Assert.notNull(payload, "'payload' must not be null");
        return new WebServiceMessageCreatorAdapter(new PayloadMessageCreator(payload),soapAction);
    }
    private static class WebServiceMessageCreatorAdapter implements RequestCreator {
        private final WebServiceMessageCreator adaptee;
        private String soapAction;
        private WebServiceMessageCreatorAdapter(WebServiceMessageCreator adaptee, String soapAction) {
            this.adaptee = adaptee;
            this.soapAction=soapAction;
        }
        public WebServiceMessage createRequest(WebServiceMessageFactory messageFactory) throws IOException {
           WebServiceMessage message = adaptee.createMessage(messageFactory);
           if(message instanceof SoapMessage){
              SoapMessage soapMessage = (SoapMessage) message;
              soapMessage.setSoapAction(soapAction);
           }
           return message;
        }
    }
}


Modifiez ensuite la classe MeteoServerWsTest et le RequestCreator
mockClientWs.sendRequest(SoapRequestCreators.withPayload(requestPayload,"http://com.meteo.fake/meteoville/1.0/read"))
             .andExpect(noFault());


Dans notre exemple de webservice le retour n’est jamais déterministe à cause de la méthode random. Si ce n’était pas le cas il serait préférable d’utiliser ResponseMatchers#withPayload(message) plutôt que ResponseMatchers#noFault().

Ensuite vous pouvez tester les cas de SoapFault. Par exemple

@Test
   public void testGetMeteoVilleArgVilleVideKo() throws SOAPException{
      Source requestPayload = new StringSource("<appelWsMeteo ville=\"\" date=\"2012-07-16T00:05:26.203\"/>");
      //Dans ce cas on attend une fault avec un message indiquant l'erreur
 mockClientWs.sendRequest(SoapRequestCreators.withPayload(requestPayload,"http://com.meteo.fake/meteoville/1.0/read"))
             .andExpect(serverOrReceiverFault("Renseignez la ville!"));
   }


Prochain article 
Nous avons vu ce les éléments à mettre en place pour fournir un webservice, l’exposé à l’extérieur et le tester. Dans le prochain article nous verrons ce qu’il faut faire pour appeler un webservice.

Aucun commentaire:

Enregistrer un commentaire

Remarque : Seul un membre de ce blog est autorisé à enregistrer un commentaire.