vlambda博客
学习文章列表

一个使用Flask-Login登录后的Pytest测试用例的坑

前言

最近写一个项目,用到了Flask-Login实现用户登录状态。项目最后需要编写测试用例,但是测试代码卡在确认用户登录状态的部分就写不下去了,研究了好久才找到原因,分享一下pytest测试Flask应用上的一个坑儿.

一个最基础的例子

为了演示这个问题,我写了一个小型应用,全部代码可以从dongweiming/mp获取。

这里只列出部分核心代码。首先是测试部分代码:

 
   
   
 
  1. from app import db, User



  2. def test_user_info(client):

  3. response = client.get('/api/user/info')

  4. assert response.json == {}



  5. def test_login(client):

  6. db.session.add(User(name='user1'))

  7. db.session.commit()

  8. response = client.post('/api/login', json={

  9. 'id': '1'})

  10. assert response.json['id'] == 1



  11. def test_after_login(client):

  12. response = client.post('/api/login', json={

  13. 'id': 1})

  14. response = client.get('/api/user/info')

  15. assert response.json == {}

其中 test_login里面给数据库里添加了一个用户记录,这是因为前面没有注册相关的代码。这么写是因为本来是一个专门用例测试注册的,里面有添加用户的逻辑,为了精简代码就在这里先添加用户再测试这个用户的登录了。当然,这么写也是为了重现问题,最后总结时会提到。

这部分测试代码,第一个函数中因为没有登录,所以获取不到用户信息;第二个函数中测试了登录功能,当请求结束后会返回用户信息(包含id=1);最后一部分也就是出问题的部分,本来是测试登陆后的逻辑,按照设想,第一步登录,然后在请求用户信息应该可以拿到对应用户信息了。

但事实上, response.json的返回结果还是 {}。也就是说,前面先请求登录API /api/login的那部分就没生效。

初步排查

因为Flask-Login的逻辑主要是在登录后给浏览器设置httponly为True的Cookie,这样下次请求时候带着这个正确的Cookie后端就认为此时已经登录。所以我一开始认为是client请求登录API后并没有设置对应的Cookie,但事实上:

  1. 第一个response, response.headers["Set-Cookie"]里表示确实已经设置过Cookie

  2. 第二个response, response.request.headers里确实有这个Cookie,那么请求是正确的,但是API没有返回正确的用户信息

所以先确认问题不在这个client实例上。

深入排查

接着看一下 conftest.py的代码:

 
   
   
 
  1. import os


  2. import pytest


  3. from app import app as _app, db



  4. @pytest.fixture

  5. def app():

  6. _app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///test.db'


  7. with _app.app_context():

  8. db.create_all()

  9. yield _app


  10. os.remove('test.db')



  11. @pytest.fixture

  12. def client(app):

  13. return app.test_client()

conftest.py用来定义被测试用例们共享的fixture、钩子等设置。例如上面测试用例函数中的参数 client就是一个fixture,它就是这里定义的。

