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

Making assertions from non-test-case classes

背景

我有一个包含 ActiveRecord::Enum 的 rails 模型。我有一个视图助手,它采用此枚举的值,以及 returns 几种可能的响应之一。假设案例被称为 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),fooraise报错以引起我们的注意,并将其添加为新案例。但是没有什么可以阻止您忘记更新测试以测试 :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 个职责,这是我最终采用的模式 copy/pasting(我有多个函数要像这样测试):

  1. 执行 foo
  2. 的实际测试
  3. 记账以确保所有参数都经过详尽检查。

我想通过移除尽可能多的样板并将其提取到可以轻松重复使用的地方来使此测试更有针对性。

尝试的解决方案

在另一种语言中,我只提取一个简单的测试助手:

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

然后我可以在其他测试中重用这个 class,只需传递不同的 all_valuesproc 参数,并记住调用 assert_all_values_checked.

问题

但是,这会中断,因为我无法从 class 调用 assertassert_empty,而 class 不是 ActionView::TestCase 的子 class . subclass/include 一些 class/module 是否可以访问这些方法?

当生产逻辑更改违反 DRY principle 时,

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

我们可以通过将案例重构为哈希查找来解决这个问题,使其成为数据驱动。并且还给它起一个名字来描述它的关联和作用,这些是 "handlers"。我还把它变成了一个方法调用,使它更容易访问,稍后会见效。

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 更改但测试没有更改时,您总是会失败。

  • 将新的 key/value 对添加到 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