从非测试用例中做出断言 类
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
),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 个职责,这是我最终采用的模式 copy/pasting(我有多个函数要像这样测试):
- 执行
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
然后我可以在其他测试中重用这个 class,只需传递不同的 all_values
和 proc
参数,并记住调用 assert_all_values_checked
.
问题
但是,这会中断,因为我无法从 class 调用 assert
和 assert_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
expected
和 foo_handlers
之间的冗余是设计使然。您仍然需要在两个地方更改对,没有办法解决这个问题,但是现在当 foo_handlers
更改但测试没有更改时,您总是会失败。
- 将新的 key/value 对添加到
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
背景
我有一个包含 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
),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 个职责,这是我最终采用的模式 copy/pasting(我有多个函数要像这样测试):
- 执行
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
然后我可以在其他测试中重用这个 class,只需传递不同的 all_values
和 proc
参数,并记住调用 assert_all_values_checked
.
问题
但是,这会中断,因为我无法从 class 调用 assert
和 assert_empty
,而 class 不是 ActionView::TestCase
的子 class . subclass/include 一些 class/module 是否可以访问这些方法?
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
expected
和 foo_handlers
之间的冗余是设计使然。您仍然需要在两个地方更改对,没有办法解决这个问题,但是现在当 foo_handlers
更改但测试没有更改时,您总是会失败。
- 将新的 key/value 对添加到
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