Probar servicios web es fácil si practicas Karate

Si quieres probar de forma rápida y simple una API y, además, automatizar tus pruebas, ¿qué te parece si te enseño artes marciales? ¿Y si te prometo que cuando acabes de leer esta entrada serás capaz de conocer y aplicar los conceptos básicos de Karate?

Sí, lo sé. Ahora mismo estás pensando, ¿qué tienen en común el Karate, una API y el testing? Te lo cuento encantado.

Intuit lanzó el pasado mes de febrero una nueva herramienta de código abierto llamada Karate.  Su movimiento básico (o Kihon como se conoce en el arte marcial) es esforzarse en reducir la barrera entre desarrollo y prueba.

Esto permite a los desarrolladores escribir tests en los que realizar peticiones HTTP y comprobar que la respuesta devuelta es la que esperan (cabeceras, cookies, cuerpo, etc.). Además, utiliza un lenguaje “divertido” para ellos, puesto que emplea un lenguaje pseudo-técnico.

Karate extiende de Cucumber-JVM, lo que me permite ejecutar tests y generar informes desde un proyecto estándar de Java. Se apoya en la sintaxis Gherkin para escribir sus escenarios, lo que conlleva que el desarrollo se enfoque, en cierta forma, por comportamiento (BDD, Behaviour Driven Development).

“BDD es un proceso de desarrollo de software que trata de combinar los aspectos puramente técnicos y los de negocio, de manera que tengamos un marco de trabajo, y un marco de pruebas, en el que los requisitos de negocio formen parte del proceso de desarrollo.”

Otro punto importante es la “magia de Karate” con respecto a Cucumber. Lo veremos más adelante con un ejemplo, pero su punto fuerte reside en que no tendremos que crear un conjunto de Steps que realicen la lógica de la prueba.

Karate ya proporciona en su núcleo un conjunto base lo suficientemente amplio como para despreocuparse de ellos. Ahorrará tiempo y mantenibilidad a los desarrolladores. Para más detalle, la clase de steps se puede consultar aquí.

Podría seguir enumerando un listado de bondades de Karate, pero quiero entrar en materia con un ejemplo real. Os dejo, en cualquier caso, el siguiente listado para que consultéis estas y otras muchas ventajas más si os apetece.

Si te he convencido, pero no quieres limitarte a tener un Kyus o cinturón blanco, he subido el ejemplo completo a GitHub para que puedas descargártelo. Es un ejemplo completo y funcional, escrito en Java, JUnit y basado en Maven y Karate.

Sin embargo, te animo a continuar con mi post. Después de leerlo, serás capaz de conocer todas las técnicas antiguas de Karate que te permitirán, algún día, llegar a Dan o cinturón negro.

Creación y configuración

Una forma rápida y simple de comenzar nuestro nuevo proyecto con Karate es usar el artefacto que nos proporciona Maven. Basta con nuestro terminal favorito, dirigirnos a nuestro espacio de trabajo y ejecutar el siguiente comando Maven:

mvn archetype:generate \
-DarchetypeGroupId=com.intuit.karate \
-DarchetypeArtifactId=karate-archetype \
-DarchetypeVersion=0.6.2 \
-DgroupId=com.paradigmadigital \
-DartifactId=karate-example

Donde cabe destacar que:

  • Los tres primeros atributos indican el artefacto de Karate y su versión (la última hasta la fecha).
  • Los dos últimos indican el nombre del proyecto que hemos creado y la paquetería base.

Tras la creación del proyecto, la estructura de directorios que tenemos es:

src/test/java
    |
    +-- karate-config.js
    +-- logback-test.xml
    |
    \-- examples
        \-- users
        |   |
        |   +-- users.feature
        |   +-- UsersRunner.java
        |
        +-- ExamplesTest.java

Como enfocaremos nuestro ejemplo en probar distintos servicios web para obtener información sobre empresas del serctor IT, lo primero que haremos será renombrar los ficheros y carpetas para sustituir “users” por “companies”.

Al igual que Cucumber, para ejecutar nuestros tests necesitamos una clase de tipo “Runner” integrada con JUnit. Su definición básica en código Java no puede ser más sencilla y ya la tenemos creada:

package examples.companies;

import com.intuit.karate.junit4.Karate;
import org.junit.runner.RunWith;

@RunWith(Karate.class)
public class CompaniesRunner {
}

Aunque el nombre de la clase no tiene por qué ser necesariamente así, es recomendable seguir una nomenclatura como en cualquier lenguaje de programación. Lo verdaderamente importante es dónde creemos la clase.

En este caso, en la misma ruta que el conjunto de tests que queremos ejecutar, como son los de tipo que los tests de tipo “Companies”.

Diseñar los tests

Dentro de la paquetería anterior también se ha creado un fichero con extensión .feature. De nuevo recurrimos a Cucumber para explicar su significado. En estos ficheros se definen los tests.

Es aquí donde se escribe la colección de escenarios que tienen como objetivo probar una funcionalidad en concreto. De aquí en adelante se espera que estemos familiarizados con la estructura básica de estos ficheros, por lo que os animo a consultar la documentación online de la que dispone Cucumber.

Una vez tengamos el conocimiento, recomiendo que borremos el fichero users.feature y partamos de cero, con el fin de empezar poco a poco e ir mejorando nuestros tests en el transcurso del post.

Toma de contacto. Método GET

Para comenzar nuestra primera prueba básica, necesitaremos definir la API REST donde estén la colección de servicios web a probar. Si no queremos depender de una API pública, yo aconsejo mockearla, por ejemplo con WireMock. Añadiremos la dependencia en el fichero de configuración Maven, a nuestro pom.xml.

...
<properties>
   <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
   <java.version>1.8</java.version>
   <maven.compiler.version>3.6.0</maven.compiler.version>
   <karate.version>0.6.2</karate.version>
   <wiremock.version>2.14.0</wiremock.version>
   <junit.version>4.12</junit.version>

</properties>

<dependencies>
   <dependency>
       <groupId>com.intuit.karate</groupId>
       <artifactId>karate-apache</artifactId>
       <version>${karate.version}</version>
       <scope>test</scope>
   </dependency>           
   <dependency>
       <groupId>com.intuit.karate</groupId>
       <artifactId>karate-junit4</artifactId>
       <version>${karate.version}</version>
       <scope>test</scope>
   </dependency>
   <dependency>
       <groupId>com.github.tomakehurst</groupId>
       <artifactId>wiremock</artifactId>
       <version>${wiremock.version}</version>
       <scope>test</scope>
   </dependency>
   <dependency>
       <groupId>junit</groupId>
       <artifactId>junit</artifactId>
       <version>${junit.version}</version>
       <scope>test</scope>
   </dependency>
</dependencies>
...

Dentro de nuestra colección simulada de servicios web, podemos empezar con una prueba en la que obtener un listado de empresas y comprobar que, al menos, el servicio nos devuelve uno.

Nuestro mock en Java (dentro de la clase CompaniesRunner) quedaría de la siguiente manera:

package examples.companies;

import com.github.tomakehurst.wiremock.WireMockServer;
import com.intuit.karate.junit4.Karate;

import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.runner.RunWith;

import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.configureFor;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;

@RunWith(Karate.class)
public class CompaniesRunner {

   private static final WireMockServer wireMockServer = new WireMockServer();

   private static final String URL = "/companies";

   @BeforeClass
   public static void setUp() {
       wireMockServer.start();

       configureFor("localhost", 8080);

       stubForGetAllCompanies();
   }

   private static void stubForGetAllCompanies() {
       stubFor(get(urlEqualTo(URL))
               .willReturn(aResponse()
                       .withStatus(200)
                       .withHeader("Content-Type", "application/json")
                       .withBody(getAllCompanies())));
   }

   private static String getAllCompanies() {
       return "[" + getParadigmaDigitalCompany() + ", " + getMinsaitCompany() + "]";
   }

   private static String getParadigmaDigitalCompany() {
       return "{" +
               " \"cif\":\"B84946656\"," +
               " \"name\":\"Paradigma Digital\"," +
               " \"username\":\"paradigmadigital\"," +
               " \"email\":\"info@paradigmadigital.com\"," +
               " \"address\":{" +
               "    \"street\":\"Atica 4, Via de las Dos Castillas\"," +
               "    \"suite\":\"33\"," +
               "    \"city\":\"Pozuelo de Alarcon, Madrid\"," +
               "    \"zipcode\":\"28224\"" +
               " }," +
               " \"website\":\"https://www.paradigmadigital.com\"" +
               "}";
   }

   private static String getMinsaitCompany() {
       return "{" +
               " \"cif\":\"B82627019\"," +
               " \"name\":\"Minsait by Indra\"," +
               " \"username\":\"minsaitbyindra\"," +
               " \"email\":\"info@minsait.com\"," +
               " \"address\":{" +
               "    \"street\":\"Av. de Bruselas\"," +
               "    \"suite\":\"35\"," +
               "    \"city\":\"Alcobendas, Madrid\"," +
               "    \"zipcode\":\"28108\"" +
               " }," +
               " \"website\":\"https://www.minsait.com\"" +
               "}";
   }

   @AfterClass
   public static void tearDown() {
       wireMockServer.stop();
   }

}

Y la sintaxis para definir el test:

Feature: testing company mock web services

 Background:
  * url 'http://localhost:8080'

 Scenario: get all companies

  Given path 'companies'
  When method get 
  Then status 200
  And match $ == '#[2]'

Si entramos en detalle en cómo hemos definido la escenario:

  1. En el Dado o Given, definimos la ruta en la que está disponible el servicio.
  2. En el Cuando o When, el tipo de método, GET.
  3. En el Entonces o Then, esperamos que la respuesta HTTP de la operación sea 200.
  4. En el Entonces o And, comprobamos que el cuerpo de la respuesta, $, sea igual a un array, ‘#[]’ y que el número de elementos sea 2.

Con nuestro primer test escrito en tiempo récord, sólo nos falta ejecutarlo. Podemos hacerlo desde nuestro IDE favorito o por línea de comandos. Para el segundo caso, como sólo queremos ejecutar los tests de la colección “Companies”, usaremos el siguiente comando Maven:

mvn test -Dtest=CompaniesRunner

Obteniendo este resultado como salida:

-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running examples.companies.CompaniesRunner
11:04:38.682 [main] INFO  org.eclipse.jetty.util.log - Logging initialized @1689ms
11:04:38.794 [main] INFO  org.eclipse.jetty.server.Server - jetty-9.2.22.v20170606
11:04:38.815 [main] INFO  o.e.j.server.handler.ContextHandler - Started o.e.j.s.ServletContextHandler@72e5a8e{/__admin,null,AVAILABLE}
11:04:38.820 [main] INFO  o.e.j.server.handler.ContextHandler - Started o.e.j.s.ServletContextHandler@3527942a{/,null,AVAILABLE}
11:04:38.843 [main] INFO  o.e.j.s.NetworkTrafficServerConnector - Started NetworkTrafficServerConnector@41a0aa7d{HTTP/1.1}{0.0.0.0:8080}
11:04:38.845 [main] INFO  org.eclipse.jetty.server.Server - Started @1854ms
11:04:39.400 [qtp1431467659-15] INFO  /__admin - RequestHandlerClass from context returned com.github.tomakehurst.wiremock.http.AdminRequestHandle
r. Normalized mapped under returned 'null'
11:04:40.449 [main] INFO  com.intuit.karate - karate.env system property was: null
11:04:40.548 [main] DEBUG com.intuit.karate -
1 > GET http://localhost:8080/companies
1 > Accept-Encoding: gzip,deflate
1 > Connection: Keep-Alive
1 > Host: localhost:8080
1 > User-Agent: Apache-HttpClient/4.5.3 (Java/1.8.0_152)

