我们有一个异步任务,可以对对象执行可能长时间运行的计算。然后结果缓存在对象上。为了防止多个任务重复相同的工作,我们添加了带有原子 SQL 更新的锁定:
UPDATE objects SET locked = 1 WHERE id = 1234 AND locked = 0
锁定仅针对异步任务。用户仍然可以更新对象本身。如果发生这种情况,旧版本对象的任何未完成的任务都应该丢弃其结果,因为它们可能已经过时。通过原子 SQL 更新也很容易做到这一点:
UPDATE objects SET results = '...' WHERE id = 1234 AND version = 1
如果对象已更新,其版本将不匹配,因此结果将被丢弃。
这两个原子更新应该处理任何可能的竞争条件。问题是如何在单元测试中验证这一点。
第一个信号量很容易测试,因为只需使用两种可能的场景设置两个不同的测试即可:(1) 对象被锁定,(2) 对象未被锁定。 (我们不需要测试 SQL 查询的原子性,因为这应该是数据库供应商的责任。)
如何测试第二个信号量?在第一个信号量之后但在第二个信号量之前的某个时间,第三方需要更改该对象。这需要暂停执行,以便可靠且一致地执行更新,但我知道不支持使用 RSpec 注入断点。有没有办法做到这一点?或者是否有其他一些我忽略的技术来模拟这种竞争条件?
您可以借鉴电子制造的想法,将测试挂钩直接放入生产代码中。正如电路板可以在制造时留出特殊位置供测试设备控制和探测电路一样,我们也可以用代码做同样的事情。
假设我们有一些代码将一行插入数据库:
class TestSubject
def insert_unless_exists
if !row_exists?
insert_row
end
end
end
但是这段代码是在多台计算机上运行的。那么,存在竞争条件,因为另一个进程可能会在我们的测试和插入之间插入行,从而导致 DuplicateKey 异常。我们想要测试我们的代码是否处理由该竞争条件导致的异常。为此,我们的测试需要在调用
row_exists?
之后、调用 insert_row
之前插入行。因此,让我们在此处添加一个测试钩子:
class TestSubject
def insert_unless_exists
if !row_exists?
before_insert_row_hook
insert_row
end
end
def before_insert_row_hook
end
end
当在野外运行时,该钩子除了消耗一点点CPU时间之外什么也不做。但是,当测试代码的竞争条件时,测试猴子会在 before_insert_row_hook 之前打补丁:
class TestSubject
def before_insert_row_hook
insert_row
end
end
这不是很狡猾吗?就像寄生黄蜂幼虫劫持了毫无戒心的毛毛虫的身体一样,测试劫持了被测代码,以便它将创建我们需要测试的确切条件。
这个想法就像异或游标一样简单,所以我怀疑很多程序员都独立发明了它。我发现它对于测试具有竞争条件的代码通常很有用。我希望它有帮助。
感谢韦恩的想法。猴子修补的唯一问题是,这将适用于整个套件中对被测方法的所有调用,这可能不是您想要的。
使用代码块怎么样?这样,您可以在单独的单元测试中提供一个块,但在其他地方保持相同的实现。
class TestSubject
def insert_unless_exists
if !row_exists?
yield if block_given?
insert_row
end
end
end
测试中(以RSpec为例)
RSpec.describe TestSubject do
subject { described_class.new }
before do
subject.insert_unless_exists do
# Only test this race condition in this unit test
insert_row
end
end
end