发布一个支持依赖关系组织的测试框架

2016-06-04 20:22:12 +08:00
 reorx

Deptest

地址: reorx/deptest , pypi/deptest

Web API 测试的需求已经困扰我好几年了。想象一个发布文章 (post) 的服务, API 如下:

想把所有的接口都测试一遍,怎样组织你的测试代码呢?

首先你可能会想到,出于单元测试的思想,要为每个接口写一个测试函数:

def test_get_posts():
    ...

def test_post_posts():
   ...

def test_put_post():
   ...

嗯,每个函数完成一个功能测试,很优雅,很整洁。

然而这时你会发现,他们并不是完全独立的测试单元, test_get_posts 要拿到数据,首先要 test_post_posts 先执行过; test_put_post 需要一个 post 的 id 才可以执行。怎样让他们执行的时候都既能满足先决条件,又不会影响到其他函数的执行呢?

如果用的是 nose 或者 py.test ,大概有两种方法来解决这个问题:

  1. 严格遵循单元测试思想:每个函数都应该独立执行,并且执行之后不影响全局环境。给每个测试函数加上 setup 和 teardown ,用来初始化数据和消除影响。比如 test_put_post.setup 会首先给数据库里插入一个 post 条目, test_put_post.teardown 把这个条目删除。

    这看起来似乎很美好,但是有两个非常糟糕的问题。一个是测试的目的发生了改变。原本这些测试是为了验证从 HTTP 请求到后端 model 层的数据接口整个流程是否工作正常,但 setup 中放入数据库操作的代码却让 model 层工作正常成为了先决条件,最终其实只是测试了 HTTP 服务是否工作正常,然而 HTTP 服务又不是你写的代码,测个毛线啊测 (╯°Д°)╯︵ ┻━┻

    另一个是让代码变得非常臃肿,原本只是想单纯地测试一下 HTTP 请求,看看结果如何,却写了一堆数据库操作的代码,而这些代码又可能引入新的问题。效率变得十分低下,写出的代码也会非常难看,最后很烦躁于是 git rm 回到点击测试的原始状态…

  2. 只写一个测试函数,把所有 HTTP 请求按照需要的先后顺序执行一遍…这可能也会带来想要删除代码的强烈烦躁情绪。

That's why deptest was created.

我们来看看以上的例子如果用 deptest 来写会是什么样子:


def test_post_posts():
    """POST to create a post item"""
    data = {
        'name': 'hello'
    }
    resp = requests.post(_url('/posts'), data=json.dumps(data))
    log_resp(resp)

    assert resp.status_code == 200
    assert 'id' in resp.json()

    return resp.json()


@depend_on('test_post_posts', with_return=True)
def test_get_posts(p):
    """GET post list, should be run after a post has been created"""
    resp = requests.get(_url('/posts'))
    log_resp(resp)

    assert resp.status_code == 200
    d = resp.json()
    assert len(d) == len(app.db)
    assert d[0]['id'] == p['id']


@depend_on('test_get_post', with_return=True)
def test_put_post(p):
    """PUT a post item, should be run after a post has been created.
    The reason why this function depends on not `test_post_posts`
    but `test_get_post` is because if it run before `test_get_post`,
    the name of the post will be changed, which will make
    the name comparation failed in `test_get_post`.
    """
    new_p = dict(p)
    new_p['name'] = 'world'
    resp = requests.put(
        _url('/posts/{}'.format(p['id'])),
        data=json.dumps(new_p))
    log_resp(resp)

    assert resp.status_code == 200
    d = resp.json()
    assert d['name'] == new_p['name']

怎么样,是不是既让每个函数只测试一个接口,又解决了顺序和依赖的问题呢 ʕ•̫͡•ʔ✧

Deptest 用一个叫 depend_on 的装饰器来定义测试函数的依赖关系:

在上面的例子中, @depend_on('test_get_post', with_return=True) 表达了 test_put_post 依赖于 test_get_post, 且接收其返回值作为参数的意思。因此 test_put_post 一定会在 test_get_post 执行成功 后才会执行。如果 test_get_post 失败了, test_put_post 不会被执行,其状态会变为 UNMET,表示未满足依赖而没有执行。

你可以在 这里 看到上面这个例子的代码,它的运行结果如下:

Have fun testing :D

4713 次点击
所在节点    Python
25 条回复
LXJ
2016-06-06 17:55:24 +08:00
@reorx 『其实如果想确保 fixture 的东西都是正确的,也可以在里面写一些 assert 语句确保正确,只是它不叫 testcase 而已』 这句话其实对应着主贴提到的 『严格遵循单元测试思想中问题: 一个是测试的目的发生了改变、另一个是让代码变得非常臃肿』臃肿的问题在 fixture 的写法上感觉还好, 还有也对应着『 setup 中放入数据库操作的代码却让 model 层工作正常成为了先决条件』,一般在构建 fixture 时,也会在调试时验证 fixture 是否正确,只是没有像 testcase 那样加一些 assert 语句,这点工作和单独写成一个 testcase 并没有本质的分别;


『因为「对测试用例的执行顺序有要求」这样的事情在很多场景下是存在的』,其实个人觉得即使是要测试的流程变得很长,也是一个单元测试,只是测试的单元变得大了而已,按照 『互相有依赖的测试其实可以看做一个测试组』说法,感觉 deptest 是把这个长的测试流程分拆了组,如果能整合到 nose 或 pytest 里面感觉会个吸引人的特性 :)
reorx
2016-06-06 19:03:12 +08:00
@LXJ 嗯,所以对 fixture 我主要的点在于,其实它很多时候就是一种测试了,但是却没有被当成一个测试来对待,反而把报错传递给了依赖它的测试,这在显示上很不舒服。

单元变大这点我非常赞同,但太大的单元不好维护,这就又走回了测试拆分的路上。 deptest 也的确像是分组的解决方案,当时其实想写 nose 的 plugin 的,看了下 nose 的代码发现代码挺复杂的,一言不合就自己从头来写了,其实功能差了 nose 很多,只支持了我最常用的一些选项。写完才发现,一个测试框架要考虑的东西真的很多,不是随随便便就能重新造出轮子的😂
iyaozhen
2017-03-16 17:00:20 +08:00
卧槽,我也在做这个事情。。。
ershiyi
2019-04-25 19:42:24 +08:00
这个解决依赖关系的可以和 pytest 结合使用吗?可不可以指点一下呀
reorx
2019-04-29 19:22:40 +08:00
@ershiyi 最近也在思考这个问题,简单研究了一下 pytest 的插件机制,应该是可以做到的,等有空了会尝试做一下

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/283512

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX