https://d226lax1qjow5r.cloudfront.net/blog/blogposts/testing-external-apis-in-ruby-with-webmock-and-vcr/webmock_vcr.png

Pruebas de API externas en Ruby con Webmock y VCR

Publicado el November 18, 2021

Tiempo de lectura: 13 minutos

Las pruebas automatizadas son desde hace tiempo parte integrante del proceso de desarrollo e implantación de software, y sus ventajas están bien establecidas. Entre otras cosas, utilizamos las pruebas para garantizar la calidad del código, evitar la regresión y, en el contexto del desarrollo basado en pruebas (TDD), como parte del proceso de escritura del código.

Como desarrolladores, estamos familiarizados y nos sentimos cómodos con el concepto de escribir conjuntos de pruebas automatizadas como parte de nuestro flujo de trabajo de desarrollo. Sin embargo, cuando nuestra aplicación aprovecha API externas, escribir esas pruebas conlleva un conjunto particular de desafíos. Exploremos algunos de ellos con un ejemplo.

Ejemplo: Envío de un SMS con Messages API de Vonage

Imaginemos una situación en la que estamos desarrollando una aplicación que incluye la funcionalidad de enviar mensajes de texto a través de SMS. Este sería un excelente caso de uso para la API de Messages de Vonage. Para implementar esta funcionalidad utilizando la Messages API, nuestra aplicación podría incluir una clase MessagesClient que defina un método send_sms método. El propósito de este método sería enviar una solicitud con el formato adecuado POST para utilizar el punto final de la API de Messages:

https://api.nexmo.com/v1/messages

En cuanto a las dependencias necesarias para las pruebas, nuestra aplicación Gemfile podría tener este aspecto (aunque, en realidad, es probable que nuestra aplicación incluya algunas dependencias adicionales).

Gemfile

# Gemfile

source "https://rubygems.org"
ruby "3.0.0"

gem 'faraday'

group :test do
  gem 'rspec'
end

En Faraday es una librería Ruby para gestionar peticiones y respuestas HTTP.

Nuestro archivo de aplicación definiría la clase MessagesClient con su método send_sms método.

app.rb

# app.rb
require "json"

class MessagesClient
  URI = 'https://api.nexmo.com/v1/messages'

  def send_sms(from_number, to_number, message)
    headers = generate_headers(message)
    body = {
      message_type: 'text',
      channel: 'sms',
      from: from_number,
      to: to_number,
      text: message
    }
    Faraday.new.post(URI, body.to_json, headers)
  end

  # additional methods omitted for brevity

end

En el ejemplo anterior, nuestro método send_sms define parámetros para el texto del mensaje, el número de destino y el número de origen. Incluye estos datos en un mensaje body que se envía en una solicitud POST al punto final de la API mediante el método post de Faraday. El método send_sms devuelve un objeto que representa la respuesta HTTP recibida por la instancia de Faraday.

Entonces, ¿cómo podríamos probar este método? Un enfoque para probar el camino feliz sería escribir una prueba que invoque el método, golpeando así el punto final de la API en vivo, y afirmando que recibimos un código de respuesta que indica el éxito. En el caso de la Messages API v1, sería un código de respuesta HTTP de 202.

messages_client_spec.rb

require "spec_helper"

describe MessagesClient do
  let(:app) { MessagesClient.new }

  describe "#send_sms" do
    let(:from_number) { "447700900000" }
    let(:to_number) { "447700900001" }
    let(:message) { "Hello world!" }

    it "returns status 202 Accepted" do
      response = app.send_sms(from_number, to_number, message)

      expect(response.status).to eq 202
    end
  end
end

Este enfoque de las pruebas plantea algunos problemas.

En primer lugar, cualquier prueba que interactúe con una dependencia externa, como una base de datos o una API externa, es probable que sea mucho más lenta que una que no lo haga. Si miramos un ejemplo de ejecución de esta prueba, podemos ver que tarda 1.63 segundos.

Finished in 1.63 seconds (files took 0.36557 seconds to load)
1 example, 0 failures

Aunque esto no parezca parecer tan lento, se trata de una única prueba para un único método. Dependiendo del tamaño y la complejidad de nuestra aplicación, podríamos tener numerosas pruebas similares. En tal caso, la ejecución de todo el conjunto de pruebas se volvería terriblemente lenta.

En segundo lugar, dado que nuestra prueba se basa en el envío de una solicitud HTTP a través de la red y su procesamiento por una dependencia externa, no podemos estar seguros de que se vaya a recibir una respuesta válida o, de hecho, ninguna respuesta. Puede haber problemas de red, cortes temporales u otros factores externos fuera de nuestro control, lo que significa que puede que no recibamos la respuesta esperada cada vez la respuesta esperada.

Hay muchas discusiones sobre el tema de las mejores prácticas para escribir pruebas automatizadas, y lo que los diferentes tipos de pruebas deben y no deben hacer. Sin embargo, algunos principios generalmente aceptados, especialmente cuando se trabaja con pruebas unitarias y pequeñas pruebas de integración, son que debemos intentar que nuestras pruebas sean rápidas y deterministas.

Cuanto más descendemos en la pirámide de las pruebas, más pruebas tenemos y más a menudo las ejecutamos. Por tanto, las pruebas rápidas son importantes en este nivel. Además, cuando utilizamos las pruebas como parte del proceso de desarrollo o para obtener retroalimentación temprana, es importante que nuestras pruebas sean deterministas; en otras palabras, queremos estar seguros de que, cuando se le proporciona una entrada específica, la prueba debe producir una salida predeterminada.

La consideración de estos principios en el contexto de nuestra send_sms presenta un problema. Queremos que nuestra prueba sea rápida y determinista, pero los problemas que conlleva acceder al punto final de la API en tiempo real van en contra de esos principios.

Hay otras consideraciones posibles cuando se utiliza una API externa, como los métodos HTTP no idempotentes (por ejemplo, la POST en nuestro método creará un mensaje SMS real cada vez que ejecutemos nuestra prueba), o posibles problemas con los costes o los límites de tarifa. Muchos de estos problemas adicionales podrían resolverse utilizando un sandbox de la API, y la Messages API ofrece un sandbox para algunos canales de mensajería.

El uso de una caja de arena es más pertinente para las pruebas que se encuentran más arriba en la pirámide, como las pruebas funcionales o de extremo a extremo y algunas pruebas de integración de mayor envergadura, y no resuelve realmente nuestros problemas de velocidad y determinismo. Lo que realmente queremos para nuestras pruebas de nivel inferior es una forma de no golpear la dependencia externa en absoluto. Una solución es mocking.

Burlándose de

Para cualquiera que no esté familiarizado con el término, mocking es una técnica de pruebas mediante la cual se utiliza una respuesta simulada o "falsa" como sustituto de una respuesta real de una parte interna o externa de nuestra aplicación. A nivel interno, puede tratarse de un objeto simulado devuelto por un método o una llamada a una función. En el contexto de las API externas, por lo general significa sustituir una respuesta HTTP real por otra simulada.

Una gran ventaja de este enfoque cuando se trabaja con una API externa es que, al no enviar una solicitud a través de la red y esperar la respuesta, la ejecución de una prueba se vuelve mucho más rápida. Además, al predefinir la respuesta HTTP, hacemos que nuestras pruebas sean deterministas.

Mocking parece ideal para resolver los problemas que hemos identificado en nuestra configuración de pruebas actual, así que ¿cómo podemos implementarlo en nuestras pruebas? send_sms prueba?

Presentación de Webmock

Webmock es una librería Ruby para stubbing y establecer expectativas en peticiones HTTP. Es compatible con varias bibliotecas HTTP diferentes y puede integrarse en varios marcos de pruebas, entre ellos rspec.

Como modelo mental de alto nivel, Webmock esencialmente hace lo siguiente:

  • Intercepta cualquier petición HTTP saliente realizada por nuestra aplicación

  • Coteja esas solicitudes con un "talón" prerregistrado.

  • Devuelve una respuesta predefinida para esa solicitud, en lugar de la respuesta HTTP real

Exploremos este modelo mental en acción añadiendo Webmock a nuestra configuración de prueba.

Ejemplo actualizado: Simulando nuestras respuestas HTTP con Webmock

En primer lugar, tenemos que añadir webmock a nuestro Gemfile y ejecutar bundle install.

Gemfile

# Gemfile

source "https://rubygems.org"
ruby "3.0.0"

gem 'faraday'

group :test do
  gem 'rspec'
  gem 'webmock'
end

Nota: también tenemos que añadir require 'webmock/rspec' a nuestro archivo spec_helper.

A continuación, podemos actualizar nuestra prueba para utilizar Webmock.

messages_client_spec.rb

require "spec_helper"

