从非测试用例类中得出断言

问题描述 投票:1回答:1

背景

我有一个包含ActiveRecord::Enum的Rails模型。我有一个视图助手,它使用此枚举的值,并返回几种可能的响应之一。假设案例称为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个责任,这是我最终复制/粘贴的模式(我有多种功能可以像这样测试):

  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_valuesproc参数传递给它,并记住调用assert_all_values_checked

问题

但是,由于我无法从不是assert的子类的类中调用assert_emptyActionView::TestCase,所以这会中断。是否可以继承/包含某些类/模块来访问这些方法?

ruby-on-rails ruby minitest
1个回答
0
投票
当生产逻辑更改违反enum_cases时,

DRY principle必须保持最新。这使得more可能出现错误。此外,这是生活在生产环境中的测试代码,另一个危险信号。

我们可以通过将案例重构为Hash查找以使其成为数据驱动来解决此问题。并且还给它一个名称来描述它的关联和功能,这些就是“处理程序”。我还把它变成了一个方法调用,使它更易于访问,并且以后会见效。

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哈希来编写数据驱动的测试。

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

expectedfoo_handlers之间的冗余是设计使然。您仍然需要在两个地方都更改对,这是不可能的,但是现在当foo_handlers更改但测试没有更改时,您总会失败。

  • 将新的键/值对添加到foo_handlers时,测试将失败。
  • 如果expected中缺少密钥,则测试将失败。
  • 如果有人不小心擦除了foo_handlers,则测试将失败。
  • 如果foo_handlers中的值错误,则测试将失败。
  • 如果foo的逻辑中断,则测试将失败。

起初,您只是将foo_handlers复制到expected。之后,它成为regression test测试,即使重构后代码仍然可以正常工作。将来的更改将逐渐更改foo_handlersexpected


但是等等,还有更多!难以测试的代码可能很难使用。相反,易于测试的代码易于使用。通过其他一些调整,我们可以使用这种数据驱动的方法来使生产代码更加灵活。

如果将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
© www.soinside.com 2019 - 2024. All rights reserved.