Статьи

Модульное тестирование в Ruby

В своей статье о модульном тестировании Мартин Фаулер упоминает, что то, чем на самом деле является единица, может меняться в зависимости от того, из какой школы вы пришли. Многие из объектно-ориентированного мира рассматривают Класс как единицу, тогда как те, кто в мире функционального программирования, рассматривают Функцию как единицу.

Хотя Ruby является объектно-ориентированным языком, я склонен рассматривать метод / функцию как единое целое. Это то, что должно быть проверено, фокусируясь на тех методах, которые составляют открытый интерфейс определенного класса. Частные методы можно тестировать через их общедоступные интерфейсы, что позволяет изменять их фактическую реализацию и при необходимости изменять их структуру.

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

Тестирование важно … и мнение

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

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

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

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

Что должно быть проверено?

Учитывая, что мы собираемся тестировать методы, что мы на самом деле тестируем? Что мы пытаемся доказать, проверяя их? Вопрос в том, какова цель метода. Это то, что должно быть проверено. Чего это намеревается достичь?

Я предполагаю, что есть другие, но я собираюсь сгруппировать цель метода в три различных категории:

  1. Возвращает значение.
  2. Он передает работу куда-то еще ( т. Е. Отправляет работу в другое место).
  3. Это вызывает побочный эффект.

Что не следует проверять?

При модульном тестировании важно избегать тестирования всей системы (что также является интеграционным тестированием — также важно). Также особенно важно избегать внешних зависимостей, среди худших из которых — внешний вызов API.

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

Избегая внешних зависимостей, вы также помогаете ускорить ваши тесты. Чтение из файла или обращение к базе данных происходит медленно, а общение с внешним HTTP API — еще медленнее. Кроме того, они, скорее всего, не хотят, чтобы вы использовали их API каждый раз, когда вы (или ваш CI-сервер) запускаете тестовый набор.

Примеры юнит-тестирования

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

Будет Giftcardкласс, который в приложении Rails будет ActiveRecordмоделью. Будет другой класс Giftcards::Repository, который будет выполнять различные действия с подарочной картой, например создавать новую, проверять баланс, регулировать баланс и отменять подарочную карту. Наконец, у нас есть несколько различных адаптеров, чья работа заключается в том, чтобы общаться с эмитентом каждой подарочной карты.

Мы будем работать с Cardexадаптером и Testадаптером (поддельный адаптер для использования в тестах).

class Giftcard
  attr_accessor :card_number, :pin, :issuer, :cancelled_at

  def self.create!(options)
    self.new.tap do |giftcard|
      options.each do |key, value|
        giftcard.send("#{key}=", value) if giftcard.respond_to?("#{key}=")
      end
    end
  end

  def masked_card_number
    "#{'X' * (card_number.length - 4)}#{last_four_digits}"
  end

  def cancel!
    self.cancelled_at = Time.now
    save!
  end

  def save!
    true
  end

  private

  def last_four_digits
    card_number[-4..-1]
  end
end

Ниже у нас есть наш Giftcards::Repositoryкласс, мост между Giftcardклассом и различными классами адаптера.

module Giftcards
  class Repository
    attr_reader :adapter

    def initialize(adapter)
      @adapter = adapter
    end

    def create(amount_cents, currency)
      details = adapter.create(amount_cents, currency)
      Giftcard.create!({
        card_number: details[:card_number],
        pin: details[:pin],
        currency: currency,
        issuer: adapter::NAME
      })
    end

    def balance(giftcard)
      adapter.balance(giftcard.card_number)
    end

    def adjust(giftcard, amount_cents)
      adapter.adjust(giftcard.card_number, amount_cents)
    end

    def cancel(giftcard)
      amount_cents = balance(giftcard)
      adjust(giftcard, -amount_cents)
      giftcard.cancel!
      giftcard
    end
  end
end

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

Это то место, где будут проходить все неприятные вызовы SOAP (если вы работали с реальными эмитентами подарочных карт, вы поймете, о чем я говорю) или, если вам повезет, JSON API (весьма сомнительно).

module Giftcards
  module Adapters
    class BaseAdapter
      # To be implemented
    end

    class Cardex < BaseAdapter
      # To be implemented
    end

    class Test < BaseAdapter

      NAME = 'test'.freeze

      def create(amount_cents, currency)
        pool = (0..9).to_a

        {
          card_number: (0..11).map{ |n| pool.sample }.join,
          pin: (0..4).map{ |n| pool.sample }.join,
          currency: currency
        }
      end

      def balance(card_number)
        100
      end

      def adjust(card_number, amount_cents)
        balance(card_number) + amount_cents
      end

      def cancel(giftcard)
        true
      end
    end
  end
end

Выделение зависимостей