describe MessagesClient do
  let(:app) { MessagesClient.new }

  describe "#send_sms" do
    let(:from_number) { "447700900000" }
    let(:to_number) { "447700900001" }
    let(:message) { "Hello world!" }

    it "returns status 202 Accepted" do
      stub_request(:post, "https://api.nexmo.com/v1/messages").to_return(status: 202)
      response = app.send_sms(from_number, to_number, message)

      expect(response.status).to eq 202
    end
  end
end

Nuestra prueba actualizada registra un stubutilizando el método stub_request de Webmock. El stub se configura para que coincida con cualquier POST a https://api.nexmo.com/v1/messagesy devuelva un :status de 202.

Webmock intercepta la petición HTTP saliente, y en su lugar devuelve la respuesta predeterminada, por lo que ejecutar nuestra prueba es mucho más rápido que antes:

Finished in 0.00749 seconds (files took 0.50786 seconds to load)
1 example, 0 failures

Limitaciones de la burla

La adición de Webmock ha hecho que nuestra prueba sea rápida y determinista, por lo que parece una buena opción para nuestras necesidades de prueba. El uso de una herramienta mocking para probar APIs externas, sin embargo, viene con algunas advertencias.

Escribir mocks puede llevar mucho tiempo

Nuestro simulacro de ejemplo sólo define el código :status de la respuesta. Otros simulacros podrían necesitar definir también la respuesta :headers y/ o :body. Dependiendo de la API que se esté probando, esas cabeceras y cuerpo podrían ser bastante grandes y complejos, y requerir una buena cantidad de tiempo para definirlos en el mock.

Si esto se amplía a múltiples pruebas contra múltiples puntos finales de API, pronto podríamos estar ante una inversión de tiempo significativa para escribir esos mocks.

Los mocks pueden ser difíciles de mantener

En relación con este primer punto está mantenimiento. Las API externas pueden cambiar con el tiempo, añadiendo nuevas funciones o lanzando nuevas versiones. Por ejemplo, la API Messages API de Vonage lanzó recientemente una nueva versión. Si tenemos un gran número de mocks complejos para nuestras pruebas, el mantenimiento de esos mocks para mantenerse al día con los cambios puede tomar mucho tiempo y esfuerzo que podría ser mejor invertido en otra cosa.

Los mocks pueden hacer suposiciones incorrectas sobre las dependencias

Dado que los mocks se escriben para representar una respuesta particular en lugar de ser una respuesta real se basan necesariamente en suposiciones sobre cuál sería la respuesta real. Esto podría no ser un problema para nuestra prueba de ejemplo, pero a medida que las respuestas que queremos simular aumentan en complejidad, también lo hace la posibilidad de hacer una suposición incorrecta acerca de esa respuesta. Estas suposiciones incorrectas podrían llevar a que el código pase la prueba simulada pero no funcione correctamente. Es de esperar que tengamos pruebas más arriba en la pirámide que identifiquen estos problemas, pero lo ideal es que queramos detectarlos lo antes posible con nuestras pruebas de nivel inferior.

Para solucionar estas limitaciones, podemos recurrir a otra biblioteca de Ruby: VCR.

VCR

VCR es una biblioteca que sigue el patrón de pruebas de "grabación y reproducción" en el contexto de las solicitudes y respuestas HTTP. Básicamente, graba las interacciones HTTP de un conjunto de pruebas y las reproduce durante futuras ejecuciones de pruebas.

VCR implementa este patrón utilizando la idea de "casetes" (basada en las cintas de casete del obsoleto Video Cassette Recorder obsoleta). Cada "casete" es un archivo que contiene datos que representan la grabación de una interacción HTTP específica. La primera vez que se ejecuta una prueba, se produce un ciclo real de solicitud/respuesta HTTP y los detalles de este ciclo se graban como un casete.

El casete incluye información sobre la solicitud y la respuesta. Los datos de la solicitud se utilizan para comparar las solicitudes durante las pruebas posteriores, y los datos de la respuesta se utilizan para simular la respuesta esperada en esas pruebas. Dado que los 'mocks' utilizan real de respuesta reales, se resuelven los problemas mencionados anteriormente sobre el tiempo necesario para escribir y mantener los simulacros y las suposiciones que se hacen al hacerlo.

Esta forma de trabajar de VCR puede ser más fácil de visualizar si la examinamos en el contexto de nuestro montaje de prueba.

Ejemplo actualizado: Integración de VCR en nuestra configuración de pruebas

Primero tenemos que añadir vcr a nuestro Gemfile y ejecutar bundle install

Gemfile

# Gemfile

