为测试生成有效的、确定性的 UUID

Generate valid, deterministic UUIDs for tests

对于我的 ruby 测试套件,我需要可预测的 UUID。我知道 UUID 本质上是随机的和不确定的,这很好。但是在测试套件中,拥有可以通过固定装置、数据助手、种子等重新使用的 UUID 会很有用。

我现在有一个简单的实现,很容易导致无效的 UUID:

def fake_uuid(character = "x")
  [8, 4, 4, 4, 12].map { |length| character * length }.join("-")
end

fake_uuid('a') => "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" # This is valid
fake_uuid('z') => "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz" # This is invalid, not hex.

显然,我可以添加检查以确保仅允许 a-f,0-9 作为输入。另一种方法是对预先生成的 UUID 列表进行硬编码,然后根据参数选择一个。

但是我在想,有没有更好的办法呢? UUIDv5 会为此工作吗?有没有办法调用 SecureRandom.uuid 使其 return 具有相同的 UUID(对于线程或会话)? 是否需要额外的 gem?还是我的方法是最接近的方法?

不需要由所有相同的字符组成。
具有一定的可读性是一个很大的优点,但不是必需的。这样,您可以例如确保 Company 有一个 UUID cccccccc-cccc-cccc-cccc-cccccccccccc 和它的 Employee UUID eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee.

I am aware that UUIDs are by nature random and non-deterministic, and that this is good.

这个假设是错误的。

UUID有5个版本:

  • 版本 1 和版本 2 基于 MAC 地址和日期时间,因此在理论上它会在同一台计算机上同时给出相同的 UUID 的意义上是确定性的。
  • 版本 3 和 5 基于命名空间和名称,因此完全确定。
  • 版本 4 是随机的。

因此,如果您使用版本 3 或版本 5 UUID,它们将是完全确定的。

尽可能考虑依赖注入和工厂

您尝试做的似乎是测试 anti-pattern。理论上你可以通过使用 Version-1 UUIDs with a pre-defined MAC address, and a gem like timecop 创建确定性时间来做你想做的事,但这对于我能想象的任何 real-world 用例来说可能都是不合理的。

相反,您应该使用工厂而不是固定装置进行测试,或者创建允许直接注入测试输入 and/or 输出值的方法。例如:

# some UUID-related method under test
def do_something_with(uuid=nil)
  # fetch the uuid the way you would if not injected
  uuid ||= gets.chomp
  uuid.tr '3', '4'
end
  
# write your tests to validate pre-defined input and
# output values
input_value  = '01957E2E-B3BA-4A46-BC4D-00615BE630E3'
output_value = '01957E2E-B4BA-4A46-BC4D-00615BE640E4'

# validate the expected transformation
do_something_with(input_value) == output_value

无论您是使用数据库还是使用像 RSpec 这样的测试 DSL 来执行此操作,方法的结果都应该是相同的,因为您定义了这两个值。由于 TDD/BDD 不应该测试核心功能,除非您实际上是在尝试测试一些自定义 UUID 生成器,否则这种方法应该可以做到。如果您 正在 滚动自己的生成器,那么您仍然可以使用相同的方法来注入参数,例如 MAC 地址、date/time 或用于生成您的生成器的其他因素确定性 UUID。

其他方法可能包括生成一组值(例如为数据库设定种子),然后在完成测试后回滚或截断数据库。 database_cleaner gem 是这样做的主要内容,但您原来的 post 并不能真正证明额外的复杂性。我在这里提到它主要是为了指出 fixture/factory 大多数用例的解决方案仍然允许您遵循相同的注入或依赖可预测数据的基本模式。

如果您正在使用 rspec,您可以存根 SecureRandom.uuid 的 return 值。

context "my example context" do
  let(:expected_uuid) { "709ab60d-3c5f-48d8-ac55-dc6b8f4f85bf" }

  before do
    allow(SecureRandom).to receive(:uuid).and_return(expected_uuid)
  end 

  it "uses the expected uuid" do
    # your spec
  end 
end

每次在 "my example context" 的上下文中调用 SecureRandom.uuid 时,这将 return expected_guid

UUID使用两位数字来表示它们的格式:(实际上只是一些数字的位)

xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx
              ^    ^
        version    variant

以下模式表示 version 4 (M=4),变体 1 (N=8),仅表示“随机字节”:

xxxxxxxx-xxxx-4xxx-8xxx-xxxxxxxxxxxx

您可以将其用作模板,根据序列号生成假的(但有效的)UUID:(如评论中所建议)

def fake_uuid(n)
  '00000000-0000-4000-8000-%012x' % n
end

fake_uuid(1) #=> "00000000-0000-4000-8000-000000000001"
fake_uuid(2) #=> "00000000-0000-4000-8000-000000000002"
fake_uuid(3) #=> "00000000-0000-4000-8000-000000000003"

Having it somewhat readable is a big pro ...

有很多未使用的字段/数字可以添加更多数据:

def fake_uuid(klass, n)
  k = { Company => 1, Employee => 2 }.fetch(klass, 0)

  '%08x-0000-4000-8000-%012x' % [k, n]
end

fake_uuid(Company, 1)   #=> "00000001-0000-4000-8000-000000000001"
fake_uuid(Company, 2)   #=> "00000001-0000-4000-8000-000000000002"

fake_uuid(Employee, 1)  #=> "00000002-0000-4000-8000-000000000001"
fake_uuid(Employee, 2)  #=> "00000002-0000-4000-8000-000000000002"

#                            ^^^^^^^^                ^^^^^^^^^^^^
#                              class                   sequence