添加测试

现在我们将为模型和视图添加测试,并在新的 tests 包裹。在以后的测试中,确保它继续工作。

测试线束

这个项目通过一些测试和一个基本的工具来启动。它们位于 tests 项目顶层的包。把测试放在一个 tests 与应用程序包一起打包,特别是随着项目规模和复杂性的增加。一个有用的约定是应用程序中的每个模块在 tests 包裹。测试模块的名称与前缀相同 test_ .

线束包括以下设置:

  • pytest.ini -基本控件 pytest 配置,包括查找测试的位置。我们已经配置 pytest 在应用程序包和 tests 包裹。

  • .coveragerc -控制覆盖范围配置。在我们的设置中,它与 pytest-cov 我们通过 --cov 选项到 pytest 命令。

  • testing.ini -镜子 development.iniproduction.ini 包含用于执行测试套件的设置。最重要的是,它包含需要数据库的测试所使用的数据库连接信息。

  • tests_require 在里面 setup.py -控制测试时安装的依赖项。当列表更改时,需要重新运行 $VENV/bin/pip install -e ".[testing]" 以确保安装新的依赖项。

  • tests/conftest.py -在我们整个测试过程中都可以使用核心夹具。下面将更详细地解释这些固定装置。

会话范围测试夹具

  • app_settings -设置 dicttesting.ini 通常被传递的文件 pserve 应用程序的 main 功能。

  • dbengine -初始化数据库。从已知状态开始测试套件的每次运行是很重要的,这个fixture负责适当地准备数据库。这包括删除任何现有的表,运行迁移,甚至可能将一些fixture数据加载到表中以便在测试中使用。

  • app - Pyramid WSGI应用程序,实现 pyramid.interfaces.IRouter 接口。这通常用于功能测试。

每个测试夹具

  • tm -A transaction.TransactionManager 对象控制事务生命周期。一般来说,其他固定装置会连接到 tm fixture来控制它们的生命周期,并确保它们在测试结束时被中止。

  • dbsession -A sqlalchemy.orm.session.Session 对象连接到数据库。会话的作用域是 tm 固定装置。任何更改都将在测试结束时中止。

  • testapp -A webtest.TestApp 实例包装 app 用于向应用程序发送请求并返回可检查的完整响应对象。这个 testapp 能够改变请求环境以便 dbsessiontm fixture被注入并由任何涉及的代码使用 request.dbsessionrequest.tm . 这个 testapp 维护一个cookiejar,因此它可以用于在请求之间共享状态,以及事务数据库连接。

  • app_request -A pyramid.request.Request 对象,该对象可用于更轻量级的测试,而不是完整的 testapp . 这个 app_request 可以传递给需要一个完全功能的请求对象的视图函数和其他代码。

  • dummy_request -A pyramid.testing.DummyRequest 非常轻量的对象。这是一个很好的对象,可以传递给视图函数,因为它具有最小的副作用,因为它将是快速和简单的。

  • dummy_config -a pyramid.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