Escribiendo un contenedor de API en Ruby con TDD

Tarde o temprano, todos los desarrolladores deben interactuar con una API. La parte más difícil siempre está relacionada con la prueba confiable del código que escribimos y, como queremos asegurarnos de que todo funcione correctamente, ejecutamos continuamente el código que consulta la API en sí. Este proceso es lento e ineficiente, ya que podemos experimentar problemas de red e inconsistencias en los datos (los resultados de la API pueden cambiar). Repasemos cómo podemos evitar todo este esfuerzo con Ruby..


Nuestra meta

"El flujo es esencial: escriba las pruebas, ejecútelas y vea cómo fallan, luego escriba el código de implementación mínimo para hacerlas aprobar. Una vez que todas lo hagan, refactorice si es necesario".

Nuestro objetivo es simple: escribir un pequeño envoltorio alrededor de la API de Dribbble para recuperar información sobre un usuario (llamado "jugador" en el mundo de Dribbble).
Como usaremos Ruby, también seguiremos un enfoque TDD: si no está familiarizado con esta técnica, Nettuts + tiene una buena introducción a RSpec que puede leer. En pocas palabras, escribiremos pruebas antes de escribir nuestra implementación de código, para que sea más fácil detectar errores y lograr una alta calidad de código. El flujo es esencial: escriba las pruebas, ejecútelas y vea cómo fallan, luego escriba el código de implementación mínimo para hacerlas aprobar. Una vez que todos lo hacen, refactorizar si es necesario.

La API

La API de Dribbble es bastante sencilla. En el momento de esto, solo admite solicitudes GET y no requiere autenticación: un candidato ideal para nuestro tutorial. Además, ofrece un límite de 60 llamadas por minuto, una restricción que muestra perfectamente por qué trabajar con API requiere un enfoque inteligente..


Conceptos clave

Este tutorial debe suponer que está familiarizado con los conceptos de prueba: accesorios, simulacros, expectativas. Las pruebas son un tema importante (especialmente en la comunidad Ruby) e incluso si no eres un Rubyist, te animo a profundizar en el tema y buscar herramientas equivalentes para tu idioma cotidiano. Si lo desea, lea "El libro RSpec" de David Chelimsky et al., Una excelente introducción a Behavior Driven Development..

Para resumir aquí, aquí hay tres conceptos clave que debe saber:

  • Burlarse de: también llamado doble, un simulacro es "un objeto que representa a otro objeto en un ejemplo". Esto significa que si queremos probar la interacción entre un objeto y otro, podemos burlarnos del segundo. En este tutorial, nos burlaremos de la API de Dribbble, ya que para probar nuestro código no necesitamos la API en sí misma, sino algo que se comporte así y expone la misma interfaz..
  • Accesorio: un conjunto de datos que recrea un estado específico en el sistema. Se puede utilizar un dispositivo para crear los datos necesarios para probar una lógica..
  • Expectativa: un ejemplo de prueba escrito desde el punto de vista del resultado que queremos lograr.

Nuestras herramientas

"Como práctica general, ejecute pruebas cada vez que las actualice".

WebMock es una biblioteca de simulacros de Ruby que se utiliza para simular (o código auxiliar) las solicitudes de http. En otras palabras, le permite simular cualquier solicitud HTTP sin hacer una. La principal ventaja de esto es poder desarrollar y probar contra cualquier servicio HTTP sin necesitar el servicio en sí mismo y sin incurrir en problemas relacionados (como límites de API, restricciones de IP y demás).
La videograbadora es una herramienta complementaria que registra cualquier solicitud http real y crea un dispositivo, un archivo que contiene todos los datos necesarios para replicar esa solicitud sin volver a realizarla. Lo configuraremos para usar WebMock para hacer eso. En otras palabras, nuestras pruebas interactuarán con la API real de Dribbble solo una vez: después de eso, WebMock eliminará todas las solicitudes gracias a los datos registrados por VCR. Tendremos una réplica perfecta de las respuestas de la API de Dribbble grabadas localmente. Además, WebMock nos permitirá probar casos perimetrales (como el tiempo de espera de la solicitud) de manera fácil y consistente. Una maravillosa consecuencia de nuestra configuración es que todo será extremadamente rápido..