fixture是一个函数,可以用作初始化或者返回了某种数据,所有测试都可以访问它,这样就达到了代码重用和逻辑隔离。例如上面的2个fixture:

  1. app。包含了初始化(重建数据库表)设置的Flask实例,通过yield迭代出来,不过最后还会做清理工作(删除SQLite文件)`

  2. client。使用了app这个fixture,返回了测试客户端,这是Flask自带的类。

一眼看去这部分代码并没有问题。既然是测试,当然不能用假数据也不要做 monkeypatch,就应该真实的创建数据库,操作数据。

我在IPython里面试验,是符合预期的:

 
   
   
 
  1. In [1]: from flask.testing import FlaskClient


  2. In [2]: client = FlaskClient(app)


  3. In [3]: response = client.post(

  4. ...: "/api/login", json={'id': '1'}

  5. ...: )


  6. In [4]: response = client.get("/api/user/info")


  7. In [5]: response.json

  8. Out[5]:

  9. {'id': 1}

这不会是一个BUG吧,哈哈!不过等我阅读了对应源码,确认过程虽然和执行pytest的方式下有一些逻辑差别,但是并不影响预期结果。

找到灵感

困扰了很久,网上一顿搜索,最终在pytest-flask的一个issue下找到相关讨论,不过按照我的理解其实可以过滤大部分回答。有些人说自己的解决方案可以工作我也不是很能理解,可能是代码逻辑有区别。

不过这个讨论中有2个人说的内容给我了启发,首先是这个:

@vitalk: the client fixture pushes a new request context, so the 1st example doesn’t work because the current_user is anonymous.

我开始觉得这个问题是Flask设计的请求上下文或者应用上下文作怪了。这个作者的意思是client每次都会使用一个新的请求上下文,啥也不说了,先试验一下,加个print

 
   
   
 
  1. @pytest.fixture

  2. def client(app):

  3. print('Hit')

  4. return app.test_client()

看看它在测试过程中被调用的频率:

 
   
   
 
  1. pytest -s # pytest默认会捕捉各种输出,除非测试用例失败否则都过滤掉了,通过-s可以关闭捕捉(等于--capture=no)

  2. ...

  3. collected 3 items


  4. tests/test_api.py Hit

  5. .Hit

  6. .Hit

  7. .


  8. ...

这里省略了大部分无关输出。可以看到Hit出现了三次,也就是每个测试用例出现了1次,但是这也说明在 test_after_login只出现了一次,所以不是它的问题。那么我开始重点怀疑第一个fixture有什么问题了,不过以我当时的知识还不知道是什么,各种尝试去hack代码也没有让测试符合预期

后来又回来看这个issue,看到之前被一直忽略的最后一个回复:

@jab: To work around this, I ended up creating my own client fixture with a longer scope (e.g. "module" or "session" both work) rather than using pytest-flask, which doesn't currently allow customization of its client fixture's scope. In case this helps anyone else!

一开始我并没懂他的意思。仔细看他说这不是 pytest-flask的问题,而是修改fixture的应用范围到 module或者 session就可以了。按着关键词搜了一下,果然可以(不过我是在app上使用了更大范围):

 
   
   
 
  1. @pytest.fixture(scope='session')

  2. def app():

  3. _app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///test.db'


  4. with _app.app_context():

  5. db.create_all()

  6. yield _app


  7. os.remove('test.db')

也就是给 pytest.fixture添加参数scope。这样测试就通过了,开心😄。

思考

最后阅读了 pytest.fixture的源码,才理解为什么。分享一下, pytest.fixture的scope参数支持5个选项,表示不同的范围:

  • function。默认值,每个测试功能运行一次,函数结束后清理。←注意这句哈

  • class。每个测试类运行一次。

  • packge。每个包运行一次。

  • module。每个模块运行一次。

  • session。每个会话运行一次。

所以问题就是app这个fixture在每个测试函数结束后就会被调用:

 
   
   
 
  1. @pytest.fixture

  2. def app():

  3. print('Hit')

  4. _app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///test.db'


  5. with _app.app_context():

  6. db.create_all()

  7. yield _app


  8. os.remove('test.db')

这次打印依然可以看到3次 Hit:

 
   
   
 
  1. pytest -s

  2. ...

  3. collected 3 items


  4. tests/test_api.py Hit

  5. .Hit

  6. .Hit

  7. .

我恍然大悟,这就是问题,和Flask上下文无关,而是和在fixture里做数据清理有关。注意看代码,每次都会在结束后删掉数据库文件,下次又会重新创建数据库,对于测试用例来说,在 test_login添加了新用户,但是等到 test_after_login里重新创建数据库早没数据了。所以找不到这个用户。这样改也是可以的:

 
   
   
 
  1. def test_after_login(client):

  2. db.session.add(User(name='user1'))

  3. db.session.commit()

  4. response = client.post('/api/login', json={

  5. 'id': 1})

  6. response = client.get('/api/user/info')

  7. assert response.json['id'] == 1

也就是说,默认情况下(scope='function'),数据在一个测试用例中才共用,所以得扩大fixture的范围。

延伸阅读

  1. flasklogin/loginmanager.py#L327-L329

  2. flask/testing.py#L120-L173

  3. _pytest/fixtures.py#L135-L144

  4. pytest-flask的一个issue

  5. 一个完整的例子