Debug Case: 单元测试里的清理现场并不干净

一个系列

在爱范儿技术团队,我个人一直在维护一个技术分享主题:Debug Case。Debug Case 顾名思义,是根据项目中遇到的实际问题得出的解决方案进行的分享。它们,往往要从一个简单的问题开始讲起。

一个问题

今天要说的问题是:
一个 Django 项目里的测试用例随着一次提交失败了。

这个测试用例长这样:

def test_update_article(self):
   resp = self.c.post(
     reverse('apiary_update_article'),
     data=json.dumps(self.payload),
     content_type='application/json'
   )
   self.assertJSONEqual(
     json.dumps({'status': 'ok', 'article_id': 1}),
     resp.content
   )
   # repost article with the same payload
   # case to ensure the article wont be duplicated

这是一个简单的用例,做的事情就是请求一个接口,通过检查返回结果看接口是不是正常工作,典型的无脑黑盒测试法。接口本身做的事情也就是在写数据库、读数据库。这样的测试用例都可以突然挂,简直…… 且慢,我们先看看测试实际上是个什么过程。

一个过程

当有人突然抛出这样一个问题,大多数工程师第一反应应该都是:teardown 没做(干净)吧?

对。这是个十分正确的设问。

单元测试通常是这样的过程——

class SomeTest(unittest.case.TestCase):
  def setUp(self):
    super(SomeTest, self).setUp()
    setup_db() # 初始化数据库,通常是在建立连接、创建表、创建初始数据等。

  def tearDown(self):
    clean_db() # 清理数据库现场,通常是在删除用例里产生的新数据、断开连接。
    super(SomeTest, self).tearDown()

很多同学可能说了:“我写测试的时候从来不关 setup 和 teardown,测试照样跑。”

对,原因就在于,每个测试用例,setup 和 teardown 都会自己跑一遍默认行为:初始化数据、清理数据库现场。

我们看一下源码:

看完源码,上边那个很正确的设问似乎就没用了。依然正确,但没用。源码用短短几行说明了:不管你知不知道,teardown 在测试跑完了就会直接 rollback 到 setup 时保存的 checkpoint(也即 session)之前。

那问题出在哪里?

一个真 Bug

真 Bug,是一种设计上的困境,而不是编码的错误。

读完源码,问题只能出在 rollback 上了。在本例中,数据库是 PostgreSQL(问题对所有 SQL 数据库都适用),我们来看 Postgres 的文档。

Important
Because sequences are non-transactional, changes made by setval are not undone if the transaction rolls back.
This function requires UPDATE privilege on the sequence.

9.16. Sequence Manipulation Functions

Postgres 特别提醒:由于自增序列(像上边用例的 article_id)是非事务性的,所以事务回滚是没法撤销自增序列上的修改的。

Why?

Aaron 解释说:

It would not be a good idea to rollback sequences. Imagine two transactions happening at the same time, each of which uses the sequence for a unique id. If the second transaction commits and the first transaction rolls back, then the second inserted a row with "2" while the first rolls the sequence back to "1".
If that sequence is then used again, the value of the sequence will become "2" which could lead to a unique constraint problem.

这是一个真 Bug,设想,一旦允许了自增序列回滚,在下面这个场景将无法工作:

数据表 id unique(唯一);
向数据表写入两条数据,此时 sequence 增加到 2;
向数据表写入一条数据,此时 sequence 增加到 3,这条数据 id 为 3;
回滚第一个行为,此时 sequence 回滚到 0;
向数据表写入三条数据,此时有数据的 id 冲突(都是 3)。

所以,回滚是无法撤销自增 id 的变化的

一个答案

知道了问题的本源,答案就很容易找了。本文测试用例中的 article_id 字段,由于无法在 teardown 过程中被 rollback,发生了变动,不再如预期,测试失败。

怎么解?

不推荐的解法 1: 我就要回滚自增 id!
这么做:ALTER SEQUENCE serial RESTART WITH 105;
或者这么做:SELECT pg_catalog.setval(pg_get_serial_sequence('table_name', 'id'), 1);
但要做好解决因此带来的麻烦的准备。

推荐的解法 2: 不要在用例里依赖(校验)自增 id!
对,不要这么做就没事了。

References