В своей статье о модульном тестировании Мартин Фаулер упоминает, что то, чем на самом деле является единица, может меняться в зависимости от того, из какой школы вы пришли. Многие из объектно-ориентированного мира рассматривают Класс как единицу, тогда как те, кто в мире функционального программирования, рассматривают Функцию как единицу.
Хотя Ruby является объектно-ориентированным языком, я склонен рассматривать метод / функцию как единое целое. Это то, что должно быть проверено, фокусируясь на тех методах, которые составляют открытый интерфейс определенного класса. Частные методы можно тестировать через их общедоступные интерфейсы, что позволяет изменять их фактическую реализацию и при необходимости изменять их структуру.
В этой статье мы рассмотрим несколько способов, которыми мы можем выполнить модульное тестирование в Ruby. Мы посмотрим, как мы можем изолировать наши модули от их зависимостей, а также как мы можем проверить, что наш код работает правильно.
Тестирование важно … и мнение
Вам нужно только взглянуть на DHH, чтобы увидеть, что с тестированием приходят мнения .
При тестировании есть много веских причин, чтобы либо добиваться полной изоляции от устройства к устройству, либо позволить системе работать вместе, как она была построена, с зависимостями и всем остальным. Самое главное, что вы действительно проводите тестирование, независимо от того, какую методологию вы используете.
Я не совсем против тестирования частных методов, если они могут помочь программисту проверить, что они работают правильно, особенно как часть TDD, так что это должно быть оставлено на усмотрение человека, чтобы принять решение. Просто имейте в виду, что частная реализация скорее изменится, чем публичный API — эти тесты с большей вероятностью будут переписаны и / или полностью отброшены, когда их полезность исчезнет.
Тесты и разработка через тестирование должны помочь вам написать лучший код, в котором вы уверены. Следуйте методологии, которая поможет вам достичь этого.
Что должно быть проверено?
Учитывая, что мы собираемся тестировать методы, что мы на самом деле тестируем? Что мы пытаемся доказать, проверяя их? Вопрос в том, какова цель метода. Это то, что должно быть проверено. Чего это намеревается достичь?
Я предполагаю, что есть другие, но я собираюсь сгруппировать цель метода в три различных категории:
- Возвращает значение.
- Он передает работу куда-то еще ( т. Е. Отправляет работу в другое место).
- Это вызывает побочный эффект.
Что не следует проверять?
При модульном тестировании важно избегать тестирования всей системы (что также является интеграционным тестированием — также важно). Также особенно важно избегать внешних зависимостей, среди худших из которых — внешний вызов 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
Единственная ответственность
Если ваш метод не попадает ни в одну из трех перечисленных выше категорий — это означает, что его назначение не определяется результатом, вызовом метода или побочным эффектом — возможно, он делает слишком много. В этом случае его следует разбить на серию одноцелевых методов.
Следуя принципу единой ответственности , ваш код будет намного проще тестировать.
Заключение
Мы рассмотрели несколько различных способов изолировать зависимости в нашем коде при написании модульных тестов, а также как проверить, что метод выполняет то, что должен делать. Самая важная часть тестирования состоит в том, чтобы фактически сделать это. Недавно я увидел в твиттере вдохновленную Бейонсе цитату, которая сейчас кажется уместной.