如何测试一个元素是从列表中随机选择的?

How to test that an element is randomly selected from a list?

我正在开发 Rails 应用程序并尝试练习 TDD(使用 RSpec)。我的 lib 目录中有一个文件,其中包含一个字符串列表,以及一个读取该文件并随机 select 列表中的一个字符串的方法。我还没有实现这个方法,因为我正在为如何编写测试该功能而苦苦挣扎。

有很多方法可以从数组中随机 select 一个对象,还有很多很好的回答问题,比如 this one 告诉我如何做到这一点(归结为实现,我可能会使用 Array#sample)。但我的期望应该是什么?我在想:

expect(array).to include(subject.random_select)

这肯定会断言我的方法返回了一些预期值 — 但是否足以断言该方法每次随机 returns 一个不同的字符串?有哪些替代方法,或者额外的测试可以确保我已经覆盖了这种方法?我真的不能指望 subject.random_select 等于伪造的输入,可以吗?

看来您的方向是正确的。我还会从数组中包含一个伪造的 "sample",并期望数组 包含该样本。

See these docs,例如: expect(array).not_to include("fake sample")

我会首先测试从单行文件中非随机选择单个字符串,然后测试从多行文件中选择字符串,然后我测试选择是随机的。你不能在有限的时间内真正测试随机性,所以你能做的最好的就是

  • 测试您的方法 return 的值是否在所需范围内,因为您的测试会 运行 在应用程序的整个生命周期中进行很多次,您可能会发现它是否曾经 return 超出范围,
  • 证明您的代码使用了随机源。

假设该文件在您的测试环境中不存在,或者您不知道其内容,或者不希望它在一个测试中正确而在其他测试中不正确,所以我们'需要提供一种方法让测试将 class 指向不同的文件。

我们可以编写以下内容,一次编写一个测试,使其通过并在编写下一个之前进行重构。下面是第三个测试写完后实现前的测试和代码:

spec/models/thing_spec.rb

describe Thing do
  describe '.random_select' do
    it "returns a single line from a file with only one line" do
      allow(Thing).to receive(:file) { "spec/models/thing/1" }
      expect(Thing.random_select).to eq("Thing 1")
    end

    it "returns a single line from a file with multiple lines" do
      allow(Thing).to receive(:file) { "spec/models/thing/2" }
      expect(Thing.random_select).to be_in(['Thing 1', 'Thing 2'])
    end

    it "returns different lines at different times" do
      allow(Thing).to receive(:file) { "spec/models/thing/2" }
      srand 0
      thing1 = Thing.random_select
      srand 1
      thing2 = Thing.random_select
      expect(thing1).not_to eq(thing2)
    end

  end
end

app/models/thing.rb

class Thing
  def self.random_select
    "Thing 1" # this made the first two tests pass, but it'll need to change for all three to pass
  end

  def self.file
    "lib/things"
  end

end

当我写第二个测试时,我意识到它在没有任何额外代码更改的情况下通过了,所以我考虑删除它。但我推迟了那个决定,写了第三个测试,并发现一旦第三个测试通过,第二个将有价值,因为第二个测试测试该值来自文件但第三个测试没有。

be_in 是测试 return 值是否在已知集合中比 include 更好的方法,因为它将实际值放在 expect 中,其中 RSpec 期待它。

还有其他方法可以控制随机性,因此您可以测试它是否已被使用。例如,如果您使用 sample,您可以 allow_any_instance_of(Array).to receive(:sample) 和 return 任何您喜欢的。但我喜欢使用 srand,因为它不需要实现来使用使用随机数生成器的特定方法。

如果文件丢失或为空,您也需要对其进行测试。

这里有一些关于我如何测试这样的东西的想法。我可能不会为像这样的简单函数做所有这些,但对于更复杂的情况,它们仍然是很好的技术:

  1. 将把条目读入数组的工作和从数组中随机选择元素的工作分开。

  2. 如果你不想在测试中依赖文件系统,你可以编写你的方法来处理一般的 IO 对象(或其他语言的流),然后在测试中使用 StringIO。您将有一个简单的转发函数,它可以打开文件并将打开的文件传递给使用 IO 的方法。例如:

    def read_entries(file)
      File.open(file) { |io| read_entries_from_io(io) }
    end
    
    def read_entries_from_io(io)
      # ... do the work ...
    end
    
    # In your spec:
    io = StringIO.new("Entry1\nEntry2\nEntry3\n")
    expect(read_entries_from_io(io)).to eq %w[Entry1 Entry2 Entry3]
    
  3. 几乎每个随机执行某些操作的 Ruby 方法(如 sampleshuffle)都采用可选的 random: 关键字参数,这允许您提供自己的随机数生成器。如果您的方法遵循相同的约定,则您可以从测试中注入一个假随机数生成器,return 是一个硬编码的假随机数序列:

    def random_select(entries, random: Random.new)
      entries.sample(random: random)
    end
    
    # In the spec:
    
    entries = %w[Entry0 Entry1 Entry2 Entry3]
    random = instance_double(Random)
    allow(random).to receive(:rand).and_return(2, 0)
    
    expect(random_select(entries, random: random)).to eq 'Entry2'
    expect(random_select(entries, random: random)).to eq 'Entry0'
    

注意: 在最后一个测试中我实际上不会有两个期望;我只是将其包括在内以展示如何 return 一系列随机值。

此外,在这种情况下,测试假设 sample 如何使用随机数生成器。 sample 可能没问题,但 shuffle.

可能不行