Рано или поздно все разработчики должны взаимодействовать с API. Самая сложная часть всегда связана с надежным тестированием кода, который мы пишем, и, поскольку мы хотим убедиться, что все работает правильно, мы постоянно выполняем код, который запрашивает сам API. Этот процесс медленный и неэффективный, поскольку мы можем столкнуться с проблемами сети и несоответствиями данных (результаты API могут измениться). Давайте рассмотрим, как мы можем избежать всех этих усилий с Ruby.
Наша цель
«Поток важен: напишите тесты, запустите их и увидите, что они терпят неудачу, а затем напишите минимальный код реализации, чтобы сделать их успешными. Как только они все это сделают, выполните рефакторинг, если это необходимо».
Наша цель проста: написать небольшую оболочку вокруг Dribbble API, чтобы получить информацию о пользователе (называемом «игроком» в мире Dribbble).
Поскольку мы будем использовать Ruby, мы также будем придерживаться подхода TDD: если вы не знакомы с этой техникой, у Nettuts + есть хороший учебник по RSpec, который вы можете прочитать. Короче говоря, мы напишем тесты перед написанием нашей реализации кода, чтобы было легче обнаруживать ошибки и достигать высокого качества кода. Поток важен: напишите тесты, запустите их и увидите, что они терпят неудачу, затем напишите минимальный код реализации, чтобы они прошли. Как только они все сделают, рефакторинг, если это необходимо.
API
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 есть профиль, который содержит довольно большой объем данных. Давайте подумаем о том, как бы мы хотели, чтобы наша библиотека использовалась на самом деле: это полезный способ выяснить, как работает наш 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, который мы создаем на основе изменений без какого-либо видимого знака (например, номера версии), мы можем рискнуть, чтобы наши тесты отлично работали с набором данных, который больше не актуален. В этом случае удаление и воссоздание фикстуры — лучший способ убедиться, что наш код по-прежнему работает, как и ожидалось.