我有一个包含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个责任,这是我最终复制/粘贴的模式(我有多种功能可以像这样测试):
foo
的实际测试我想通过尽可能多地去除样板并将其提取到易于使用的位置来使测试更加集中。
用另一种语言,我只提取一个简单的测试助手:
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
。
但是,由于我无法从不是assert
的子类的类中调用assert_empty
和ActionView::TestCase
,所以这会中断。是否可以继承/包含某些类/模块来访问这些方法?
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
expected
和foo_handlers
之间的冗余是设计使然。您仍然需要在两个地方都更改对,这是不可能的,但是现在当foo_handlers
更改但测试没有更改时,您总会失败。
foo_handlers
时,测试将失败。expected
中缺少密钥,则测试将失败。foo_handlers
,则测试将失败。foo_handlers
中的值错误,则测试将失败。foo
的逻辑中断,则测试将失败。起初,您只是将foo_handlers
复制到expected
。之后,它成为regression test测试,即使重构后代码仍然可以正常工作。将来的更改将逐渐更改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