source "https://rubygems.org"
ruby "3.0.0"

gem 'faraday'

group :test do
  gem 'rspec'
  gem 'webmock'
  gem 'vcr'
end

A continuación, podemos actualizar nuestra prueba para utilizar VCR.

messages_client_spec.rb

require "spec_helper"
require "vcr"

VCR.configure do |config|
  config.cassette_library_dir = "spec/cassettes"
  config.hook_into :webmock
  config.configure_rspec_metadata!
end

describe MessagesClient do
  let(:app) { MessagesClient.new }

  describe "#send_sms" do
    let(:from_number) { "447700900000" }
    let(:to_number) { "447700900001" }
    let(:message) { "Hello world!" }

    it "returns status 202 Accepted" do
      response = VCR.use_cassette('send_sms') do
        app.send_sms(from_number, to_number, message)
      end

      expect(response.status).to eq 202
    end
  end
end

En este archivo requerimos vcr y luego configuramos nuestro VCR, (aunque si tuviéramos varios archivos de especificaciones, podríamos mover la configuración a nuestro archivo spec_helper archivo).

  • La configuración de cassette_library_dir configuración le dice a VCR dónde almacenar los 'cassettes'. Aquí especificamos un directorio spec/cassettesSi este directorio no existe, VCR lo creará.

  • La configuración de hook_into configuración le dice a VCR cómo engancharse a las peticiones HTTP. Como ya tenemos webmock como dependencia, lo especificamos aquí (aunque podríamos eliminarlo de nuestro archivo Gemfile y engancharnos directamente a Faraday en su lugar).

También hay muchas otras opciones de configuración disponibles.

En la propia prueba, hemos eliminado la solicitud de Webmock. En su lugar, establecemos la variable response al valor de retorno del método use_cassette de VCR, al que pasamos un nombre de casete como argumento y también un bloque. Bajo la configuración de grabación por defecto, si el cassette existe, VCR lo utilizará para construir un objeto de respuesta que podremos comprobar en nuestro test. Si el casete no existe, VCR llamará al bloque y utilizará lo que devuelva para crear el casete.

Cuando primero ejecutar nuestra prueba, ya que el casete no existe en ese momento, nos golpeó el punto final de la API y VCR utiliza esa interacción HTTP para crear una send_sms.yml que almacena los detalles de la solicitud y la respuesta HTTP.

send_sms.yml

---
http_interactions:
- request:
    method: post
    uri: https://api.nexmo.com/v1/messages
    body:
      encoding: UTF-8
      string: '{"message_type":"text","channel":"sms","from":"447700900000","to":"447700900001","text":"Hello
        world!"}'
    headers:
      User-Agent:
      - Faraday v1.8.0
      Authorization:
      - Basic xxxxxxxxxxxxxxxxxxxxxxxxxxx==
      Content-Type:
      - application/json
      Host:
      - api.nexmo.com
      Content-Length:
      - '12'
      Accept-Encoding:
      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
      Accept:
      - "*/*"
  response:
    status:
      code: 202
      message: Accepted
    headers:
      Server:
      - nginx
      Date:
      - Thu, 18 Nov 2021 12:35:49 GMT
      Content-Type:
      - application/json
      Content-Length:
      - '55'
    body:
      encoding: UTF-8
      string: '{"message_uuid":"c26213be-2916-4c64-903e-4125158eedd8"}'
  recorded_at: Thu, 18 Nov 2021 12:35:49 GMT
recorded_with: VCR 6.0.0

Cuando la prueba se ejecuta por primera vez, ya que estamos golpeando el punto final de la API, la ejecución es tan lenta como cuando no estábamos usando Webmock o VCR en absoluto.

Finished in 1.61 seconds (files took 0.58899 seconds to load)
1 example, 0 failures

En todos siguientes ejecuciones de prueba, VCR utilizará el casete. Primero comprobará que los detalles de la solicitud para la ejecución de la prueba coinciden con los registrados en el casete. Si coinciden Si coinciden, se utilizarán los detalles de respuesta del casete para crear un objeto de respuesta. En este caso, el código de respuesta registrado es 202 por lo que será el código status para nuestro objeto de respuesta. Dado que nuestra prueba afirma que response.status debe ser igual a 202nuestra prueba pasa.

Estas pruebas posteriores también son mucho más rápidas que las primeras.

Finished in 0.01033 seconds (files took 0.55884 seconds to load)
1 example, 0 failures

Trucos y consejos para videograbadoras

