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

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

4747 次点击
所在节点    Python
25 条回复
strahe
2016-06-04 22:13:17 +08:00
不错,稍后看看
JhZ7z587cYROBgVQ
2016-06-04 22:28:13 +08:00
有个问题,对于登录场景,输入用户名密码后会种 cookie ,这个 cookie 能靠 depend_on 共享么
qdcanyun
2016-06-04 22:30:35 +08:00
比较好奇 lz 用的什么 Web 框架,一般来说 Web 框架都会提供一个 testing client 来模拟 http 请求,来在 http 层面之上测试你的 Web Application ,所以目前也没遇到过 lz 说的 「最终其实只是测试了 HTTP 服务是否工作正常,然而 HTTP 服务又不是你写的代码」这种情况
janxin
2016-06-04 22:44:44 +08:00
这个方案看起来不错
reorx
2016-06-04 23:03:20 +08:00
@qdcanyun 是的,好的 web 框架都会有方便的测试集成方案,但对 http 服务写测试代码是一个通用需求,不应该强依赖于 web 框架,所以我才想单独解决这个问题。目前我的测试对象是一个 go 写的服务…
reorx
2016-06-04 23:07:06 +08:00
@jason0916 理论上可以的,让需要 cookie 的测试 depend_on 获得 cookie 的测试,把 cookie 取出来向下传递。如果你的 http client 用的是 requests ,甚至可以用它的 session 功能来方便地发出带 cookie 的请求
LXJ
2016-06-04 23:27:11 +08:00
上面实现的功能感觉 Pytest 已经提供了一个类似的的机制了: https://pytest.org/latest/fixture.html 而且控制依赖的粒度可以做到很细。
reorx
2016-06-05 10:24:02 +08:00
@LXJ 不一样的, fixture 提供的是 setup teardown 的进化版,方便复用的依赖注入,但 fixture 本身不是测试,而且也无法控制测试的执行顺序。 deptest 也会支持 setup teardown 函数,但 depend_on 的核心是对测试本身的控制,而不是其所需参数的初始化过程。
JhZ7z587cYROBgVQ
2016-06-05 10:43:19 +08:00
@reorx 额,我去看了下 example ,还是不很明白怎么共享返回值,楼主能给个例子么? with_return = True 以后怎么取返回值呢?然后多用例返回值共享的话会不会有命名冲突?有可能有这么一个场景哈,我要测一个下单功能,首先我要登录,然后我要获取菜单,依赖登录的 cookie 和菜单接口的返回值选取第一个可点菜品下单。
reorx
2016-06-05 10:58:21 +08:00
@jason0916 稍等呀,一会有电脑了给你写这个场景的例子

先简单解释下取返回值的问题, with_return 为 True ,则 depend_on 的函数的返回值会再调用时传给被装饰的函数,所以需要定义一个参数来接收。文章中的例子有点小问题, test_put_post 依赖的 test_get_post 忘了写出来,它是会把获取到的条目返回的…

如果依赖多个函数的返回值,会按 depend_on 的先后顺序传递,靠近被装饰函数的会先传递,所以有几个 with_return 就定义几个参数就对了
JhZ7z587cYROBgVQ
2016-06-05 11:16:05 +08:00
原来是这样,我还在想该怎么接受返回值,我去看下 depend_on 的实现
reorx
2016-06-05 11:35:03 +08:00
JhZ7z587cYROBgVQ
2016-06-05 11:51:49 +08:00
Thanks ,感觉很不错啊
JhZ7z587cYROBgVQ
2016-06-05 12:04:39 +08:00
不过对于是否使用存储过程来进行接口测试这个点我还是抱有疑问,如果要做数据校验,比如说这个场景哈:我知道数据库里今天可下单菜品有四个,分别是 blabla ,然后我要检测可下单的菜品是否全部由接口正常返回,就是说不仅校验数量,还要校验菜品名,菜品 id 什么的。

这个的话在自动化测试中由于每天菜品可能不同(就是在 alpha , beta 测试环境下菜品也是有失效时间的,需要重新配置)如果由接口返回,可能就没办法去校验了,因为事先不知道正确值是什么(或者每次都去更新代码,但这样就太繁琐了)。

这点上我在想是不是还是用存储过程来做会比较好?我这个只是疑问哈,没有任何说 deptest 的设计有问题的意思,因为之前帮团队做自动化测试的引入,要交接的时候发现测试大多(全部)不会写代码,交接得很痛,这两天在想着能不能改下 pytest 写个工具,可以通过读取 json , yaml 之类的工具来实现接口测试的自动生成,这样测试就可以专心于写测试用例而不用再去学 python 写代码了
bingxx
2016-06-05 12:22:13 +08:00
推荐一个数据驱动测试的 python 库 DDT , http://ddt.readthedocs.io/en/latest/
JhZ7z587cYROBgVQ
2016-06-05 12:33:47 +08:00
@bingxx 谢谢哈,我理解哈,其实感觉核心矛盾在于是 “预设数据” 还是 “靠接口生成数据” 这个点上, DDT 的话我大概看了下 example , 实际上还是预设数据的一种手段,存储过程也是预设过程的手段嘛
reorx
2016-06-05 13:47:56 +08:00
@jason0916 测试是一个很大的门类,其中的方法论和专业知识也是非常体系化的,要做得很精通不比开发容易。我不是专业的测试工程师,就说说作为开发的想法。

我觉得你提到的场景里其实有两个测试需求,一个是功能测试,一个是验收测试(名词不知道用对了没有,意会一下)。添加菜品、获取菜品、菜品下单这三个接口是否正常工作,返回数据是否满足一致性要求,这个是功能测试;今天后台添加了几个菜品,用户是否能获取到,这个是验收测试。前者是对系统功能的测试,不需要固定的预设数据,在测试的时候只要添加的菜品和获取到的是一致的就可以了,所以数据甚至都可以随机生成;而后者是对用户产品层面做出的直接反映,这个就要求获取到的数据必须是明确需求的那几个了。

两个测试的目的不同,因此方式方法也会不同。 pytest , deptest 这些都是面向程序,做功能测试的框架,用来解决验收测试的需求应该不大合适,你应该去寻找专门做验收测试的工具来完成这个事情。

另外,如果是想要自动生成测试代码,或者用配置文件的方式写测试,之前看到一个项目应该能满足这个需求: https://github.com/svanoort/pyresttest ,我自己也写过一个工具来做类似的事情 https://github.com/reorx/apibox/blob/master/apibox/testing.py 但是后来考虑到这样做会使功能受限于框架的语法和支持的字段,没有直接写测试函数灵活,这个项目就没有深入做下去了。
LXJ
2016-06-06 12:32:09 +08:00
@reorx 关于『但 fixture 本身不是测试,而且也无法控制测试的执行顺序』这句话,其实如果想确保 fixture 的东西都是正确的,也可以在里面写一些 assert 语句确保正确,只是它不叫 testcase 而已;而且单元测试里面,不同测试用例间理论上一开始假想可以单独运行的,并不受其他单元测试用例的影响,所以执顺序并非一个很重要特性。

纯想象性质猜想一下,如果采用 deptest 的写法,可能因为前面某个 testcase failed 了,导致后面的依赖它的测试也多 failed 了,开发者必须找到最底层的依赖,先 fix 了,再开始调试上层的测试是否正常;测试用例多了后,感觉也会带来额外的 debug 成本。
reorx
2016-06-06 14:31:33 +08:00
@LXJ 嗯,没错,其实这些观点我也很认同,如果代入到单元测试的思想里的话 :)

但是 deptest 本身就是想打破单元测试思想才开发的工具。因为「对测试用例的执行顺序有要求」这样的事情在很多场景下是存在的,所以我想实践一下专门去组织顺序和依赖的做法,可以看做是对单元测试之外的方法论的尝试,或许最终还是会回归的单元测试上,但至少也对这种想法做了验证。

关于 testcase failed 而影响 dependent 的执行,在我的想法里,互相有依赖的测试其实可以看做一个测试组,这个测试组是单元化的,依赖链上任何一个测试失败就代表这个组的失败,这时就可以直接去 fix 这个 bug (看做整个组的 bug )。而且因为依赖失败而没有执行的测试,其状态为 UNMET ,区别于 OK 和 FAILED ,在浏览结果的时候也是很清晰的。说白了 deptest 本质还是一种测试理念,不能套到单元测试中来理解。
reorx
2016-06-06 14:41:43 +08:00
@LXJ 另外我还是觉得 fixture 「可以在里面写一些 assert 语句确保正确,只是它不叫 testcase 而已」这样有问题。 fixture 就是 fixture ,如果它是测试的一种, pytest 就不会专门为其定义一个概念和称谓了。 fixture 也必须是执行测试的先决条件,如果 fixture 存在失败的可能,那么就要为这段代码写测试来进行检验。

还有, fixture 如果失败,在 pytest 中会使所有依赖 fixture 的测试函数变成 ERROR at setup ,显示一堆重复的报错信息,这也是不合理的设计,因为 fixture 都已经失败,这些测试函数本就不需要执行了,而不是强行执行进入 ERROR 的状态。

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

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

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

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

© 2021 V2EX