En cuanto a las pruebas unitarias, utilizaremos Minitest. Es una biblioteca de pruebas unitarias rápida y simple que también respalda las expectativas en el modo RSpec. Ofrece un conjunto de características más pequeño, pero me parece que esto realmente lo alienta y lo empuja a separar su lógica en métodos pequeños y comprobables. Minitest es parte de Ruby 1.9, por lo que si lo está utilizando (espero que sí) no necesita instalarlo. En Ruby 1.8, es sólo una cuestión de gema instalar minitest.

Usaré Ruby 1.9.3: si no lo hace, probablemente encontrará algunos problemas relacionados con require_relative, pero he incluido el código de reserva en un comentario justo debajo de él. Como práctica general, debe ejecutar pruebas cada vez que las actualice, incluso si no menciono este paso explícitamente en el tutorial..


Preparar

Utilizaremos lo convencional. / lib y /especulación Estructura de carpetas para organizar nuestro código. En cuanto al nombre de nuestra biblioteca, lo llamaremos. Plato, siguiendo la convención de Dribbble de usar términos relacionados con el baloncesto.

El Gemfile contendrá todas nuestras dependencias, aunque son bastante pequeñas.

 fuente: rubygems gema grupo 'httparty': prueba hacer gema 'webmock' gema 'vcr' gema 'girar' gema 'rastrillo' final

Httparty es una gema fácil de usar para manejar solicitudes HTTP; Será el núcleo de nuestra biblioteca. En el grupo de prueba, también agregaremos Turn para cambiar la salida de nuestras pruebas para que sean más descriptivas y admitan el color..

los / lib y /especulación Las carpetas tienen una estructura simétrica: para cada archivo contenido en el / lib / plato carpeta, debe haber un archivo dentro / spec / plato con el mismo nombre y el sufijo '_spec'.