Изоляция этих зависимостей может быть достигнута либо с помощью внедрения зависимостей, посредством чего вы вводите зависимости заглушки (обертка API, которая специально предназначена для тестирования и немедленно возвращает вам стандартный ответ), либо с помощью некоторой формы двойного теста или метода mocking / stubbing.

Использование парных разрядов

Двойники — это своего рода «поддельная» версия зависимых объектов. В большинстве случаев они являются неполными представлениями, которые раскрывают только методы, необходимые для выполнения теста. Rspec поставляется с отличной библиотекой rspec-mock, которая включает в себя всевозможные способы создания этих двойников.

Одним из примеров, который я буду использовать ниже, является создание поддельного экземпляра Giftcardкласса, который имеет только card_numberметод (и значение, которое он возвращает).

instance_double('Giftcard', card_number: 12345)

Ложные HTTP-запросы

Существует несколько способов насмешливых HTTP-запросов, и мы не будем углубляться в них. В статье, которую я написал о микросервисах в Rails , есть довольно хороший пример, когда мы высмеиваем HTTP-запрос с использованием Faradayгема.

Другие способы макетирования HTTP-запросов могут включать использование гема webmock . Или вы можете использовать видеомагнитофон , который делает реальный запрос один раз, записывает ответ, а затем использует записанный ответ с этого момента.

Методы окурков

Из-за того, как написан Ruby, мы на самом деле можем заменить метод объекта поддельной версией. Это делается в rspec с помощью следующего кода:

allow(adapter).to receive(:adjust) { 50 }

Теперь, когда adapter.adjustвызывается, он будет возвращать значение, 50несмотря ни на что, вместо выполнения дорогого вызова SOAP для реального сервиса. Полный тест выглядит следующим образом:

describe Giftcards::Repository do
  describe '#adjust' do
    let(:adapter)    { Giftcards::Adapters::Cardex.new }
    let(:repository) { Giftcards::Repository.new(adapter) }
    let(:giftcard)   do
      Giftcard.new do |giftcard|
        giftcard.card_number = '12345'
      end
    end

    it 'returns new balance' do
      allow(adapter).to receive(:adjust) { 50 }
      expect(repository.adjust(giftcard, -50)).to eq(50)
    end
  end
end

Внедрение зависимости

Внедрение зависимостей — это то, где вы передаете в него зависимости объекта. В этом случае, вместо того, чтобы Giftcards::Repositoryкласс решал, какой адаптер он будет использовать, мы можем явно передать ему адаптер, предоставив больше контроля в руки вызывающего.

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

adapter = Giftcards::Adapters::Test.new
repository = Giftcards::Repository.new(adapter)

Проверка нашего кода работает как ожидалось

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

Тестирование возвращаемого значения

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

describe Giftcard do
  describe '#masked_card_number' do
    it 'masks number correctly' do
      giftcard = Giftcard.new
      giftcard.card_number = '123456780012'
      expect(giftcard.masked_card_number).to eq('XXXXXXXX0012')
    end
  end
end

Тестирование другого метода, вызванного правильно

Бывают случаи, когда целью метода является передача какой-либо работы другому методу. Или, другими словами, он вызывает метод. Давайте напишем тест, чтобы убедиться, что этот другой метод был вызван правильно. Rspec на самом деле имеет механизм для выяснения этого, где мы можем проверить, что объект получил определенный вызов метода с конкретными аргументами.

describe Giftcards::Repository do
  describe '#balance' do
    let(:adapter)    { Giftcards::Adapters::Test.new }
    let(:repository) { Giftcards::Repository.new(adapter) }
    let(:giftcard)   { instance_double('Giftcard', card_number: 12345) }

    it 'returns balance for giftcard' do
      expect(repository.balance(giftcard)).to eq(100)
    end

    it 'calls the balance of adapter' do
      expect(repository.adapter).to receive(:balance).with(giftcard.card_number)
      repository.balance(giftcard)
    end
  end
end

Тестирование побочных эффектов

Наконец, наш метод может вызвать некоторые побочные эффекты. Это может быть что угодно — от изменения состояния объекта (значения переменной экземпляра) до записи в базу данных или файл. Здесь важно проверить состояние до и после, чтобы убедиться, что побочный эффект произошел.

describe Giftcard do
  describe '#cancel!' do
    it 'updates cancelled_at field to current time' do
      giftcard = Giftcard.new
      expect(giftcard.cancelled_at).to eq(nil)
      giftcard.cancel!
      expect(giftcard.cancelled_at).to be_a(Time)
    end
  end
end

Единственная ответственность

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

Следуя принципу единой ответственности , ваш код будет намного проще тестировать.

Заключение

Мы рассмотрели несколько различных способов изолировать зависимости в нашем коде при написании модульных тестов, а также как проверить, что метод выполняет то, что должен делать. Самая важная часть тестирования состоит в том, чтобы фактически сделать это. Недавно я увидел в твиттере вдохновленную Бейонсе цитату, которая сейчас кажется уместной.