粗心的Mock模拟测试是有害的 - Philippe Bourgau


在2010年至2014年期间,我正在开展一个名为http://mes-courses.fr的辅助项目。这实际上类似“家庭购物”。我希望人们能够在5分钟内通过使用更好的在线百货界面购物。我使用的是Ruby,我刚读过以测试为导向的面向对象的成长软件。我对Mock模拟感到有点太兴奋了,而且用得太多了。
我一直在练习测试驱动开发超过5年,我期待使用像Ruby这样的语言取得很好的成绩。几个月之后,我可以感觉到事情并没有那么好。测试初始化​​代码越来越长,因为它包含了大量的模拟设置。这使得测试更复杂,更不易读。这也使它们变得不可靠,我的所有单元测试都无法测试出系统异常情况。我越来越习惯于经常进行端到端的测试,我也失去了很多时间来维护模拟设置代码与真实类一致。Mocks还欺骗了我在代码和测试文件之间保持1对1映射的不良做法。当将代码从一个文件移动到另一个文件时,这又增加了我的维护负担。

它达到了我无法忍受的程度。所有这些问题都指向了模拟,因此我尝试将它们从测试文件中删除。以下是我最终用来删除它们的技术: 


最终的结果超出了我的希望,因为我的问题几乎神奇地消失了。代码变得更简单,我对单元测试变得更加自信,并且更容易维护。作为一个例子,这里是轨道控制器测试文件的差异的摘录,该文件经历了这种模拟饮食。

其他人已经谈到了模拟的危险:

不可变的值对象如何对抗模拟
过度使用模拟会使测试非常痛苦。如果我们长时间坚持痛苦的模拟,我们最终会放弃单位测试。最终,系统将降级为遗留系统。 

不可变的值对象:

  • 创建后无法改变状态
  • 仅依赖于其他不可变值对象
  • 不要以任何方式改变整个系统的状态
  • 不要做副作用,例如输入和输出

Eric Evans在Domain-Driven Design Blue Book中推广了这个名字。不可变值对象在函数式语言中已经存在了数十年。我们说这些对象是不可变的(它们不能改变)和纯粹的(它们不能做副作用)。以下是值对象Value Objects的两个有趣属性:
  • 您可以多次调用方法,而不会有任何改变系统的风险
  • 每次在同一个对象上调用相同的方法时,总会得到相同的结果

这些本身在测试时已经很方便了。

让我们反过来看看副作用如何导致嘲弄。每个测试都从设置运行测试的状态开始。副作用使这变得复杂,因为许多对象需要协作来设置此状态。当这变得太痛苦时,人们开始用Mock模拟(模拟相当于一种黑客技术),这反过来使测试更加脆弱:

  • 我们没有测试“真实”的情况
  • 我们需要将此设置与实际代码保持一致

错综复杂的状态初始化鼓励人们使用模拟。

隔离系统的各个部分
不幸的是,这不是故事的全部!可变状态也诱使我们使用模拟。一旦您的测试处理可变状态,就有可能在“真实”系统中更改此状态,这意味着一些错误可能会“逃避”单元测试并出现在端到端测试或生产中,那是使用Mock模拟的地方!为了在快速反馈循环中检测到这个错误,我们可能会添加更大范围的测试并使用模拟来加速它们......

可变状态和副作用使单元测试效果降低。

但是,不可变值对象帮助我们避免模拟的另一个原因。由于前两个原因我们会尝试越来越多地使用它们,我们需要调整我们的编程风格。随着我们将在不可变值对象中推送越来越多的代码,“命令式”部分将缩小。这种“命令性”部分是副作用发生的地方。这是模拟IO有意义的部分。总而言之,我们使用的不可变值对象越多,IO就越孤立,我们需要的模拟越少。
Javascript专家Eric Elliot也在这里写了关于不变性和模拟的文章。

Fizz Buzz示例
举个简单的例子,我将介绍经典的Fizz Buzz。我已经使用和不使用不可变值对象来实现和测试它。请记住,这是一个玩具示例,问题很明显且很容易修复。我试图在小范围内突出大型程序的复杂性所隐藏的相同问题。

让我们从典型的FizzBu​​zz实现开始。

1.upto(100) do |i|
  if (i%3 == 0 and i%5 == 0)
    STDOUT.puts("FizzBuzz\n")
  elsif (i%3 == 0)
    STDOUT.puts(
"Fizz\n")
  elsif (i%5 == 0)
    STDOUT.puts(
"Buzz\n")
  else
    STDOUT.puts(
"#{i}\n")
  end
end

假设您需要在代码周围添加一些测试。最简单的方法是模拟STDOUT:

require 'rspec'

def fizzBuzz(max, out)
  1.upto(max) do |i|
    if (i%3 == 0 and i%5 == 0)
      out.puts("FizzBuzz\n")
    elsif (i%3 == 0)
      out.puts(
"Fizz\n")
    elsif (i%5 == 0)
      out.puts(
"Buzz\n")
    else
      out.puts(
"#{i}\n")
    end
  end
