Блог Ruby-разработчика

Тестируем внешние сервисы легко!

| Comments

В любом крупном проекте, так или иначе, но приходится сталкиваться с использованием внешних сервисов. Это может быть шлюз для отправки SMS, или сервис для получения курсов валют. В этой статье я опишу как тестировать их легко и приятно.

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

Имитация сервиса на примере конвертера валют

Допустим у нас есть некая система, которая использует внешний сервис Fixer.io для получения курсов валют, и преобразования некой суммы в ту валюту, которую укажет клиент.

converter.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Converter
  def initialize(amount, source = "RUB", target = "USD")
    @amount = amount
    @target = target
    @source = source
  end

  def convert!
    body = get_exchange_rate_from_api
    rate = extract_exchange_rate(body)
    @amount * rate
  end

  private

  def extract_exchange_rate(body)
    JSON.parse(body)["rates"][@target]
  end

  def get_exchange_rate_from_api
    url = URI(api_url)
    Net::HTTP.get(url)
  end

  def api_url
    "http://api.fixer.io/latest?symbols=#{@target}&base=#{@source}"
  end
end

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

Webmock

Webmock это библиотека для создания и использования заглушек HTTP запросов. Это довольно простой и удобный инструмент, и подходит для использования в связке с Rspec, Minitest, Test::Unit

Настройка Webmock проста. На странице проекта есть инструкция по установке. Она сводится к установке gem, и прописыванию библиотеки в spec_helper.rb или в test_helper.rb

test_helper.rb
1
require 'webmock/minitest'
spec_helper.rb
1
require 'webmock/rspec'

Давайте посмотрим как будет выглядеть тест на Rspec, написанный с использованием Webmock

converter_spec.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
require 'rails_helper'

Rspec.describe Converter do

 before do
     stub_request(:get, "http://api.fixer.io/latest?symbols=USD&base=EUR").
          to_return(:body => %Q(
    {
    "base": "EUR",
    "date": "2016-01-29",
    "rates": {
    "USD": 2.0
    }
    }
    ))
 end

 it 'should convert eur to usd' do
    expect(Converter.new(2, "EUR", "USD").convert!).to_eq(4)
 end

end

Все довольно просто. Теперь когда наш код в тесте будет обращаться по url http://api.fixer.io/latest?symbols=USD&base=EUR , вызов сервиса будет заглушаться, и вместо реального запроса мы получим то, что указали в to_return.

Webmock позволяет легко создавать заглушки для сервиса, но есть и подводные камни. Если вдруг ответ реального сервера поменялся, например поменялось API, то мы не сможем отследить это изменение через тест.

VCR

Решить проблему, обозначенную выше помогает - VCR. Его отличие от Webmock в том, что VCR записывает реальный HTTP-ответ от сервиса и использует его потом изолированно в тестах. Запись производится в YAML файл.

Настройка и установка также простая. Установить gem, и добавить конфигурационные строчки в test_helper.rb или spec_helper.rb

spec_helper.rb
1
2
3
4
5
6
7
8
require 'vcr'

VCR.configure do |config|
  # Указываем где будем хранить наши кассеты )
  config.cassette_library_dir = "fixtures/vcr_cassettes"
  # Интегрируемся с webmock 
  config.hook_into :webmock # or :fakeweb
end

Вот пример мини-теста с использованием VCR

converter_test.rb
1
2
3
4
5
6
7
class ConverterTest < Minitest::Test
  def it_converts_eur_to_usd
    VCR.use_cassette("eur_to_usd_conversion") do
      assert_equals 4, Converter.convert!(4.3932, "EUR", "USD")
    end
  end
end

Во время запуска этого теста, будет послан реальный запрос к сервису, а ответ записан в yml-файл c названием, которое было указано как аргумент в VCR.use_cassette.

Вот так примерно будет выглядеть ответ, записанный в файл:

test/fixtures/vcr_cassettes/eur_to_usd_conversion.yml
1
2
3
4
5
6
7
8
9
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
40
41
42
43
44
---
http_interactions:
- request:
    method: get
    uri: http://api.fixer.io/latest?base=EUR&symbols=USD
    body:
      encoding: US-ASCII
      string: ''
    headers:
      Accept-Encoding:
      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
      Accept:
      - "*/*"
      User-Agent:
      - Ruby
      Host:
      - api.fixer.io
  response:
    status:
      code: 200
      message: OK
    headers:
      Server:
      - nginx/1.4.6 (Ubuntu)
      Date:
      - Fri, 29 Jan 2016 23:00:20 GMT
      Content-Type:
      - application/json
      Content-Length:
      - '56'
      Connection:
      - keep-alive
      Status:
      - 200 OK
      Last-Modified:
      - Wed, 28 Jan 2016 00:00:00 GMT
      X-Content-Type-Options:
      - nosniff
    body:
      encoding: UTF-8
      string: '{"base":"EUR","date":"2016-01-29","rates":{"USD":1.099}}'
    http_version:
  recorded_at: Fri, 29 Jan 2016 23:00:20 GMT
recorded_with: VCR 3.0.0

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

Внедрение зависимости (Dependency Injection)

Еще один из способов, состоит во внедрении паттерна проектирования Dependency Injection. Посмотрим на примере нашего конвертера, как можно использовать его.

converter.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
class Converter
  def initialize(amount, source = "EUR", target = "USD", api = FixerAPI)
    @amount = amount
    @target = target
    @source = source
    @api    = api
  end

  def convert!
    rate = @api.get_exhange_rate(source: @source, target: @target)
    @amount * rate
  end
end

Итак, мы внедрили в конструктор класса Converter, FixerAPI класс, который представляет собой обертку для работы с сервисом Fixer. Вот так выглядит наш FixerAPI класс

fixer_api.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class FixerAPI
  def self.get_exchange_rate(source: source, target: target)
    new.get_exhange_rate(source: source, target: target)
  end

  def get_exchange_rate(source: source, target: target)
    url = URI(api_url(source, target))
    body = Net::HTTP.get(url)
    JSON.parse(body)["rates"][target]
  end

  private

  def api_url(source, target)
    "http://api.fixer.io/latest?symbols=#{target}&base=#{source}"
  end
end

Мы вынесли работу с внешним сервисом в отдельный класс, и можем его включать в любом месте где захотим. И в этом состоит суть внедрения зависимости. Так как код стал изолирован, то и тестировать его можно, заменив например FixerAPI каким-нибудь FakeAPI.

converter_test.rb
1
2
3
4
5
6
7
8
9
10
11
class FakeAPI
  def self.get_exchange_rate(source: source, target: target)
    2
  end
end

class ConverterTest < Minitest::Test
  def it_converts_eur_to_usd
    assert_equals 4, Converter.convert!(2, "EUR", "USD", FakeAPI)
  end
end

Написать свой тестовый сервис

Я не буду долго рассматривать этот способ, так как считаю его очень сложным. Но он имеет место быть. Если коротко, то мы можем написать тестовый сервер, который будет работать например на Sinatra. Он будет возвращать нам нужные данные. Их мы и будем использовать в наших тестах.

fixer_server.rb
1
2
3
4
5
6
require 'sinatra'

get '/latest' do
  content_type :json
  { base: "EUR", date: "2015-12-15", rates: { usd: 1.099 } }.to_json
end

Заключение

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

Comments