Статьи

Написание API Wrapper в Ruby с TDD

Рано или поздно все разработчики должны взаимодействовать с API. Самая сложная часть всегда связана с надежным тестированием кода, который мы пишем, и, поскольку мы хотим убедиться, что все работает правильно, мы постоянно выполняем код, который запрашивает сам API. Этот процесс медленный и неэффективный, поскольку мы можем столкнуться с проблемами сети и несоответствиями данных (результаты API могут измениться). Давайте рассмотрим, как мы можем избежать всех этих усилий с Ruby.


«Поток важен: напишите тесты, запустите их и увидите, что они терпят неудачу, а затем напишите минимальный код реализации, чтобы сделать их успешными. Как только они все это сделают, выполните рефакторинг, если это необходимо».

Наша цель проста: написать небольшую оболочку вокруг Dribbble API, чтобы получить информацию о пользователе (называемом «игроком» в мире Dribbble).
Поскольку мы будем использовать Ruby, мы также будем придерживаться подхода TDD: если вы не знакомы с этой техникой, у Nettuts + есть хороший учебник по RSpec, который вы можете прочитать. Короче говоря, мы напишем тесты перед написанием нашей реализации кода, чтобы было легче обнаруживать ошибки и достигать высокого качества кода. Поток важен: напишите тесты, запустите их и увидите, что они терпят неудачу, затем напишите минимальный код реализации, чтобы они прошли. Как только они все сделают, рефакторинг, если это необходимо.

API Dribbble довольно прост. На данный момент он поддерживает только запросы GET и не требует аутентификации: идеальный кандидат для нашего урока. Более того, он предлагает ограничение в 60 вызовов в минуту, ограничение, которое прекрасно показывает, почему для работы с API требуется разумный подход.


В этом руководстве необходимо предположить, что вы знакомы с концепциями тестирования: приспособления, макеты, ожидания. Тестирование — важная тема (особенно в сообществе Ruby), и даже если вы не являетесь Rubyist, я бы посоветовал вам глубже вникнуть в суть вопроса и найти эквивалентные инструменты для вашего повседневного языка. Возможно, вы захотите прочитать «Книгу RSpec» Дэвида Челимского и соавт. Отличный учебник по Behavior Driven Development.

Подводя итог, вот три ключевых понятия, которые вы должны знать:

  • Mock : также называется double, mock — это «объект, который заменяет другой объект в примере». Это означает, что, если мы хотим проверить взаимодействие между объектом и другим, мы можем издеваться над вторым. В этом уроке мы будем издеваться над API Dribbble, так как для тестирования нашего кода нам не нужен сам API, а что-то, что ведет себя как он и предоставляет тот же интерфейс.
  • Fixture : набор данных, воссоздающий определенное состояние в системе. Приспособление может быть использовано для создания необходимых данных для проверки части логики.
  • Ожидание : тестовый пример, написанный с точки зрения результата, которого мы хотим достичь.

«Как правило, запускайте тесты каждый раз, когда обновляете их».

WebMock — это библиотека Ruby Mocking, которая используется для макетирования (или заглушки) http-запросов. Другими словами, это позволяет вам симулировать любой HTTP-запрос, фактически не делая его. Основным преимуществом этого является возможность разрабатывать и тестировать против любой службы HTTP без необходимости самой службы и без возникновения связанных с этим проблем (таких как ограничения API, ограничения IP и т. Д.).
Видеомагнитофон — это дополнительный инструмент, который записывает любой реальный http-запрос и создает прибор, файл, который содержит все необходимые данные для репликации этого запроса без повторного выполнения. Мы настроим его для использования WebMock. Другими словами, наши тесты будут взаимодействовать с настоящим API Dribbble только один раз: после этого WebMock заблокирует все запросы благодаря данным, записанным видеомагнитофоном. У нас будет отличная копия ответов Dribbble API, записанных локально. Кроме того, WebMock позволит нам легко и согласованно тестировать крайние случаи (например, время ожидания запроса). Замечательным следствием нашей настройки является то, что все будет очень быстро.

Что касается модульного тестирования, мы будем использовать Minitest . Это быстрая и простая библиотека модульного тестирования, которая также поддерживает ожидания в стиле RSpec. Он предлагает меньший набор функций, но я считаю, что на самом деле это поощряет и подталкивает вас разделить вашу логику на маленькие, тестируемые методы. Minitest является частью Ruby 1.9, поэтому, если вы используете его (я надеюсь), вам не нужно его устанавливать. На Ruby 1.8 это всего лишь вопрос gem install minitest .