Empecemos por crear una /lib/dish.rb Archivo y añadir el siguiente código:

 requiere "httparty" Dir [File.dirname (__ FILE__) + '/dish/*.rb'◆.each do | file | requiere el final del archivo

No hace mucho: requiere 'httparty' y luego itera sobre cada .rb archivo dentro / lib / plato para exigirlo. Con este archivo en su lugar, podremos agregar cualquier funcionalidad dentro de archivos separados en / lib / plato y que se cargue automáticamente solo por requerir este único archivo.

Vamos a la /especulación carpeta. Aquí está el contenido de la spec_helper.rb expediente.

 # necesitamos el archivo de biblioteca real require_relative '… / lib / dish' # para Ruby < 1.9.3, use this instead of require_relative # require(File.expand_path('… /… /lib/dish', __FILE__)) #dependencies require 'minitest/autorun' require 'webmock/minitest' require 'vcr' require 'turn' Turn.config do |c| # :outline - turn's original case/test outline mode [default] c.format = :outline # turn on invoke/execute tracing, enable full backtrace c.trace = true # use humanized test names (works only with :outline format) c.natural = true end #VCR config VCR.config do |c| c.cassette_library_dir = 'spec/fixtures/dish_cassettes' c.stub_with :webmock end

Hay algunas cosas aquí que vale la pena mencionar, así que vamos a analizarlas pieza por pieza:

  • Al principio, requerimos el archivo lib principal para nuestra aplicación, por lo que el código que queremos probar está disponible para el conjunto de pruebas. los require_relative declaración es una adición Ruby 1.9.3.
  • Entonces requerimos todas las dependencias de la biblioteca: minitest / autorun Incluye todas las expectativas que usaremos., webmock / minitest agrega los enlaces necesarios entre las dos bibliotecas, mientras que VCR y giro son bastante autoexplicativos.
  • El bloque de configuración de Turn solo necesita ajustar nuestra salida de prueba. Utilizaremos el formato de esquema, donde podemos ver la descripción de nuestras especificaciones..
  • Los bloques de configuración de la videograbadora le dicen a la videograbadora que almacene las solicitudes en una carpeta de dispositivos (observe la ruta relativa) y que use WebMock como una biblioteca de marcas (la videograbadora admite otras).

Por último, pero no menos importante, el Rakefile que contiene algún código de soporte:

 require 'rake / testtask' Rake :: TestTask.new do | t | t.test_files = FileList ['spec / lib / dish / * _ spec.rb'] t.verbose = true end task: default =>: test

los rake / testtask biblioteca incluye un TestTask Clase que es útil para establecer la ubicación de nuestros archivos de prueba. De ahora en adelante, para ejecutar nuestras especificaciones, solo escribiremos rastrillo desde el directorio raíz de la biblioteca.

Como una forma de probar nuestra configuración, agreguemos el siguiente código a /lib/dish/player.rb:

 módulo plato clase jugador final fin

Entonces /spec/lib/dish/player_spec.rb:

 require_relative '… /… / spec_helper' # Para Ruby < 1.9.3, use this instead of require_relative # require (File.expand_path('./… /… /… /spec_helper', __FILE__)) describe Dish::Player do it "must work" do "Yay!".must_be_instance_of String end end

Corriendo rastrillo Debería darte un pase de prueba y ningún error Esta prueba no es de ninguna manera útil para nuestro proyecto, pero verifica implícitamente que nuestra estructura de archivos de biblioteca está en su lugar (el describir bloque lanzaría un error si el Plato :: Jugador módulo no fue cargado).


Primeras especificaciones

Para funcionar correctamente, Dish requiere los módulos Httparty y la correcta base_uri, es decir, la URL base de la API de Dribbble. Vamos a escribir las pruebas relevantes para estos requisitos en player_spec.rb:

… Describa Dish :: El reproductor describe "los atributos predeterminados" debe incluir los métodos httparty "haga Dish :: Player.must_include HTTParty end it" debe tener la url base establecida en el punto final de la Dribble API "do Dish :: Player.base_uri .must_equal 'http://api.dribbble.com' end end end end

Como puede ver, las expectativas de Minitest se explican por sí mismas, especialmente si usted es un usuario de RSpec: la mayor diferencia es la redacción, donde Minitest prefiere "must / wont" a "should / should_not".

La ejecución de estas pruebas mostrará un error y un error. Para que pasen, agreguemos nuestras primeras líneas de código de implementación a jugador.rb:

 módulo Dish class Player incluye HTTParty base_uri 'http://api.dribbble.com' end end

Corriendo rastrillo De nuevo debería mostrarse las dos especificaciones que pasan. Ahora nuestra Jugador clase tiene acceso a todos los métodos de clase Httparty, como obtener o enviar.


Grabando nuestra primera solicitud

Como estaremos trabajando en el Jugador Clase, necesitaremos tener datos API para un jugador. La página de documentación de Dribbble API muestra que el punto final para obtener datos sobre un jugador específico es http://api.dribbble.com/players/:id

Como en la moda típica de Rails., :carné de identidad es o bien el carné de identidad o la nombre de usuario de un jugador específico. Estaremos usando simplebits, El nombre de usuario de Dan Cederholm, uno de los fundadores de Dribbble..

Para grabar la solicitud con VCR, actualicemos nuestra player_spec.rb archivo añadiendo lo siguiente describir Bloque a la especificación, justo después de la primera:

… Describa "GET profile" do antes de hacer VCR.insert_cassette 'player',: record =>: new_episodes finaliza después de que VCR.eject_cassette end it "registra el archivo" do Dish :: Player.get ('/ players / simplebits') fin extremo fin

despues de correr rastrillo, Puedes verificar que el aparato ha sido creado. A partir de ahora, todas nuestras pruebas serán completamente independientes de la red..

los antes de el bloque se utiliza para ejecutar una parte específica del código antes de cada expectativa: lo usamos para agregar la macro de VCR utilizada para grabar un dispositivo que llamaremos "jugador". Esto creará un jugador.yml archivar bajo spec / fixtures / dish_cassettes. los :grabar La opción está configurada para registrar todas las solicitudes nuevas una vez y reproducirlas en cada solicitud subsiguiente e idéntica. Como prueba de concepto, podemos agregar una especificación cuyo único objetivo es grabar un elemento para el perfil de simplebits. los después La directiva le indica a la VCR que retire el casete después de las pruebas, asegurándose de que todo esté correctamente aislado. los obtener método en el Jugador La clase está disponible, gracias a la inclusión del Httparty módulo.

despues de correr rastrillo, Puedes verificar que el aparato ha sido creado. A partir de ahora, todas nuestras pruebas serán completamente independientes de la red..


Obtener el perfil del jugador

Cada usuario de Dribbble tiene un perfil que contiene una cantidad bastante extensa de datos. Pensemos en cómo nos gustaría que fuera nuestra biblioteca cuando realmente se use: esta es una forma útil de hacer que nuestro DSL funcione. Esto es lo que queremos lograr:

 simplebits = Dish :: Player.new ('simplebits') simplebits.profile => # devuelve un hash con todos los datos de la API simplebits.username => 'simplebits' simplebits.id => 1 simplebits.shots_count => 157

Simple y efectivo: queremos crear una instancia de un Jugador utilizando su nombre de usuario y luego obtener acceso a sus datos llamando a los métodos en la instancia que se asignan a los atributos devueltos por la API. Necesitamos ser consistentes con la API misma.

Afrontemos una cosa a la vez y escribamos algunas pruebas relacionadas con la obtención de los datos del jugador desde la API. Podemos modificar nuestra "GET perfil" bloque para tener:

 describe "GET profile" do let (: player) Dish :: Player.new antes de hacer VCR.insert_cassette 'player',: record =>: new_episodes end after after do VCR.eject_cassette end it "debe tener un método de perfil" do player.must_respond_to: profile end it "debe analizar la respuesta api de JSON a Hash" do player.profile.must_be_instance_of Hash end it "debe realizar la solicitud y obtener los datos" do player.profile ["username"]. must_equal 'simplebits 'end end

los dejar directiva en la parte superior crea una Plato :: Jugador Instancia disponible en las expectativas. A continuación, queremos asegurarnos de que nuestro jugador tenga un método de perfil cuyo valor sea un hash que representa los datos de la API. Como último paso, probamos una clave de muestra (el nombre de usuario) para asegurarnos de que realmente realizamos la solicitud.

Tenga en cuenta que todavía no estamos manejando cómo configurar el nombre de usuario, ya que este es un paso más. La implementación mínima requerida es la siguiente:

... class Player incluye HTTParty base_uri 'http://api.dribbble.com' def profile self.class.get '/ players / simplebits' end end ... 

Una cantidad muy pequeña de código: solo estamos envolviendo una llamada get en el perfil método. Luego, pasamos la ruta codificada para recuperar los datos de simplebits, datos que ya habíamos almacenado gracias a VCR.

Todas nuestras pruebas deben pasar.


Configuración del nombre de usuario

Ahora que tenemos una función de perfil de trabajo, podemos ocuparnos del nombre de usuario. Aquí están las especificaciones relevantes:

 describe los "atributos de instancia predeterminados" do let (: player) Dish :: Player.new ('simplebits') it "debe tener un atributo id" do player.must_respond_to: username end it "debe tener la id correcta" do player .username.must_equal 'simplebits' end end describe "GET profile" do let (: player) Dish :: Player.new ('simplebits') antes de hacer VCR.insert_cassette 'base',: record =>: new_episodes termina después do VCR.eject_cassette end it "debe tener un método de perfil" do player.must_respond_to: profile end it "debe analizar la respuesta de api de JSON a Hash" do player.profile.must_be_instance_of Hash end it "debe obtener el perfil correcto" do player .profile ["username"]. must_equal "simplebits" end end

Hemos agregado un nuevo bloque de descripción para verificar el nombre de usuario que vamos a agregar y simplemente enmendamos el jugador inicialización en el GET perfil Bloque para reflejar el DSL que queremos tener. Ejecutar las especificaciones ahora revelará muchos errores, como nuestro Jugador la clase no acepta argumentos cuando se inicializa (por ahora).

La implementación es muy sencilla:

... class Player attr_accessor: username include HTTParty base_uri 'http://api.dribbble.com' def initialize (username) self.username = username end def profile self.class.get "/players/#self.username" end fin… 

El método de inicialización obtiene un nombre de usuario que se almacena dentro de la clase gracias a attr_accessor método añadido anteriormente. Luego cambiamos el método de perfil para interpolar el atributo de nombre de usuario.

Deberíamos conseguir que todas nuestras pruebas pasen una vez más..


Atributos dinámicos

En un nivel básico, nuestra lib está en muy buena forma. Como el perfil es un Hash, podríamos detenernos aquí y usarlo al pasar la clave del atributo del cual queremos obtener el valor. Nuestro objetivo, sin embargo, es crear un DSL fácil de usar que tenga un método para cada atributo.

Pensemos en lo que necesitamos lograr. Supongamos que tenemos una instancia de jugador e indicamos cómo funcionaría:

 player.username => 'simplebits' player.shots_count => 157 player.foo_attribute => NoMethodError

Vamos a traducir esto a especificaciones y agregarlas a la GET perfil bloquear:

… Describa "atributos dinámicos" antes de que player.profile end it "debe devolver el valor del atributo si está presente en el perfil" do player.id.must_equal 1 end it "debe aumentar el método si el atributo no está presente" do lambda player. foo_attribute .must_raise NoMethodError end end ... 

Ya tenemos una especificación para el nombre de usuario, por lo que no necesitamos agregar otra. Tenga en cuenta algunas cosas:

  • llamamos explícitamente perfil de jugador en un bloque anterior, de lo contrario será nulo cuando intentemos obtener el valor del atributo.
  • para probar eso foo_attribute genera una excepción, necesitamos envolverla en un lambda y verificar que genere el error esperado.
  • nosotros probamos eso carné de identidad es igual a 1, ya que sabemos que ese es el valor esperado (esto es una prueba puramente dependiente de los datos).

En cuanto a la implementación, podríamos definir una serie de métodos para acceder a la perfil hash, sin embargo, esto crearía una gran cantidad de lógica duplicada. Además, confiaría en el resultado de la API para tener siempre las mismas claves..

"Vamos a confiar en método_missing para manejar estos casos y 'generar' todos esos métodos sobre la marcha ".

En cambio, confiaremos en método_missing para manejar estos casos y 'generar' todos esos métodos sobre la marcha. Pero ¿qué significa esto? Sin entrar en demasiada metaprogramación, podemos decir simplemente que cada vez que llamamos a un método que no está presente en el objeto, Ruby genera una NoMethodError mediante el uso método_missing. Al redefinir este mismo método dentro de una clase, podemos modificar su comportamiento.

En nuestro caso, interceptaremos el método_missing llame, verifique que el nombre del método que se ha llamado sea una clave en el hash del perfil y, en caso de resultados positivos, devuelva el valor de hash para esa clave. Si no, llamaremos súper elevar una norma NoMethodError: esto es necesario para asegurarnos de que nuestra biblioteca se comporte exactamente como lo haría cualquier otra biblioteca. En otras palabras, queremos garantizar la menor sorpresa posible..

Agreguemos el siguiente código a la Jugador clase:

 def method_missing (name, * args, & block) if profile.has_key? (name.to_s) profile [name.to_s] else end end end

El código hace exactamente lo que se describe arriba. Si ahora ejecuta las especificaciones, debe hacer que todas pasen. Le incito a agregar algunos más a los archivos de especificaciones para algún otro atributo, como disparos_cuenta.

Esta implementación, sin embargo, no es realmente Ruby idiomática. Funciona, pero se puede convertir en un operador ternario, una forma condensada de condicional if-else. Puede ser reescrito como:

 def method_missing (name, * args, & block) profile.has_key? (name.to_s)? perfil [name.to_s]: super end

No es solo una cuestión de longitud, sino también una cuestión de coherencia y convenciones compartidas entre los desarrolladores. Navegar por el código fuente de las gemas y bibliotecas de Ruby es una buena manera de acostumbrarse a estas convenciones.


Almacenamiento en caché

Como paso final, queremos asegurarnos de que nuestra biblioteca sea eficiente. No debe realizar más solicitudes de las necesarias y, posiblemente, almacenar datos en caché internamente. Una vez más, pensemos en cómo podríamos usarlo:

 player.profile => realiza la solicitud y devuelve un Hash player.profile => devuelve el mismo hash player.profile (true) => fuerza la recarga de la solicitud http y luego devuelve el hash (con cambios de datos si es necesario)

¿Cómo podemos probar esto? Podemos usar WebMock para habilitar y deshabilitar las conexiones de red al punto final de la API. Incluso si estamos utilizando dispositivos de VCR, WebMock puede simular un tiempo de espera de red o una respuesta diferente al servidor. En nuestro caso, podemos probar el almacenamiento en caché obteniendo el perfil una vez y luego deshabilitando la red. Llamando perfil de jugador De nuevo deberíamos ver los mismos datos, mientras que llamando jugador.perfil (verdadero) deberíamos conseguir un Error de tiempo de espera, como la biblioteca intentaría conectarse al punto final de la API (deshabilitado).

Añadamos otro bloque a la player_spec.rb archivo, justo después generación dinámica de atributos:

 describa el "almacenamiento en caché" do # usamos Webmock para deshabilitar la conexión de red después de # recuperar el perfil antes de player.profile stub_request (: any, /api.dribbble.com/).to_timeout finalice "must cache the profile" do player. profile.must_be_instance_of Hash end it "debe actualizar el perfil si está forzado" do lambda player.profile (true) .must_raise Timeout :: Error end end

los solicitud de esbozo El método intercepta todas las llamadas al punto final de la API y simula un tiempo de espera, aumentando el tiempo esperado. Error de tiempo de espera. Como hicimos antes, probamos la presencia de este error en un lambda..

La implementación puede ser complicada, así que la dividiremos en dos pasos. En primer lugar, movamos la solicitud http real a un método privado:

... def perfil get_profile end ... privado def get_profile self.class.get ("/ players / # self.username") end ... 

Esto no hará que nuestras especificaciones pasen, ya que no estamos guardando en caché el resultado de obtener_perfil. Para hacer eso, cambiemos la perfil método:

... def perfil @profile || = get_profile end ... 

Almacenaremos el hash de resultado en una variable de instancia. También tenga en cuenta el || = operador, cuya presencia asegura que obtener_perfil se ejecuta solo si @profile devuelve un valor falsy (como nulo).

A continuación podemos añadir la directiva de recarga forzada:

… Def perfil (fuerza = falso) fuerza? @profile = get_profile: @profile || = get_profile end ... 

Estamos usando un ternario de nuevo: si fuerza es falso, realizamos obtener_perfil y caché, si no, usamos la lógica escrita en la versión anterior de este método (es decir, realizando la solicitud solo si no tenemos un hash).

Nuestras especificaciones deberían ser ecológicas ahora y este es también el final de nuestro tutorial..


Terminando

Nuestro propósito en este tutorial era escribir una biblioteca pequeña y eficiente para interactuar con la API de Dribbble; Hemos sentado las bases para que esto suceda. La mayor parte de la lógica que hemos escrito puede resumirse y reutilizarse para acceder a todos los demás puntos finales. Minitest, WebMock y VCR han demostrado ser herramientas valiosas para ayudarnos a configurar nuestro código.

.