Пишем API gem: как написать тесты для внешнего API

Illustration of a person with a remote control and goggles operating a drone above, with a workbench, tools, and a barrier with warning stripes in the foreground. Illustration of a person with a remote control and goggles operating a drone above, with a workbench, tools, and a barrier with warning stripes in the foreground.

В прошлой статье мы разобрались как совладать с Hypermedia API и зачем оно вообще нужно. С момента написания той статьи я успел добавить все API точки в код гема, а так же внёс некоторые мелкие фиксы. Вот ссылка на все изменения с того момента: 8056904...c9088e6.

В этой статье мы исправим главный недостаток гема GrooveHQ: отсутствие тестов. Тестирование кода, зависящего от внешнего API – неприятная, но часто встречающаяся задача. Я постараюсь упростить её как можно сильней.

Если тебя мучает вопрос “почему не TDD? А как же red green refactor?”, то прочитай статью про написание первых тестов платформы mkdev, там я объясняю причины моей не любви к этому подходу.

Настройка тестового окружения

В качестве библиотеки для тестов я снова выбираю Rspec – особых преимуществ кроме привычности и бо`льшей популярности я не вижу, но и их достаточно чтобы сделать выбор. Обновим Gemfile:

# ./Gemfile
# ...
group :test do
  gem 'rspec'
end

Внимательный читатель спросит: а почему я добавил гем в Gemfile, а не в gemspec? Отвечаю: просмотрев исходники популярных гемов и пару статей я пришёл к выводу, что гемы для тестов не являются настоящей зависимостью гема, а значит их не стоит добавлять в gemspec.

Дальше нужно добавить папку spec и файл spec/spec_helper.rb:

# ./spec/spec_helper.rb
require_relative '../lib/groovehq # Подгружаем гем

RSpec.configure do |config|
end

Можно переходить к тестам.

Выявление недоработок в коде тестами

Первый же написанный мой тест выловил небольшой косяк в коде:

# ./spec/groovehq/resource_spec.rb
require 'spec_helper'

describe GrooveHQ::Resource do

  let(:client) { GrooveHQ::Client.new("phantogram") }

  it "returns empty data for invalid input" do
    resource = GrooveHQ::Resource.new(client, "")
    expect(resource.data).to eql({})
  end

end

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

# ./lib/groovehq/resource.rb
module GrooveHQ

  class Resource
    attr_reader :rels, :data

    def initialize(client, data)
      data = {} unless data.is_a?(Hash)

      @client = client
      @data = data.with_indifferent_access

      links = @data.delete(:links)
      links[:self] = @data.delete(:href) if @data.has_key?(:href)

      @rels = parse_links(links).with_indifferent_access
    end
# ...

Воодушевившись первой победой, добавляю ещё несколько тестов для GrooveHQ::Resource:

# ./spec/groovehq/resource_spec.rb
# ...
  it "parses relations correctly" do
    data = {
      links: {
        assignee: {
          href: "https://api.groovehq.com/v1/agents/matt@groovehq.com"
        }
      }
    }
    resource = GrooveHQ::Resource.new(client, data)
    expect(resource.rels[:assignee]).to be_instance_of(GrooveHQ::Relation)
  end

  it "parses self relation correctly" do
    data = {
      href: "https://api.groovehq.com/v1/agents/matt@groovehq.com"
    }
    resource = GrooveHQ::Resource.new(client, data)
    expect(resource.rels[:self]).to be_instance_of(GrooveHQ::Relation)
  end

  it "parses data correctly" do
    data = {
      name: "When I am small"
    }
    resource = GrooveHQ::Resource.new(client, data)
    expect(resource.data[:name]).to eql "When I am small"
  end
# ...

Заодно нашёл ещё один баг, смотри полный коммит со всеми тестами на данный момент: f1b5da4. Немного подумав, я решил разбить тесты этого класса на контексты, где каждый контекст посвящён отдельному методу: ed0cd7a.

Лирическое отступление

Прежде чем я продолжу писать тесты, хочется просто поговорить за жизнь. Во-первых, я решил не покрывать тестами все методы, отвечающие на конкретные API-точки. Например, вот такой метод:

      def customer(email)
        get("/customers/#{email}")
      end

Единственный тест, который я могу здесь придумать – это сделать stub результата HTTP-запроса и сравнить, что вызов этого метода возвращает результат этого stub’а. Получается какой-то странный тест. А таких методов сейчас 23. Засорять папку с тестами такими вот бестолковыми проверками, которые потеряют актуальность после малейшего обновления API смысла нет.

Таким образом, мне остаётся только написать тесты на метод GrooveHQ::Client::Connection#parse_data и можно считать 80% важного кода покрытым тестами.

Тестируем запросы к API

Сразу же возникла одна концептуальная проблема: метод #parse_data помечен как private, а значит я не могу вызывать его напрямую, только при помощи send. Да и в целом, одна из особенностей приватных методов в том, что их не нужно тестировать напрямую.

К сожалению, это означает что нам не обойтись без стабов (stubs), так как все публичные методы, которые я могу вызвать выполняют запрос к API. Значит, нужно “стабать” эти запросы и вместо проведения запроса просто возвращать готовый JSON ответ. Для этой задачи я буду использовать гем WebMock, который подключил в коммите 2bf8b92.

Здесь вместо разбивки на контексты по методам, я сделал контекст для каждого типа ответов от API. Вот, например, тест для самого простого варианта – один корневой ключ со всеми атрибутами ресурса внутри:

# ./spec/groovehq/client/connection_spec.rb

# ...
  context "nested hash as response with single root key" do

    let(:response) do
      {
        ticket: {
          number: 1,
          summary: "This is the first 100 characters of the first message...",
          links: {
            assignee: {
              href: "https://api.groovehq.com/v1/agents/matt@groovehq.com"
            }
          }
        }
      }.to_json
    end

    before(:each) do
      stub_request(:get, "https://api.groovehq.com/v1/tickets/1").to_return(body: response)
    end

    it "returns resource for single root key" do
      expect(client.get("/tickets/1")).to be_instance_of(GrooveHQ::Resource)
    end

    it "returns resource with correct relations" do
      expect(client.get("/tickets/1").rels[:assignee].href).to eql("https://api.groovehq.com/v1/agents/matt@groovehq.com")
    end

  end
# ...

Строчкой stub_request(:get, "https://api.groovehq.com/v1/tickets/1").to_return(body: response) я говорю: “вместо проведения настоящего запроса по этому адресу просто возвращай мне сразу response”. Тест получился достаточно объёмным, поэтому за всеми спеками нужно идти и смотреть коммит 69e56b4.

TravisCI

На этом, поидее, можно было бы и завершить написание тестов. Но я посчитал хорошей идеей добавить к проекту Travis CI – самое популярное CI решение для OpenSource проектов. Добавляется он просто – созданием файла .travis.yml и следованием документации.

В результате после каждого коммита прогоняются тесты и все, кто пользуется гемом всегда могут проверить работоспособен ли он. GrooveHQ гем, например, работоспособен.

Что дальше?

На данный момент гем feature complete – можно подключать его к своему приложению и использовать для удобного обращения к любым API точкам GrooveHQ. Все нуждающиеся в этом участки кода покрыты тестами, и у гема даже есть красивый значок от Трэвиса, рапортующий о том, что эти тесты проходят. Впринципе, на этом можно и остановиться. Но вместо этого тебя ждут ещё одна статья, а меня – ещё немного работы по улучшению гема.

Во-первых, в следующей статье мы рассмотрим метапрограммирование в Ruby. Мне хочется использовать что-то подобное: client.ticket(2).state вместо client.ticket(2).rels[:state].get.data.

Во-вторых, нужно куда-то этот гем опубликовать, а ещё написать к нему документацию.

Так что впереди по-прежнему много интересного. Если на твой взгляд в геме не хватает чего-то ещё – пиши в комментариях и, возможно, появится необходимость в ещё нескольких статьях ;-)