Я буду использовать Ruby 1.9.3: если вы этого не сделаете, вы, вероятно, столкнетесь с некоторыми проблемами, связанными с require_relative , но я добавил резервный код в комментарий прямо под ним. Как правило, вы должны запускать тесты каждый раз, когда обновляете их, даже если я не буду подробно упоминать этот шаг в учебном пособии.


Настроить

Мы будем использовать обычную структуру папок /lib и /spec для организации нашего кода. Что касается названия нашей библиотеки, мы назовем его « Блюдо» в соответствии с соглашением Дрибблла об использовании терминов, связанных с баскетболом.

Gemfile будет содержать все наши зависимости, хотя они довольно маленькие.

01
02
03
04
05
06
07
08
09
10
source :rubygems
 
gem ‘httparty’
 
group :test do
  gem ‘webmock’
  gem ‘vcr’
  gem ‘turn’
  gem ‘rake’
end

Httparty — это простой в использовании гем для обработки HTTP-запросов; это будет ядро ​​нашей библиотеки. В группе тестов мы также добавим Turn, чтобы изменить результаты наших тестов, чтобы они были более описательными и поддерживали цвет.

Папки /lib и /spec имеют симметричную структуру: для каждого файла, содержащегося в папке /lib/dish , должен быть файл внутри /spec/dish с тем же именем и суффиксом _spec.

Давайте начнем с создания файла /lib/dish.rb и добавим следующий код:

1
2
3
4
require «httparty»
Dir[File.dirname(__FILE__) + ‘/dish/*.rb’].each do |file|
  require file
end

Он ничего не делает: он требует httparty, а затем перебирает каждый файл .rb внутри /lib/dish чтобы это потребовать. С этим файлом мы сможем добавить любую функциональность в отдельные файлы в /lib/dish и автоматически загрузить его, просто потребовав этот единственный файл.

Давайте перейдем в папку /spec . Вот содержимое файла spec_helper.rb .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#we need the actual library file
require_relative ‘../lib/dish’
# For 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

Здесь стоит отметить несколько вещей, поэтому давайте разберем их по частям:

  • Сначала нам требуется основной файл lib для нашего приложения, чтобы код, который мы хотим протестировать, был доступен для набора тестов. Оператор require_relative является дополнением для Ruby 1.9.3.
  • Затем нам требуются все зависимости библиотек: minitest/autorun включает в себя все ожидания, которые мы будем использовать, webmock/minitest добавляет необходимые привязки между двумя библиотеками, тогда как vcr и turn довольно webmock/minitest .
  • Блок конфигурации Turn просто нуждается в настройке нашего тестового вывода. Мы будем использовать формат структуры, где мы можем увидеть описание наших спецификаций.
  • Блоки конфигурации VCR говорят VCR хранить запросы в папке фикстур (обратите внимание на относительный путь) и использовать WebMock в качестве библиотеки-заглушки (VCR поддерживает некоторые другие).

Наконец, что не менее Rakefile , Rakefile который содержит некоторый код поддержки:

1
2
3
4
5
6
7
8
require ‘rake/testtask’
 
Rake::TestTask.new do |t|
  t.test_files = FileList[‘spec/lib/dish/*_spec.rb’]
  t.verbose = true
end
 
task :default => :test

Библиотека rake/testtask включает класс TestTask который полезен для определения местоположения наших тестовых файлов. Теперь для запуска наших спецификаций мы будем набирать rake только из корневого каталога библиотеки.

Чтобы проверить нашу конфигурацию, давайте добавим следующий код в /lib/dish/player.rb :

1
2
3
4
module Dish
  class Player
  end
end

Затем /spec/lib/dish/player_spec.rb :

01
02
03
04
05
06
07
08
09
10
11
require_relative ‘../../spec_helper’
# For 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

Выполнение rake должно дать вам один проход теста и без ошибок. Этот тест ни в коем случае не полезен для нашего проекта, но он неявно проверяет, что структура файла нашей библиотеки на месте (блок describe выдает ошибку, если модуль Dish::Player не был загружен).


Для правильной работы Dish требуются модули Httparty и правильный base_uri , то есть базовый URL-адрес Dribbble API. Давайте напишем соответствующие тесты для этих требований в player_spec.rb :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
describe Dish::Player do
 
  describe «default attributes» do
 
    it «must include httparty methods» do
      Dish::Player.must_include HTTParty
    end
 
    it «must have the base url set to the Dribble API endpoint» do
      Dish::Player.base_uri.must_equal ‘http://api.dribbble.com’
    end
 
  end
 
end

Как вы можете видеть, ожидания Minitest говорят сами за себя, особенно если вы являетесь пользователем RSpec: самое большое различие заключается в формулировке, где Minitest предпочитает «must / wont» вместо «should / should_not».

Запуск этих тестов покажет одну ошибку и один сбой. Чтобы они прошли, давайте добавим наши первые строки кода реализации в player.rb :

01
02
03
04
05
06
07
08
09
10
11
module Dish
 
  class Player
 
    include HTTParty
 
    base_uri ‘http://api.dribbble.com’
 
  end
 
end

Повторный rake должен показать прохождение двух спецификаций. Теперь наш класс Player имеет доступ ко всем методам класса Httparty, таким как get или post .


Поскольку мы будем работать с классом Player , нам понадобятся данные API для игрока. На странице документации API Dribbble показано, что конечной точкой для получения данных о конкретном игроке является http://api.dribbble.com/players/:id

Как и в обычном стиле Rails,: :id — это либо id, либо имя пользователя конкретного игрока. Мы будем использовать simplebits , имя пользователя Dan Cederholm, одного из основателей Dribbble.

Чтобы записать запрос с помощью видеомагнитофона, давайте обновим наш файл player_spec.rb , добавив следующий блок describe в спецификацию сразу после первого:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
  …
 
  describe «GET profile» do
 
  before do
    VCR.insert_cassette ‘player’, :record => :new_episodes
  end
 
  after do
    VCR.eject_cassette
  end
 
  it «records the fixture» do
    Dish::Player.get(‘/players/simplebits’)
  end
 
  end
 
end

После запуска rake , вы можете убедиться, что прибор был создан. Отныне все наши тесты будут полностью независимы от сети.

Блок before используется для выполнения определенной части кода перед каждым ожиданием: мы используем его для добавления макроса VCR, используемого для записи прибора, который мы назовем «player». Это создаст файл player.yml папке spec/fixtures/dish_cassettes . Опция :record установлена, чтобы записывать все новые запросы один раз и воспроизводить их при каждом последующем идентичном запросе. В качестве доказательства концепции мы можем добавить спецификацию, единственная цель которой — записать фикстуру для профиля simplebits. Директива after говорит VCR об удалении кассеты после испытаний, следя за тем, чтобы все было должным образом изолировано. Метод get класса Player стал доступен благодаря включению модуля Httparty .

После запуска rake , вы можете убедиться, что прибор был создан. Отныне все наши тесты будут полностью независимы от сети.


Dribbble

У каждого пользователя Dribbble есть профиль, который содержит довольно большой объем данных. Давайте подумаем о том, как бы мы хотели, чтобы наша библиотека использовалась на самом деле: это полезный способ выяснить, как работает наш DSL. Вот чего мы хотим достичь:

1
2
3
4
5
6
7
8
9
simplebits = Dish::Player.new(‘simplebits’)
simplebits.profile
  => #returns a hash with all the data from the API
simplebits.username
  => ‘simplebits’
simplebits.id
  => 1
simplebits.shots_count
  => 157

Просто и эффективно: мы хотим создать экземпляр Player, используя его имя пользователя, а затем получить доступ к его данным, вызвав методы в экземпляре, которые сопоставляются с атрибутами, возвращаемыми API. Мы должны соответствовать самому API.

Давайте рассмотрим одну вещь за раз и напишем несколько тестов, связанных с получением данных игрока из API. Мы можем изменить наш блок "GET profile" чтобы иметь:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
describe «GET profile» do
 
  let(:player) { Dish::Player.new }
 
  before do
    VCR.insert_cassette ‘player’, :record => :new_episodes
  end
 
  after do
    VCR.eject_cassette
  end
 
  it «must have a profile method» do
    player.must_respond_to :profile
  end
 
  it «must parse the api response from JSON to Hash» do
    player.profile.must_be_instance_of Hash
  end
 
  it «must perform the request and get the data» do
    player.profile[«username»].must_equal ‘simplebits’
  end
 
end

Директива let сверху создает экземпляр Dish::Player доступный в ожиданиях. Далее мы хотим убедиться, что у нашего плеера есть метод профиля, значением которого является хеш, представляющий данные из API. В качестве последнего шага мы тестируем образец ключа (имя пользователя), чтобы убедиться, что мы действительно выполняем запрос.

Обратите внимание, что мы еще не определили, как установить имя пользователя, так как это еще один шаг. Требуемая минимальная реализация:

01
02
03
04
05
06
07
08
09
10
11
12
13
class Player
 
  include HTTParty
 
  base_uri ‘http://api.dribbble.com’
 
  def profile
    self.class.get ‘/players/simplebits’
  end
 
end

Очень мало кода: мы просто заключаем вызов get в метод profile . Затем мы проходим жестко закодированный путь для извлечения данных простых битов, данных, которые мы уже сохранили благодаря видеомагнитофону.

Все наши тесты должны быть пройдены.


Теперь, когда у нас есть функция рабочего профиля, мы можем позаботиться о имени пользователя. Вот соответствующие спецификации:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
describe «default instance attributes» do
 
  let(:player) { Dish::Player.new(‘simplebits’) }
 
  it «must have an id attribute» do
    player.must_respond_to :username
  end
 
  it «must have the right id» do
    player.username.must_equal ‘simplebits’
  end
 
end
 
describe «GET profile» do
 
  let(:player) { Dish::Player.new(‘simplebits’) }
 
  before do
    VCR.insert_cassette ‘base’, :record => :new_episodes
  end
 
  after do
    VCR.eject_cassette
  end
 
  it «must have a profile method» do
    player.must_respond_to :profile
  end
 
  it «must parse the api response from JSON to Hash» do
    player.profile.must_be_instance_of Hash
  end
 
  it «must get the right profile» do
    player.profile[«username»].must_equal «simplebits»
  end
 
end

Мы добавили новый блок описания, чтобы проверить имя пользователя, которое мы собираемся добавить, и просто изменили инициализацию player в блоке GET profile чтобы отразить DSL, который мы хотим иметь. Запуск спецификаций теперь покажет много ошибок, так как наш класс Player не принимает аргументы при инициализации (пока).

Реализация очень проста:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
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
 
end

Метод initialize получает имя пользователя, которое хранится внутри класса благодаря добавленному выше методу attr_accessor . Затем мы изменим метод профиля, чтобы интерполировать атрибут имени пользователя.

Мы должны пройти все наши тесты еще раз.


На базовом уровне наша библиотека находится в довольно хорошей форме. Поскольку профиль является хешем, мы могли бы остановиться здесь и уже использовать его, передав ключ атрибута, для которого мы хотим получить значение. Однако наша цель — создать простой в использовании DSL, в котором есть метод для каждого атрибута.

Давайте подумаем о том, что нам нужно достичь. Давайте предположим, что у нас есть экземпляр игрока и заглушка, как он будет работать:

1
2
3
4
5
6
player.username
  => ‘simplebits’
player.shots_count
  => 157
player.foo_attribute
  => NoMethodError

Давайте переведем это в спецификации и добавим их в блок GET profile :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
describe «dynamic attributes» do
 
  before do
    player.profile
  end
 
  it «must return the attribute value if present in profile» do
    player.id.must_equal 1
  end
 
  it «must raise method missing if attribute is not present» do
    lambda { player.foo_attribute }.must_raise NoMethodError
  end
 
end

У нас уже есть спецификация для имени пользователя, поэтому нам не нужно добавлять еще одну. Обратите внимание на несколько вещей:

  • мы явно вызываем player.profile в блоке before, иначе это будет nil, когда мы попытаемся получить значение атрибута.
  • чтобы проверить, что foo_attribute вызывает исключение, нам нужно обернуть его в лямбду и проверить, что оно вызывает ожидаемую ошибку.
  • мы проверяем, что id равен 1 , так как мы знаем, что это ожидаемое значение (это чисто зависимый от данных тест).

С точки зрения реализации, мы могли бы определить серию методов для доступа к хэшу profile , но это создаст много дублированной логики. Кроме того, результат API будет всегда иметь одинаковые ключи.

«Мы будем полагаться на method_missing для обработки этих случаев и« генерирования »всех этих методов на лету».

Вместо этого мы будем полагаться на method_missing для обработки этих случаев и «генерирования» всех этих методов на лету. Но что это значит? Не вдаваясь в слишком много метапрограммирования, мы можем просто сказать, что каждый раз, когда мы вызываем метод, отсутствующий в объекте, Ruby вызывает NoMethodError с помощью method_missing . Переопределив этот метод внутри класса, мы можем изменить его поведение.

В нашем случае мы перехватим вызов method_missing , убедимся, что имя вызванного метода является ключом в хеше профиля, и в случае положительного результата вернем значение хеша для этого ключа. Если нет, мы вызовем super чтобы вызвать стандартную NoMethodError : это необходимо, чтобы убедиться, что наша библиотека ведет себя точно так же, как и любая другая библиотека. Другими словами, мы хотим гарантировать как можно меньше сюрпризов.

Давайте добавим следующий код в класс Player :

1
2
3
4
5
6
7
def method_missing(name, *args, &block)
  if profile.has_key?(name.to_s)
    profile[name.to_s]
  else
    super
  end
end

Код делает именно то, что описано выше. Если вы сейчас запускаете спецификации, вы должны их пройти. Я бы рекомендовал вам добавить еще несколько файлов спецификаций для какого-либо другого атрибута, например shots_count .

Эта реализация, однако, не совсем идиоматическая Ruby. Это работает, но его можно упростить до троичного оператора, сжатой формы условного оператора if-else. Это может быть переписано как:

1
2
3
def method_missing(name, *args, &block)
  profile.has_key?(name.to_s) ?
end

Это не просто вопрос длины, но также вопрос согласованности и общих соглашений между разработчиками. Просмотр исходного кода драгоценных камней и библиотек Ruby — хороший способ привыкнуть к этим соглашениям.


В качестве последнего шага мы хотим убедиться, что наша библиотека эффективна. Он не должен делать больше запросов, чем необходимо, и, возможно, кэшировать данные внутри. Еще раз, давайте подумаем о том, как мы могли бы это использовать:

1
2
3
4
5
6
player.profile
  => performs the request and returns a Hash
player.profile
  => returns the same hash
player.profile(true)
  => forces the reload of the http request and then returns the hash (with data changes if necessary)

Как мы можем проверить это? Мы можем использовать WebMock для включения и отключения сетевых подключений к конечной точке API. Даже если мы используем видеомагнитофоны, WebMock может имитировать время ожидания сети или другой ответ на сервер. В нашем случае мы можем проверить кэширование, получив профиль один раз, а затем отключив сеть. При повторном вызове player.profile мы должны увидеть те же данные, а при вызове player.profile(true) мы должны получить Timeout::Error , поскольку библиотека будет пытаться подключиться к (отключенной) конечной точке API.

Давайте добавим еще один блок в файл player_spec.rb сразу после dynamic attribute generation :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
describe «caching» do
 
  # we use Webmock to disable the network connection after
  # fetching the profile
  before do
    player.profile
    stub_request(:any, /api.dribbble.com/).to_timeout
  end
 
  it «must cache the profile» do
    player.profile.must_be_instance_of Hash
  end
 
  it «must refresh the profile if forced» do
    lambda { player.profile(true) }.must_raise Timeout::Error
  end
 
end

Метод stub_request перехватывает все вызовы конечной точки API и имитирует тайм-аут, увеличивая ожидаемый Timeout::Error . Как и раньше, мы проверяем наличие этой ошибки в лямбде.

Реализация может быть сложной, поэтому мы разделим ее на два этапа. Во-первых, давайте переместим фактический http-запрос в приватный метод:

01
02
03
04
05
06
07
08
09
10
11
12
13
def profile
  get_profile
end
 
 
private
 
def get_profile
  self.class.get(«/players/#{self.username}»)
end

Это не приведет к прохождению наших спецификаций, так как мы не get_profile результат get_profile . Для этого изменим метод profile :

1
2
3
4
5
def profile
  @profile ||= get_profile
end

Мы будем хранить результирующий хеш в переменной экземпляра. Также обратите внимание на оператор ||= , присутствие которого гарантирует, что get_profile запускается, только если @profile возвращает ложное значение (например, nil ).

Далее мы можем добавить директиву принудительной перезагрузки:

1
2
3
4
5
def profile(force = false)
  force ?
end

Мы снова используем get_profile : если force имеет значение false, мы выполняем get_profile и get_profile его, если нет, мы используем логику, написанную в предыдущей версии этого метода (т.е. выполняем запрос, только если у нас еще нет хэша). ).

Наши спецификации должны быть зелеными, и это также конец нашего урока.


Нашей целью в этом уроке было написать небольшую и эффективную библиотеку для взаимодействия с Dribbble API; мы заложили основу для этого. Большая часть написанной нами логики может быть абстрагирована и использована для доступа ко всем остальным конечным точкам. Minitest, WebMock и VCR оказались ценными инструментами, помогающими нам формировать наш код.

Нам, однако, нужно помнить о небольшом предостережении: видеомагнитофон может стать обоюдоострым мечом, поскольку наши тесты могут стать слишком зависимыми от данных. Если по какой-либо причине API, который мы создаем на основе изменений без какого-либо видимого знака (например, номера версии), мы можем рискнуть, чтобы наши тесты отлично работали с набором данных, который больше не актуален. В этом случае удаление и воссоздание фикстуры — лучший способ убедиться, что наш код по-прежнему работает, как и ожидалось.