Делая утверждения из классов не в тестовом случае - PullRequest
1 голос
/ 16 января 2020

Фон

У меня есть модель рельсов, которая содержит ActiveRecord::Enum. У меня есть помощник вида, который принимает значение этого перечисления и возвращает один из нескольких возможных ответов. Предположим, что случаи назывались enum_cases, например:

enum_cases = [:a, :b, :c]

def foo(input)
    case input
    when :a then 1
    when :b then 2
    when :c then 3
    else raise NotImplementedError, "Unhandled new case: #{input}"
    end
end

Я хочу провести модульное тестирование этого кода. Проверка счастливых путей тривиальна:

class FooHelperTests < ActionView::TestCase
  test "foo handles all enum cases" do
    assert_equal foo(:a), 1
    assert_equal foo(:b), 2
    assert_equal foo(:c), 3
    assert_raises NotImplementedError do
        foo(:d)
    end
  end
end

Однако в этом есть недостаток. Если будут добавлены новые случаи (например, :z), foo выдаст raise ошибку, чтобы привлечь наше внимание к ней, и добавит ее в качестве нового случая. Но ничто не мешает вам забыть обновить тест, чтобы проверить новое поведение для :z. Теперь я знаю, что это в основном работа с инструментами покрытия кода, и мы используем один из них, но не настолько строго, чтобы разрывы в одну строку взорвались. Кроме того, это, во всяком случае, своего рода учебное упражнение.

Так что я изменил свой тест:

test "foo handles all enum cases" do
  remaining_cases = enum_cases.to_set

  tester = -> (arg) do
    remaining_cases.delete(arg)
    foo(arg)
  end

  assert_equal tester.call(:a), 1
  assert_equal tester.call(:b), 2
  assert_equal tester.call(:c), 3
  assert_raises NotImplementedError do
    tester.call(:d)
  end

  assert_empty remaining_cases, "Not all cases were tested! Remaining: #{remaining_cases}"
end

Это прекрасно работает, однако у него есть 2 обязанности, и это образец, который я в итоге копирую / pasting (у меня есть несколько функций для проверки, как это):

  1. Выполните фактическое тестирование foo
  2. Ведите бухгалтерский учет, чтобы убедиться, что все параметры были тщательно проверены.

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

Попытка решения

На другом языке я бы просто извлек простой помощник по тестированию:

class ExhaustivityChecker
  def initialize(all_values, proc)
    @remaining_values = all_values.to_set
    @proc = proc
  end

  def run(arg, allow_invalid_args: false)
    assert @remaining_values.include?(arg) unless allow_invalid_args 
    @remaining_values.delete(arg)
    @proc.call(arg)
  end

  def assert_all_values_checked
    assert_empty @remaining_values, "Not all values were tested! Remaining: #{@remaining_values}"
  end
end

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

test "foo handles all enum cases" do
    tester = ExhaustivityChecker.new(enum_cases, -> (arg) { foo(arg) })

    assert_equal tester.run(:a), 1
    assert_equal tester.run(:b), 2
    assert_equal tester.run(:c), 3
    assert_raises NotImplementedError do
        tester.run(:d, allow_invalid_args: true)
    end

    tester.assert_all_values_checked
end

Я мог бы затем повторно использовать этот класс в других тестах, просто передав ему разные all_values и proc аргументы и не забывая вызвать assert_all_values_checked.

Issue

Однако, это ломается, потому что я не могу вызвать assert и assert_empty из класса, который не является подклассом ActionView::TestCase. Можно ли создать подкласс / включить некоторый класс / модуль для получения доступа к этим методам?

1 Ответ

1 голос
/ 16 января 2020

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

Мы можем решить эту проблему путем рефакторинга дела в поиске Ha sh, сделав его управляемым данными . А также присвоение ему имени, описывающего, с чем оно связано и что оно делает, это «обработчики». Я также превратил его в вызов метода, который облегчает доступ и который принесет плоды позже.