end

# main
fizzBuzz(100,STDOUT)

describe 'Mockist Fizz Buzz' do

  it 'should print numbers, fizz and buzz' do
    out = double(
"out")
    expect(out).to receive(:puts).with(
"1\n").ordered
    expect(out).to receive(:puts).with(
"2\n").ordered
    expect(out).to receive(:puts).with(
"Fizz\n").ordered
    expect(out).to receive(:puts).with(
"4\n").ordered
    expect(out).to receive(:puts).with(
"Buzz\n").ordered
    expect(out).to receive(:puts).with(
"Fizz\n").ordered
    expect(out).to receive(:puts).with(
"7\n").ordered
    expect(out).to receive(:puts).with(
"8\n").ordered
    expect(out).to receive(:puts).with(
"Fizz\n").ordered
    expect(out).to receive(:puts).with(
"Buzz\n").ordered
    expect(out).to receive(:puts).with(
"11\n").ordered
    expect(out).to receive(:puts).with(
"Fizz\n").ordered
    expect(out).to receive(:puts).with(
"13\n").ordered
    expect(out).to receive(:puts).with(
"14\n").ordered
    expect(out).to receive(:puts).with(
"FizzBuzz\n").ordered

    fizzBuzz(15, out)
  end
end

不幸的是,这段代码存在一些问题:

  • 使用嵌套逻辑和复杂的模拟设置,代码和测试都不是非常易读
  • 他们似乎也违反了单一责任原则
  • 这取决于可变输出。在一个更大的程序中,有些东西可能会搞乱这个输出流。这会破坏FizzBu​​zz。

现在让我们尝试使用尽可能多的不可变值对象,看看模拟会发生什么。

require 'rspec'

# We extracted a function to do the fizz buzz on a single number
def fizzBuzzN(i)
  if (i%3 == 0 and i%5 == 0)
    "FizzBuzz"
  elsif (i%3 == 0)
   
"Fizz"
  elsif (i%5 == 0)
   
"Buzz"
  else
    i.to_s
  end
end

# We replaced the many calls to STDOUT.puts by building a single 
# large (and immutable) string
def fizzBuzz(max)
  ((1..max).map {|i| fizzBuzzN(i)}).join(
"\n")
end

# main, with a single call to STDOUT.puts
STDOUT.puts fizzBuzz(100)

describe 'Statist Fizz Buzz' do

  it 'should print numbers not multiples of 3 or 5' do
    expect(fizzBuzzN(1)).to eq(
"1")
    expect(fizzBuzzN(2)).to eq(
"2")
    expect(fizzBuzzN(4)).to eq(
"4")
  end

  it 'should print Fizz for multiples of 3' do
    expect(fizzBuzzN(3)).to eq(
"Fizz")
    expect(fizzBuzzN(6)).to eq(
"Fizz")
  end

  it 'should print Buzz for multiples of 5' do
    expect(fizzBuzzN(5)).to eq(
"Buzz")
    expect(fizzBuzzN(10)).to eq(
"Buzz")
  end

  it 'should print FizzBuzz for multiples of 3 and 5' do
    expect(fizzBuzzN(15)).to eq(
"FizzBuzz")
    expect(fizzBuzzN(30)).to eq(
"FizzBuzz")
  end


  it 'should print numbers, fizz and buzz' do
    expect(fizzBuzz(15)).to start_with(
"1\n2\nFizz").and(end_with("14\nFizzBuzz"))
  end
end

正如我们所看到的,使用不可变值对象让我们摆脱了模拟。显然,这个新代码不如原始版本有效,但大多数时候,这并不重要。虽然我们获得了更好的颗粒和更可读的测试作为奖励。

不可变值对象具有与测试相关的其他优点。

  • 我们可以直接断言他们的等同,而不必深入了解他们的内部结构
  • 我们可以根据需要多次调用方法,而不会有改变任何东西和破坏测试的风险
  • 不可变值对象不太可能包含无效状态。这消除了对一系列有效性测试的需要。

为什么说服其他开发人员使用不可变数据结构如此困难?
到目前为止,遇到共享可变状态的错误时,我获得了最大的成功。当这种情况发生时,不变设计的长期利益和安全性赢得了人们的青睐。好消息是,当你说服团队中的更多人时,不变性会像病毒一样传播!
在这种情况之外,您可以尝试以下一些参数来说服其他人员:
  • 不可变值可防止由系统的不同部分引起的错误改变相同的可变状态
  • 它们使得在较小的部分中处理程序变得更容易并且一般地推理系统
  • 不可变值不需要任何同步,使多线程编程更容易
  • 当试图添加一个简单的setter而不是保持一个类不可变时,突出显示压力很大的调试时间
  • 如果您正在处理设计合同熟练,请解释内置的不变性
  • 承认主流语言对不可变值对象的支持不足。指向可以解决这些限制的数据构建器等模式