添加测试¶
现在我们将为模型和视图添加测试,并在新的 tests
包裹。在以后的测试中,确保它继续工作。
测试线束¶
这个项目通过一些测试和一个基本的工具来启动。它们位于 tests
项目顶层的包。把测试放在一个 tests
与应用程序包一起打包,特别是随着项目规模和复杂性的增加。一个有用的约定是应用程序中的每个模块在 tests
包裹。测试模块的名称与前缀相同 test_
.
线束包括以下设置:
pytest.ini
-基本控件pytest
配置,包括查找测试的位置。我们已经配置pytest
在应用程序包和tests
包裹。.coveragerc
-控制覆盖范围配置。在我们的设置中,它与pytest-cov
我们通过--cov
选项到pytest
命令。testing.ini
-镜子development.ini
和production.ini
包含用于执行测试套件的设置。最重要的是,它包含需要数据库的测试所使用的数据库连接信息。tests_require
在里面setup.py
-控制测试时安装的依赖项。当列表更改时,需要重新运行$VENV/bin/pip install -e ".[testing]"
以确保安装新的依赖项。tests/conftest.py
-在我们整个测试过程中都可以使用核心夹具。下面将更详细地解释这些固定装置。
会话范围测试夹具¶
app_settings
-设置dict
从testing.ini
通常被传递的文件pserve
应用程序的main
功能。dbengine
-初始化数据库。从已知状态开始测试套件的每次运行是很重要的,这个fixture负责适当地准备数据库。这包括删除任何现有的表,运行迁移,甚至可能将一些fixture数据加载到表中以便在测试中使用。app
- Pyramid WSGI应用程序,实现pyramid.interfaces.IRouter
接口。这通常用于功能测试。
每个测试夹具¶
tm
-Atransaction.TransactionManager
对象控制事务生命周期。一般来说,其他固定装置会连接到tm
fixture来控制它们的生命周期,并确保它们在测试结束时被中止。dbsession
-Asqlalchemy.orm.session.Session
对象连接到数据库。会话的作用域是tm
固定装置。任何更改都将在测试结束时中止。testapp
-Awebtest.TestApp
实例包装app
用于向应用程序发送请求并返回可检查的完整响应对象。这个testapp
能够改变请求环境以便dbsession
和tm
fixture被注入并由任何涉及的代码使用request.dbsession
和request.tm
. 这个testapp
维护一个cookiejar,因此它可以用于在请求之间共享状态,以及事务数据库连接。app_request
-Apyramid.request.Request
对象,该对象可用于更轻量级的测试,而不是完整的testapp
. 这个app_request
可以传递给需要一个完全功能的请求对象的视图函数和其他代码。dummy_request
-Apyramid.testing.DummyRequest
非常轻量的对象。这是一个很好的对象,可以传递给视图函数,因为它具有最小的副作用,因为它将是快速和简单的。dummy_config
-apyramid.config.Configurator
用作配置的对象dummy_request
。对于模拟配置(如路由和安全策略)非常有用。
修改固定装置¶
我们将对测试工具进行一些特定于应用程序的更改。为经常做的事情想出模式总是好的,这样可以避免很多样板。
使用CSRF令牌初始化cookiejar。记住我们的应用程序正在使用
pyramid.csrf.CookieCSRFStoragePolicy
.testapp.get_csrf_token()
-每个POST/PUT/DELETE/PATCH请求都必须包含当前的CSRF令牌,以向我们的应用程序证明客户端不是第三方。所以我们需要一种简单的方法来获取当前的CSRF令牌并将其添加到请求中。testapp.login(params)
-许多页面只有登录的用户才可以访问,所以我们需要一个简单的方法在测试开始时登录用户。
更新 tests/conftest.py
如下图所示,添加高亮显示的线条:
1import alembic
2import alembic.config
3import alembic.command
4import os
5from pyramid.paster import get_appsettings
6from pyramid.scripting import prepare
7from pyramid.testing import DummyRequest, testConfig
8import pytest
9import transaction
10from webob.cookies import Cookie
11import webtest
12
13from tutorial import main
14from tutorial import models
15from tutorial.models.meta import Base
16
17
18def pytest_addoption(parser):
19 parser.addoption('--ini', action='store', metavar='INI_FILE')
20
21@pytest.fixture(scope='session')
22def ini_file(request):
23 # potentially grab this path from a pytest option
24 return os.path.abspath(request.config.option.ini or 'testing.ini')
25
26@pytest.fixture(scope='session')
27def app_settings(ini_file):
28 return get_appsettings(ini_file)
29
30@pytest.fixture(scope='session')
31def dbengine(app_settings, ini_file):
32 engine = models.get_engine(app_settings)
33
34 alembic_cfg = alembic.config.Config(ini_file)
35 Base.metadata.drop_all(bind=engine)
36 alembic.command.stamp(alembic_cfg, None, purge=True)
37
38 # run migrations to initialize the database
39 # depending on how we want to initialize the database from scratch
40 # we could alternatively call:
41 # Base.metadata.create_all(bind=engine)
42 # alembic.command.stamp(alembic_cfg, "head")
43 alembic.command.upgrade(alembic_cfg, "head")
44
45 yield engine
46
47 Base.metadata.drop_all(bind=engine)
48 alembic.command.stamp(alembic_cfg, None, purge=True)
49
50@pytest.fixture(scope='session')
51def app(app_settings, dbengine):
52 return main({}, dbengine=dbengine, **app_settings)
53
54@pytest.fixture
55def tm():
56 tm = transaction.TransactionManager(explicit=True)
57 tm.begin()
58 tm.doom()
59
60 yield tm
61
62 tm.abort()
63
64@pytest.fixture
65def dbsession(app, tm):
66 session_factory = app.registry['dbsession_factory']
67 return models.get_tm_session(session_factory, tm)
68
69class TestApp(webtest.TestApp):
70 def get_cookie(self, name, default=None):
71 # webtest currently doesn't expose the unescaped cookie values
72 # so we're using webob to parse them for us
73 # see https://github.com/Pylons/webtest/issues/171
74 cookie = Cookie(' '.join(
75 '%s=%s' % (c.name, c.value)
76 for c in self.cookiejar
77 if c.name == name
78 ))
79 return next(
80 (m.value.decode('latin-1') for m in cookie.values()),
81 default,
82 )
83
84 def get_csrf_token(self):
85 """
86 Convenience method to get the current CSRF token.
87
88 This value must be passed to POST/PUT/DELETE requests in either the
89 "X-CSRF-Token" header or the "csrf_token" form value.
90
91 testapp.post(..., headers={'X-CSRF-Token': testapp.get_csrf_token()})
92
93 or
94
95 testapp.post(..., {'csrf_token': testapp.get_csrf_token()})
96
97 """
98 return self.get_cookie('csrf_token')
99
100 def login(self, params, status=303, **kw):
101 """ Convenience method to login the client."""
102 body = dict(csrf_token=self.get_csrf_token())
103 body.update(params)
104 return self.post('/login', body, **kw)
105
106@pytest.fixture
107def testapp(app, tm, dbsession):
108 # override request.dbsession and request.tm with our own
109 # externally-controlled values that are shared across requests but aborted
110 # at the end
111 testapp = TestApp(app, extra_environ={
112 'HTTP_HOST': 'example.com',
113 'tm.active': True,
114 'tm.manager': tm,
115 'app.dbsession': dbsession,
116 })
117
118 # initialize a csrf token instead of running an initial request to get one
119 # from the actual app - this only works using the CookieCSRFStoragePolicy
120 testapp.set_cookie('csrf_token', 'dummy_csrf_token')
121
122 return testapp
123
124@pytest.fixture
125def app_request(app, tm, dbsession):
126 """
127 A real request.
128
129 This request is almost identical to a real request but it has some
130 drawbacks in tests as it's harder to mock data and is heavier.
131
132 """
133 with prepare(registry=app.registry) as env:
134 request = env['request']
135 request.host = 'example.com'
136
137 # without this, request.dbsession will be joined to the same transaction
138 # manager but it will be using a different sqlalchemy.orm.Session using
139 # a separate database transaction
140 request.dbsession = dbsession
141 request.tm = tm
142
143 yield request
144
145@pytest.fixture
146def dummy_request(tm, dbsession):
147 """
148 A lightweight dummy request.
149
150 This request is ultra-lightweight and should be used only when the request
151 itself is not a large focus in the call-stack. It is much easier to mock
152 and control side-effects using this object, however:
153
154 - It does not have request extensions applied.
155 - Threadlocals are not properly pushed.
156
157 """
158 request = DummyRequest()
159 request.host = 'example.com'
160 request.dbsession = dbsession
161 request.tm = tm
162
163 return request
164
165@pytest.fixture
166def dummy_config(dummy_request):
167 """
168 A dummy :class:`pyramid.config.Configurator` object. This allows for
169 mock configuration, including configuration for ``dummy_request``, as well
170 as pushing the appropriate threadlocals.
171
172 """
173 with testConfig(request=dummy_request) as config:
174 yield config
单元测试¶
我们可以在代码库中测试各个api,以确保它们满足应用程序其余部分所期望的契约。例如,我们将测试添加到 tutorial.models.User
对象。
创造 tests/test_user_model.py
如下所示:
1from tutorial import models
2
3
4def test_password_hash_saved():
5 user = models.User(name='foo', role='bar')
6 assert user.password_hash is None
7
8 user.set_password('secret')
9 assert user.password_hash is not None
10
11def test_password_hash_not_set():
12 user = models.User(name='foo', role='bar')
13 assert not user.check_password('secret')
14
15def test_correct_password():
16 user = models.User(name='foo', role='bar')
17 user.set_password('secret')
18 assert user.check_password('secret')
19
20def test_incorrect_password():
21 user = models.User(name='foo', role='bar')
22 user.set_password('secret')
23 assert not user.check_password('incorrect')
集成测试¶
我们可以直接执行视图代码,绕过 Pyramid 并且只测试我们编写的代码。这些测试使用虚拟请求,我们将适当准备这些请求来设置每个视图预期的条件,例如将虚拟数据添加到会话。我们将使用 dummy_config
配置必要的路由,并将安全策略设置为 pyramid.testing.DummySecurityPolicy
嘲弄 dummy_request.identity
。
更新 tests/test_views.py
如下所示:
1from pyramid.testing import DummySecurityPolicy
2
3from tutorial import models
4
5
6def makeUser(name, role):
7 return models.User(name=name, role=role)
8
9
10def setUser(config, user):
11 config.set_security_policy(
12 DummySecurityPolicy(identity=user)
13 )
14
15def makePage(name, data, creator):
16 return models.Page(name=name, data=data, creator=creator)
17
18class Test_view_wiki:
19 def _callFUT(self, request):
20 from tutorial.views.default import view_wiki
21 return view_wiki(request)
22
23 def _addRoutes(self, config):
24 config.add_route('view_page', '/{pagename}')
25
26 def test_it(self, dummy_config, dummy_request):
27 self._addRoutes(dummy_config)
28 response = self._callFUT(dummy_request)
29 assert response.location == 'http://example.com/FrontPage'
30
31class Test_view_page:
32 def _callFUT(self, request):
33 from tutorial.views.default import view_page
34 return view_page(request)
35
36 def _makeContext(self, page):
37 from tutorial.routes import PageResource
38 return PageResource(page)
39
40 def _addRoutes(self, config):
41 config.add_route('edit_page', '/{pagename}/edit_page')
42 config.add_route('add_page', '/add_page/{pagename}')
43 config.add_route('view_page', '/{pagename}')
44
45 def test_it(self, dummy_config, dummy_request, dbsession):
46 # add a page to the db
47 user = makeUser('foo', 'editor')
48 page = makePage('IDoExist', 'Hello CruelWorld IDoExist', user)
49 dbsession.add_all([page, user])
50
51 # create a request asking for the page we've created
52 self._addRoutes(dummy_config)
53 dummy_request.context = self._makeContext(page)
54
55 # call the view we're testing and check its behavior
56 info = self._callFUT(dummy_request)
57 assert info['page'] is page
58 assert info['content'] == (
59 '<div class="document">\n'
60 '<p>Hello <a href="http://example.com/add_page/CruelWorld">'
61 'CruelWorld</a> '
62 '<a href="http://example.com/IDoExist">'
63 'IDoExist</a>'
64 '</p>\n</div>\n'
65 )
66 assert info['edit_url'] == 'http://example.com/IDoExist/edit_page'
67
68class Test_add_page:
69 def _callFUT(self, request):
70 from tutorial.views.default import add_page
71 return add_page(request)
72
73 def _makeContext(self, pagename):
74 from tutorial.routes import NewPage
75 return NewPage(pagename)
76
77 def _addRoutes(self, config):
78 config.add_route('add_page', '/add_page/{pagename}')
79 config.add_route('view_page', '/{pagename}')
80
81 def test_get(self, dummy_config, dummy_request, dbsession):
82 setUser(dummy_config, makeUser('foo', 'editor'))
83 self._addRoutes(dummy_config)
84 dummy_request.context = self._makeContext('AnotherPage')
85 info = self._callFUT(dummy_request)
86 assert info['pagedata'] == ''
87 assert info['save_url'] == 'http://example.com/add_page/AnotherPage'
88
89 def test_submit_works(self, dummy_config, dummy_request, dbsession):
90 dummy_request.method = 'POST'
91 dummy_request.POST['body'] = 'Hello yo!'
92 dummy_request.context = self._makeContext('AnotherPage')
93 setUser(dummy_config, makeUser('foo', 'editor'))
94 self._addRoutes(dummy_config)
95 self._callFUT(dummy_request)
96 page = (
97 dbsession.query(models.Page)
98 .filter_by(name='AnotherPage')
99 .one()
100 )
101 assert page.data == 'Hello yo!'
102
103class Test_edit_page:
104 def _callFUT(self, request):
105 from tutorial.views.default import edit_page
106 return edit_page(request)
107
108 def _makeContext(self, page):
109 from tutorial.routes import PageResource
110 return PageResource(page)
111
112 def _addRoutes(self, config):
113 config.add_route('edit_page', '/{pagename}/edit_page')
114 config.add_route('view_page', '/{pagename}')
115
116 def test_get(self, dummy_config, dummy_request, dbsession):
117 user = makeUser('foo', 'editor')
118 page = makePage('abc', 'hello', user)
119 dbsession.add_all([page, user])
120
121 self._addRoutes(dummy_config)
122 dummy_request.context = self._makeContext(page)
123 info = self._callFUT(dummy_request)
124 assert info['pagename'] == 'abc'
125 assert info['save_url'] == 'http://example.com/abc/edit_page'
126
127 def test_submit_works(self, dummy_config, dummy_request, dbsession):
128 user = makeUser('foo', 'editor')
129 page = makePage('abc', 'hello', user)
130 dbsession.add_all([page, user])
131
132 self._addRoutes(dummy_config)
133 dummy_request.method = 'POST'
134 dummy_request.POST['body'] = 'Hello yo!'
135 setUser(dummy_config, user)
136 dummy_request.context = self._makeContext(page)
137 response = self._callFUT(dummy_request)
138 assert response.location == 'http://example.com/abc'
139 assert page.data == 'Hello yo!'
功能测试¶
我们将测试整个应用程序,包括单元测试和集成测试中未测试的安全方面,如登录、注销、检查 basic
用户不能编辑它没有创建的页面,但是 editor
用户可以,等等。
更新 tests/test_functional.py
如下所示:
1import pytest
2import transaction
3
4from tutorial import models
5
6
7basic_login = dict(login='basic', password='basic')
8editor_login = dict(login='editor', password='editor')
9
10@pytest.fixture(scope='session', autouse=True)
11def dummy_data(app):
12 """
13 Add some dummy data to the database.
14
15 Note that this is a session fixture that commits data to the database.
16 Think about it similarly to running the ``initialize_db`` script at the
17 start of the test suite.
18
19 This data should not conflict with any other data added throughout the
20 test suite or there will be issues - so be careful with this pattern!
21
22 """
23 tm = transaction.TransactionManager(explicit=True)
24 with tm:
25 dbsession = models.get_tm_session(app.registry['dbsession_factory'], tm)
26 editor = models.User(name='editor', role='editor')
27 editor.set_password('editor')
28 basic = models.User(name='basic', role='basic')
29 basic.set_password('basic')
30 page1 = models.Page(name='FrontPage', data='This is the front page')
31 page1.creator = editor
32 page2 = models.Page(name='BackPage', data='This is the back page')
33 page2.creator = basic
34 dbsession.add_all([basic, editor, page1, page2])
35
36def test_root(testapp):
37 res = testapp.get('/', status=303)
38 assert res.location == 'http://example.com/FrontPage'
39
40def test_FrontPage(testapp):
41 res = testapp.get('/FrontPage', status=200)
42 assert b'FrontPage' in res.body
43
44def test_missing_page(testapp):
45 res = testapp.get('/SomePage', status=404)
46 assert b'404' in res.body
47
48def test_successful_log_in(testapp):
49 params = dict(
50 **basic_login,
51 csrf_token=testapp.get_csrf_token(),
52 )
53 res = testapp.post('/login', params, status=303)
54 assert res.location == 'http://example.com/'
55
56def test_successful_log_with_next(testapp):
57 params = dict(
58 **basic_login,
59 next='WikiPage',
60 csrf_token=testapp.get_csrf_token(),
61 )
62 res = testapp.post('/login', params, status=303)
63 assert res.location == 'http://example.com/WikiPage'
64
65def test_failed_log_in(testapp):
66 params = dict(
67 login='basic',
68 password='incorrect',
69 csrf_token=testapp.get_csrf_token(),
70 )
71 res = testapp.post('/login', params, status=400)
72 assert b'login' in res.body
73
74def test_logout_link_present_when_logged_in(testapp):
75 testapp.login(basic_login)
76 res = testapp.get('/FrontPage', status=200)
77 assert b'Logout' in res.body
78
79def test_logout_link_not_present_after_logged_out(testapp):
80 testapp.login(basic_login)
81 testapp.get('/FrontPage', status=200)
82 params = dict(csrf_token=testapp.get_csrf_token())
83 res = testapp.post('/logout', params, status=303)
84 assert b'Logout' not in res.body
85
86def test_anonymous_user_cannot_edit(testapp):
87 res = testapp.get('/FrontPage/edit_page', status=303).follow()
88 assert b'Login' in res.body
89
90def test_anonymous_user_cannot_add(testapp):
91 res = testapp.get('/add_page/NewPage', status=303).follow()
92 assert b'Login' in res.body
93
94def test_basic_user_cannot_edit_front(testapp):
95 testapp.login(basic_login)
96 res = testapp.get('/FrontPage/edit_page', status=403)
97 assert b'403' in res.body
98
99def test_basic_user_can_edit_back(testapp):
100 testapp.login(basic_login)
101 res = testapp.get('/BackPage/edit_page', status=200)
102 assert b'Editing' in res.body
103
104def test_basic_user_can_add(testapp):
105 testapp.login(basic_login)
106 res = testapp.get('/add_page/NewPage', status=200)
107 assert b'Editing' in res.body
108
109def test_editors_member_user_can_edit(testapp):
110 testapp.login(editor_login)
111 res = testapp.get('/FrontPage/edit_page', status=200)
112 assert b'Editing' in res.body
113
114def test_editors_member_user_can_add(testapp):
115 testapp.login(editor_login)
116 res = testapp.get('/add_page/NewPage', status=200)
117 assert b'Editing' in res.body
118
119def test_editors_member_user_can_view(testapp):
120 testapp.login(editor_login)
121 res = testapp.get('/FrontPage', status=200)
122 assert b'FrontPage' in res.body
123
124def test_redirect_to_edit_for_existing_page(testapp):
125 testapp.login(editor_login)
126 res = testapp.get('/add_page/FrontPage', status=303)
127 assert b'FrontPage' in res.body
运行测试¶
在UNIX上:
$VENV/bin/pytest -q
在Windows上:
%VENV%\Scripts\pytest -q
预期结果应如下所示:
........................... [100%]
27 passed in 6.91s