def foo_handlers
  {
    a: 1,
    b: 2,
    c: 3
  }.freeze
end

def foo(input)
  foo_handlers.fetch(input)
rescue KeyError
  raise NotImplementedError, "Unhandled new case: #{input}"
end

Hash#fetch используется для поднятия KeyError, если ввод не найден.

Затем мы можем написать управляемый данными тест, выполнив цикл, не foo_handlers, а кажущийся избыточным expected Ха sh, определенный в тестах.

class FooHelperTests < ActionView::TestCase
  test "foo handles all expected inputs" do
    expected = {
      a: 1,
      b: 2,
      c: 3
    }.freeze

    # Verify expect has all the cases.
    assert_equal expect.keys.sort, foo_handlers.keys.sort

    # Drive the test with the expected results, not with the production data.
    expected.keys do |key|
      # Again, using `fetch` to get a clear KeyError rather than nil.
      assert_equal foo(key), expected.fetch(value)
    end
  end

  # Simplify the tests by separating happy path from error path.
  test "foo raises NotImplementedError if the input is not handled" do
    assert_raises NotImplementedError do
      # Use something that obviously does not exist to future proof the test.
      foo(:does_not_exist)
    end
  end
end

Избыточность между expected и foo_handlers это дизайн. Вам все еще нужно поменять пары в обоих местах, этого не избежать, но теперь вы всегда получите ошибку, когда foo_handlers изменится, а тесты - нет.

  • Когда новый ключ Пара / значение добавляется к foo_handlers, тест не пройден.
  • Если в expected отсутствует ключ, тест не пройден.
  • Если кто-то случайно уничтожит foo_handlers тест потерпит неудачу.
  • Если значения в foo_handlers неверны, тест не пройден.
  • Если лог c из foo сломан, тест не пройден.

Сначала вы просто скопируете foo_handlers в expected. После этого это становится регрессионным тестом , проверяющим, что код все еще работает даже после рефакторинга. Будущие изменения будут постепенно меняться foo_handlers и expected.


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

Если мы сделаем foo_handlers аксессором со значением по умолчанию, исходящим от метода, а не от константы, теперь мы можем изменить поведение foo для отдельных объектов. Это может или не может быть желательным для вашей конкретной реализации, но оно есть в вашей панели инструментов.

class Thing
  attr_accessor :foo_handlers

  # This can use a constant, as long as the method call is canonical.
  def default_foo_handlers
    {
      a: 1,
      b: 2,
      c: 3
    }.freeze
  end

  def initialize
    @foo_handlers = default_foo_handlers
  end

  def foo(input)
    foo_handlers.fetch(input)
  rescue KeyError
    raise NotImplementedError, "Unhandled new case: #{input}"
  end
end

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

thing = Thing.new
puts thing.foo(:a) # 1
puts thing.foo(:b) # 2

thing.foo_handlers = { a: 23 }
puts thing.foo(:a) # 23
puts thing.foo(:b) # NotImplementedError

И что более важно, подкласс может изменить свои обработчики. Здесь мы добавляем обработчики, используя Hash#merge.

class Thing::More < Thing
  def default_foo_handlers
    super.merge(
      d: 4,
      e: 5
    )
  end
end

thing = Thing.new
more = Thing::More.new

puts more.foo(:d)  # 4
puts thing.foo(:d) # NotImplementedError

Если ключ требует более простого значения, используйте имена методов и вызывайте их с помощью Object#public_send. Затем эти методы могут быть проверены модулем.

def foo_handlers
  {
    a: :handle_a,
    b: :handle_b,
    c: :handle_c
  }.freeze
end

def foo(input)
  public_send(foo_handlers.fetch(input), input)
rescue KeyError
  raise NotImplementedError, "Unhandled new case: #{input}"
end

def handle_a(input)
  ...
end

def handle_b(input)
  ...
end

def handle_c(input)
  ...
end
...