11:04:40.550 [qtp1431467659-16] INFO  / - RequestHandlerClass from context returned com.github.tomakehurst.wiremock.http.StubRequestHandler. Norma
lized mapped under returned 'null'
11:04:40.579 [main] DEBUG com.intuit.karate -
1 < 200
1 < Content-Type: application/json
1 < Server: Jetty(9.2.22.v20170606)
1 < Transfer-Encoding: chunked
1 < Vary: Accept-Encoding, User-Agent
[{ "cif":"B84946656", "name":"Paradigma Digital", "username":"paradigmadigital", "email":"info@paradigmadigital.com", "address":{    "street":"Ati
ca 4, Via de las Dos Castillas",    "suite":"33",    "city":"Pozuelo de Alarcon, Madrid",    "zipcode":"28224" }, "website":"https://www.paradigma
digital.com"}, { "cif":"B82627019", "name":"Minsait by Indra", "username":"minsaitbyindra", "email":"info@minsait.com", "address":{    "street":"A
v. de Bruselas",    "suite":"35",    "city":"Alcobendas, Madrid",    "zipcode":"28108" }, "website":"https://www.minsait.com"}]

11:04:40.584 [main] DEBUG com.intuit.karate - response time in milliseconds: 72
1 Scenarios (1 passed)
5 Steps (5 passed)
0m0,992s
html report: (paste into browser to view)
-----------------------------------------
file:./../workspace/karate-example/target/surefire-reports/TEST-examples.companies.companies.html

11:04:40.857 [main] INFO  o.e.j.s.NetworkTrafficServerConnector - Stopped NetworkTrafficServerConnector@41a0aa7d{HTTP/1.1}{0.0.0.0:8080}
11:04:40.860 [main] INFO  o.e.j.server.handler.ContextHandler - Stopped o.e.j.s.ServletContextHandler@3527942a{/,null,UNAVAILABLE}
11:04:40.861 [main] INFO  o.e.j.server.handler.ContextHandler - Stopped o.e.j.s.ServletContextHandler@72e5a8e{/__admin,null,UNAVAILABLE}
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.566 sec

Results :

Tests run: 6, Failures: 0, Errors: 0, Skipped: 0

[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 6.309 s
[INFO] Finished at: 2018-02-11T11:04:40+01:00
[INFO] Final Memory: 12M/225M
[INFO] ------------------------------------------------------------------------

Mejorando las comprobaciones

Si, por ejemplo, el servicio que acabamos de usar se utiliza en una web donde mostrar la información de contacto de empresas partners, seguramente nos interese, como mínimo, información relativa a sus nombres y emails de contacto. Traducido a nuestra prueba, será comprobar que los atributos “name” y “email” están presentes para todas las empresas.

Así, nuestro test no solo comprobará que el servicio nos proporciona todas las empresas esperadas, sino que los atributos anteriores tienen algún valor.

Feature: testing company mock web services

 Background:
  * url 'http://localhost:8080'

 Scenario: get all companies

  Given path 'companies'
  When method get
  Then status 200
  And match $ == '#[2]'
  And match each $ contains {name: '#notnull'}
  And match each $ contains {email: '#notnull'}

También con expresiones regulares

Un paso más allá es que no nos contentemos con comprobar que el email no es nulo. Mediante expresiones regulares somos capaces de asegurar que el email de cada empresa cumple con una validación mínima en cuanto a formato se refiere. Si empleamos una expresión regular del tipo:

[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,4}

El test quedaría de esta forma:

Feature: sample karate test script

 Background:
   * url 'http://localhost:8080'

 Scenario: get all companies

   Given path 'companies'
   When method get
   Then status 200
   And match $ == '#[2]'
   And match each $ contains {name: '#notnull'}
   And match each $ contains {email: '#regex [A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,4}'}

Parametrizando el escenario

Al igual que con Cucumber, es posible parametrizar los escenarios. Para conseguirlo hay que hacer uso de un Scenario Outline, parámetros de sustitución o placeholders delimitados por < > y una tabla de ejemplos de prueba o Examples.

En el mismo feature anterior podemos añadir un nuevo escenario, en este caso, para consultar el nombre de las empresas partners a partir de su CIF, identificado como “cif” y, finalmente, comprobar que el valor “name” es el que esperamos recibir:

Scenario Outline: check company name

 Given path 'companies', '<cif>'
 When method get
 Then status 200
 And match $ contains {name: '<name>'}

 Examples:
   | cif       | name              |
   | B84946656 | Paradigma Digital |
   | B82627019 | Minsait by Indra  |

La ejecución del test anterior fallará, puesto que no tenemos simulado el servicio web de recuperación de compañía dado su CIF. Creamos los mocks en CompaniesRunner, añadiendo en el método setUp las siguientes dos llamadas a un nuevo método para obtener compañías por su CIF:

...
stubForGetCompanyByCIF("B84946656", getParadigmaDigitalCompany());
stubForGetCompanyByCIF("B82627019", getMinsaitCompany());
...
private static void stubForGetCompanyByCIF(String cif, String companyByCIFResponse) {
   stubFor(get(urlMatching(URL + "/" + cif))
           .willReturn(aResponse()
                   .withStatus(200)
                   .withHeader("Content-Type", "application/json")
                   .withBody(companyByCIFResponse)));
}
...

Si volvemos a ejecutar los tests ya no fallarán.

Hagamos una petición POST

Ya hemos visto que no atañe demasiada complejidad realizar y comprobar peticiones GET. Continuemos ahora con una de tipo POST. En el siguiente escenario, daremos de alta varias nuevas empresas partner y comprobamos que se crean según lo esperado. Para simplificar el proceso, asumimos que sólo los campos obligatorios para dar de alta una empresa en el sistema son: “cif”, “name” y “email”.

Para que no tengamos el mismo error anterior, crearemos primero el mock. De nuevo añadimos al método setUp un par de llamadas al nuevo método Java de creación de compañías:

...
stubForCreateCompany("B18996504", "Stratio", "info@stratio.com");
...
private static void stubForCreateCompany(String cif, String name, String email) {
   stubFor(post(urlEqualTo(URL))
           .withHeader("content-type", equalTo("application/json"))
           .withRequestBody(containing("cif"))
           .withRequestBody(containing("name"))
           .withRequestBody(containing("email"))
           .willReturn(aResponse()
                   .withStatus(201)
                   .withHeader("Content-Type", "application/json")
                   .withBody(
                           "{" +
                                   "   \"cif\": \"" + cif + "\"," +
                                   "   \"name\": \"" + name + "\"," +
                                   "   \"email\": \"" + email + "\"" +
                                   "}")));
}
...

Y el test Gherkin quedaría así:

Scenario Outline: create new companies

 Given path 'companies'
 And def company =
 """
 {
   "cif": '<cif>',
   "name": '<name>',
   "email": '<email>'
 }
 """
 And request company
 When method post
 Then status 201
 And match $ contains company
 And match $ contains {cif: '#notnull'}

 Examples:
   | cif       | name    | email              |
   | B18996504 | Stratio | info@stratio.com   |

Nuestro test hace uso de nuevas sentencias que necesitan de una explicación:

  1. Dentro de las precondiciones, hemos definido (def) un nuevo cliente en forma de JSON y lo hemos usado como parte de la petición (request) en la siguiente cláusula Given.
  2. El método empleado ha sido post, como anunciamos anteriormente.
  3. Comprobamos que se ha creado la nueva empresa, de tal forma que la respuesta contenga el cliente definido y que además tenga un identificador no nulo.

Conclusión

Si habéis llegado hasta aquí tengo por seguro que os han surgido preguntas del tipo ¿es Karate un “uso simplificado” de Cucumber y BDD? ¿No hay mucha implementación en sus escenarios?

Lo cierto es que todas las respuestas anteriores son afirmativas. Si somos puristas, Karate no es para equipos o empresas que decidan seguir a rajatabla la práctica de BDD. Y sí, su lenguaje no es una abstracción a alto nivel que le resulte cómodo aúna persona de Negocio.

Sin embargo, está a mitad de camino y es un buen paso hacia delante en el afán por animar a generar pruebas automáticas y facilitar su implementación. Se apoya en Gherkin para hacer los tests más entendibles y, sobre todo, simplifica en gran medida el tiempo y esfuerzo necesario para probar servicios web HTTP.

Cordobés y madrileño por amor. Ingeniero informático y Scrum Master dentro de Paradigma. En mis ratos libres, QAradigma al que no se le escapa un bug. Champion total del Fifa y de algún que otro partido de ping-pong.

Ver toda la actividad de Manuel Abril

Escribe un comentario