VCR ofrece mucha flexibilidad en cuanto a opciones de configuración.

Configurar la concordancia de solicitudes

Para reproducir una grabación, VCR necesita comparar las nuevas peticiones HTTP con los detalles de una grabación anterior. En comparación se puede hacer contra varios elementos de una solicitud.

La configuración por defecto es comparar con el método HTTP y el URI, pero esta configuración puede modificarse para comparar el host y la ruta por separado (en lugar del URI completo), los parámetros de consulta, las cabeceras de la solicitud y el cuerpo de la solicitud.

Además, se pueden crear comparadores personalizados para ofrecer aún más flexibilidad.

Volver a grabar, no desaparecer

Como se mencionó anteriormente, la API externa puede cambiar con el tiempo o lanzar nuevas versiones, lo que significa que las grabaciones pueden quedar "desactualizadas". Si esto ocurre, en lugar de tener que reescribir un mock entero (como haríamos con una configuración de mocking estándar) podemos grabar una nueva interacción HTTP para reemplazar la obsoleta. Hay varias formas de enfocar la regrabación:

  • La forma más bruta es borrar el archivo de la grabación actual. Si no existe ninguna grabación para una prueba específica, VCR grabará automáticamente una nueva.

  • También hay varios modos de grabación que se pueden configurar para determinar cuándo se realizan nuevas grabaciones. Por ejemplo, :once (que es el predeterminado) sólo graba nuevas interacciones si no hay un archivo de prueba, mientras que :new_episodes grabará una nueva interacción si existe un archivo para una prueba pero los detalles de la solicitud para esa prueba no coinciden exactamente con los registrados en el archivo.

  • Podemos activar la regrabación automática para volver a grabar las interacciones a intervalos regulares. Podemos configurar la opción :re_record_interval opción en la configuración para un casete en particular. Cuando se utilice ese casete, VCR comprobará la fecha y hora del casete con la hora actual. recorded_at marca de tiempo del casete con la hora actual. Si ha transcurrido más tiempo del especificado por la opción :re_record_intervalla interacción se volverá a grabar.

Cuidado con los datos sensibles

Al interactuar con una API externa, dependiendo del método de autenticación de dicha API, es muy posible que incluyamos datos confidenciales, como claves de API, como parte de nuestras solicitudes, por ejemplo en una solicitud de Autorización header. Estos datos estarán presentes en la grabación VCR como parte de la interacción. Si también estamos haciendo que el código de nuestro proyecto esté disponible públicamente, por ejemplo enviándolo a un repositorio público en GitHub, esto puede presentarnos un problema.

Una solución podría ser añadir grabaciones individuales, o todo nuestro cassettes a un archivo .gitignore archivo. Como alternativa, podemos utilizar la opción de configuración filter_sensitive_data de VCR para especificar una cadena de sustitución para determinados datos, que se mostrará en la grabación en lugar de los datos reales.

Utilizar la documentación

VCR proporciona una documentación de uso detallada para estas y muchas otras opciones de configuración, así como documentación API de la biblioteca.

Herramientas alternativas

Las herramientas que se tratan aquí están bien establecidas dentro del ecosistema Ruby, pero también hay muchas alternativas disponibles, tanto para rubyistas como para no rubyistas.

En términos de capacidades de imitación, la rspec-mocks biblioteca puede proporcionar capacidades de imitación a rspec. Algunas bibliotecas HTTP como Faraday proporcionan adaptadores que permiten definir peticiones stubbed. La funcionalidad mocking de Faraday (entre otras) también es compatible con VCR.

Además, existen muchos ports de VCR a otros lenguajes de programación, tales como vcrpy para Python, php-vcr para PHP, y scotch y Betamax.Net para .net/ C#. Nock proporciona una funcionalidad similar a la combinación de VCR y Webmock para Node.

Hay muchos más puertos a otros idiomas que figuran en el VCR READMEasí que, sea cual sea el idioma que utilices, es de esperar que haya una herramienta de grabación y reproducción que puedas utilizar.

¡Felices pruebas!

Compartir:

https://a.storyblok.com/f/270183/373x376/e8d3211236/karl-lingiah.png
Karl LingiahDefensor del desarrollador Ruby

Karl es un defensor de los desarrolladores para Vonage, centrado en el mantenimiento de nuestros SDK de servidor Ruby y la mejora de la experiencia de los desarrolladores para nuestra comunidad. Le encanta aprender, hacer cosas, compartir conocimientos y, en general, todo lo relacionado con la tecnología web.