diff options
| author | Michael Merickel <michael@merickel.org> | 2020-01-07 01:12:58 -0600 |
|---|---|---|
| committer | Michael Merickel <michael@merickel.org> | 2020-01-07 01:12:58 -0600 |
| commit | 3c06a69e753e3e0cda8d1c9a6a1db9c55f7843ea (patch) | |
| tree | 348c99dfcd9c49efcaf9eb7e40658d1b1b3397ba /docs/tutorials | |
| parent | 9629dcfa579e5c78a285e26e42dcff2b1b2df8b7 (diff) | |
| download | pyramid-3c06a69e753e3e0cda8d1c9a6a1db9c55f7843ea.tar.gz pyramid-3c06a69e753e3e0cda8d1c9a6a1db9c55f7843ea.tar.bz2 pyramid-3c06a69e753e3e0cda8d1c9a6a1db9c55f7843ea.zip | |
revamp the test suite and explain the fixtures
Diffstat (limited to 'docs/tutorials')
29 files changed, 700 insertions, 488 deletions
diff --git a/docs/tutorials/wiki2/basiclayout.rst b/docs/tutorials/wiki2/basiclayout.rst index e8bc4c5a9..ef78e052b 100644 --- a/docs/tutorials/wiki2/basiclayout.rst +++ b/docs/tutorials/wiki2/basiclayout.rst @@ -348,3 +348,9 @@ code in our stock application. The ``Index`` import and the ``Index`` object creation in ``mymodel.py`` is not required for this tutorial, and will be removed in the next step. + +Tests +----- + +The project contains a basic structure for a test suite using ``pytest``. +The structure is covered later in :ref:`wiki2_adding_tests`. diff --git a/docs/tutorials/wiki2/src/authentication/testing.ini b/docs/tutorials/wiki2/src/authentication/testing.ini index 07ec6550e..d3c601f16 100644 --- a/docs/tutorials/wiki2/src/authentication/testing.ini +++ b/docs/tutorials/wiki2/src/authentication/testing.ini @@ -33,7 +33,7 @@ file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s [server:main] use = egg:waitress#main -listen = *:6543 +listen = localhost:6543 ### # logging configuration @@ -50,11 +50,11 @@ keys = console keys = generic [logger_root] -level = WARN +level = INFO handlers = console [logger_tutorial] -level = WARN +level = DEBUG handlers = qualname = tutorial diff --git a/docs/tutorials/wiki2/src/authorization/testing.ini b/docs/tutorials/wiki2/src/authorization/testing.ini index 07ec6550e..d3c601f16 100644 --- a/docs/tutorials/wiki2/src/authorization/testing.ini +++ b/docs/tutorials/wiki2/src/authorization/testing.ini @@ -33,7 +33,7 @@ file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s [server:main] use = egg:waitress#main -listen = *:6543 +listen = localhost:6543 ### # logging configuration @@ -50,11 +50,11 @@ keys = console keys = generic [logger_root] -level = WARN +level = INFO handlers = console [logger_tutorial] -level = WARN +level = DEBUG handlers = qualname = tutorial diff --git a/docs/tutorials/wiki2/src/basiclayout/testing.ini b/docs/tutorials/wiki2/src/basiclayout/testing.ini index 85e5e1ae9..5caa1a8dc 100644 --- a/docs/tutorials/wiki2/src/basiclayout/testing.ini +++ b/docs/tutorials/wiki2/src/basiclayout/testing.ini @@ -31,7 +31,7 @@ file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s [server:main] use = egg:waitress#main -listen = *:6543 +listen = localhost:6543 ### # logging configuration @@ -48,11 +48,11 @@ keys = console keys = generic [logger_root] -level = WARN +level = INFO handlers = console [logger_tutorial] -level = WARN +level = DEBUG handlers = qualname = tutorial diff --git a/docs/tutorials/wiki2/src/installation/testing.ini b/docs/tutorials/wiki2/src/installation/testing.ini index 85e5e1ae9..5caa1a8dc 100644 --- a/docs/tutorials/wiki2/src/installation/testing.ini +++ b/docs/tutorials/wiki2/src/installation/testing.ini @@ -31,7 +31,7 @@ file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s [server:main] use = egg:waitress#main -listen = *:6543 +listen = localhost:6543 ### # logging configuration @@ -48,11 +48,11 @@ keys = console keys = generic [logger_root] -level = WARN +level = INFO handlers = console [logger_tutorial] -level = WARN +level = DEBUG handlers = qualname = tutorial diff --git a/docs/tutorials/wiki2/src/models/testing.ini b/docs/tutorials/wiki2/src/models/testing.ini index 85e5e1ae9..5caa1a8dc 100644 --- a/docs/tutorials/wiki2/src/models/testing.ini +++ b/docs/tutorials/wiki2/src/models/testing.ini @@ -31,7 +31,7 @@ file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s [server:main] use = egg:waitress#main -listen = *:6543 +listen = localhost:6543 ### # logging configuration @@ -48,11 +48,11 @@ keys = console keys = generic [logger_root] -level = WARN +level = INFO handlers = console [logger_tutorial] -level = WARN +level = DEBUG handlers = qualname = tutorial diff --git a/docs/tutorials/wiki2/src/tests/.gitignore b/docs/tutorials/wiki2/src/tests/.gitignore index 1853d983c..c612e59f2 100644 --- a/docs/tutorials/wiki2/src/tests/.gitignore +++ b/docs/tutorials/wiki2/src/tests/.gitignore @@ -19,3 +19,4 @@ Data.fs* .DS_Store coverage test +*.sqlite diff --git a/docs/tutorials/wiki2/src/tests/setup.py b/docs/tutorials/wiki2/src/tests/setup.py index 500c5e599..12eabaff2 100644 --- a/docs/tutorials/wiki2/src/tests/setup.py +++ b/docs/tutorials/wiki2/src/tests/setup.py @@ -20,8 +20,8 @@ requires = [ 'pyramid_tm', 'SQLAlchemy', 'transaction', - 'zope.sqlalchemy', 'waitress', + 'zope.sqlalchemy', ] tests_require = [ diff --git a/docs/tutorials/wiki2/src/tests/testing.ini b/docs/tutorials/wiki2/src/tests/testing.ini new file mode 100644 index 000000000..d3c601f16 --- /dev/null +++ b/docs/tutorials/wiki2/src/tests/testing.ini @@ -0,0 +1,81 @@ +### +# app configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html +### + +[app:main] +use = egg:tutorial + +pyramid.reload_templates = false +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en + +sqlalchemy.url = sqlite:///%(here)s/testing.sqlite + +retry.attempts = 3 + +auth.secret = test-seekrit + +[pshell] +setup = tutorial.pshell.setup + +### +# wsgi server configuration +### + +[alembic] +# path to migration scripts +script_location = tutorial/alembic +file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s +# file_template = %%(rev)s_%%(slug)s + +[server:main] +use = egg:waitress#main +listen = localhost:6543 + +### +# logging configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### + +[loggers] +keys = root, tutorial, sqlalchemy, alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console + +[logger_tutorial] +level = DEBUG +handlers = +qualname = tutorial + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[logger_alembic] +level = WARN +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s diff --git a/docs/tutorials/wiki2/src/tests/tests/conftest.py b/docs/tutorials/wiki2/src/tests/tests/conftest.py new file mode 100644 index 000000000..094bc06f1 --- /dev/null +++ b/docs/tutorials/wiki2/src/tests/tests/conftest.py @@ -0,0 +1,165 @@ +import alembic +import alembic.config +import alembic.command +import os +from pyramid.paster import get_appsettings +from pyramid.scripting import prepare +from pyramid.testing import DummyRequest +import pytest +import transaction +from webob.cookies import Cookie +import webtest + +from tutorial import main +from tutorial import models +from tutorial.models.meta import Base + + +def pytest_addoption(parser): + parser.addoption('--ini', action='store', metavar='INI_FILE') + +@pytest.fixture(scope='session') +def ini_file(request): + # potentially grab this path from a pytest option + return os.path.abspath(request.config.option.ini or 'testing.ini') + +@pytest.fixture(scope='session') +def app_settings(ini_file): + return get_appsettings(ini_file) + +@pytest.fixture(scope='session') +def dbengine(app_settings, ini_file): + engine = models.get_engine(app_settings) + + alembic_cfg = alembic.config.Config(ini_file) + Base.metadata.drop_all(bind=engine) + alembic.command.stamp(alembic_cfg, None, purge=True) + + # run migrations to initialize the database + # depending on how we want to initialize the database from scratch + # we could alternatively call: + # Base.metadata.create_all(bind=engine) + alembic.command.upgrade(alembic_cfg, "head") + + yield engine + + Base.metadata.drop_all(bind=engine) + alembic.command.stamp(alembic_cfg, None, purge=True) + +@pytest.fixture(scope='session') +def app(app_settings, dbengine): + return main({}, dbengine=dbengine, **app_settings) + +@pytest.fixture +def tm(): + tm = transaction.TransactionManager(explicit=True) + tm.begin() + tm.doom() + + yield tm + + tm.abort() + +@pytest.fixture +def dbsession(app, tm): + session_factory = app.registry['dbsession_factory'] + return models.get_tm_session(session_factory, tm) + +class TestApp(webtest.TestApp): + def get_cookie(self, name, default=None): + # webtest currently doesn't expose the unescaped cookie values + # so we're using webob to parse them for us + # see https://github.com/Pylons/webtest/issues/171 + cookie = Cookie(' '.join( + '%s=%s' % (c.name, c.value) + for c in self.cookiejar + if c.name == name + )) + return next( + (m.value.decode('latin-1') for m in cookie.values()), + default, + ) + + def get_csrf_token(self): + """ + Convenience method to get the current CSRF token. + + This value must be passed to POST/PUT/DELETE requests in either the + "X-CSRF-Token" header or the "csrf_token" form value. + + testapp.post(..., headers={'X-CSRF-Token': testapp.get_csrf_token()}) + + or + + testapp.post(..., {'csrf_token': testapp.get_csrf_token()}) + + """ + return self.get_cookie('csrf_token') + + def login(self, params, status=303, **kw): + """ Convenience method to login the client.""" + body = dict(csrf_token=self.get_csrf_token()) + body.update(params) + return self.post('/login', body, **kw) + +@pytest.fixture +def testapp(app, tm, dbsession): + # override request.dbsession and request.tm with our own + # externally-controlled values that are shared across requests but aborted + # at the end + testapp = TestApp(app, extra_environ={ + 'HTTP_HOST': 'example.com', + 'tm.active': True, + 'tm.manager': tm, + 'app.dbsession': dbsession, + }) + + # initialize a csrf token instead of running an initial request to get one + # from the actual app - this only works using the CookieCSRFStoragePolicy + testapp.set_cookie('csrf_token', 'dummy_csrf_token') + + return testapp + +@pytest.fixture +def app_request(app, tm, dbsession): + """ + A real request. + + This request is almost identical to a real request but it has some + drawbacks in tests as it's harder to mock data and is heavier. + + """ + env = prepare(registry=app.registry) + request = env['request'] + request.host = 'example.com' + + # without this, request.dbsession will be joined to the same transaction + # manager but it will be using a different sqlalchemy.orm.Session using + # a separate database transaction + request.dbsession = dbsession + request.tm = tm + + yield request + env['closer']() + +@pytest.fixture +def dummy_request(app, tm, dbsession): + """ + A lightweight dummy request. + + This request is ultra-lightweight and should be used only when the + request itself is not a large focus in the call-stack. + + It is way easier to mock and control side-effects using this object. + + - It does not have request extensions applied. + - Threadlocals are not properly pushed. + + """ + request = DummyRequest() + request.registry = app.registry + request.host = 'example.com' + request.dbsession = dbsession + request.tm = tm + + return request diff --git a/docs/tutorials/wiki2/src/tests/tests/test_functional.py b/docs/tutorials/wiki2/src/tests/tests/test_functional.py index 0250e71c9..c6bbd3d5a 100644 --- a/docs/tutorials/wiki2/src/tests/tests/test_functional.py +++ b/docs/tutorials/wiki2/src/tests/tests/test_functional.py @@ -1,134 +1,127 @@ +import pytest import transaction -import unittest -import webtest - - -class FunctionalTests(unittest.TestCase): - - basic_login = ( - '/login?login=basic&password=basic' - '&next=FrontPage&form.submitted=Login') - basic_wrong_login = ( - '/login?login=basic&password=incorrect' - '&next=FrontPage&form.submitted=Login') - basic_login_no_next = ( - '/login?login=basic&password=basic' - '&form.submitted=Login') - editor_login = ( - '/login?login=editor&password=editor' - '&next=FrontPage&form.submitted=Login') - - @classmethod - def setUpClass(cls): - from tutorial.models.meta import Base - from tutorial.models import ( - User, - Page, - get_tm_session, - ) - from tutorial import main - - settings = { - 'sqlalchemy.url': 'sqlite://', - 'auth.secret': 'seekrit', - } - app = main({}, **settings) - cls.testapp = webtest.TestApp(app) - - session_factory = app.registry['dbsession_factory'] - cls.engine = session_factory.kw['bind'] - Base.metadata.create_all(bind=cls.engine) - - with transaction.manager: - dbsession = get_tm_session(session_factory, transaction.manager) - editor = User(name='editor', role='editor') - editor.set_password('editor') - basic = User(name='basic', role='basic') - basic.set_password('basic') - page1 = Page(name='FrontPage', data='This is the front page') - page1.creator = editor - page2 = Page(name='BackPage', data='This is the back page') - page2.creator = basic - dbsession.add_all([basic, editor, page1, page2]) - - @classmethod - def tearDownClass(cls): - from tutorial.models.meta import Base - Base.metadata.drop_all(bind=cls.engine) - - def test_root(self): - res = self.testapp.get('/', status=302) - self.assertEqual(res.location, 'http://localhost/FrontPage') - - def test_FrontPage(self): - res = self.testapp.get('/FrontPage', status=200) - self.assertTrue(b'FrontPage' in res.body) - - def test_unexisting_page(self): - self.testapp.get('/SomePage', status=404) - - def test_successful_log_in(self): - res = self.testapp.get(self.basic_login, status=302) - self.assertEqual(res.location, 'http://localhost/FrontPage') - - def test_successful_log_in_no_next(self): - res = self.testapp.get(self.basic_login_no_next, status=302) - self.assertEqual(res.location, 'http://localhost/') - - def test_failed_log_in(self): - res = self.testapp.get(self.basic_wrong_login, status=200) - self.assertTrue(b'login' in res.body) - - def test_logout_link_present_when_logged_in(self): - self.testapp.get(self.basic_login, status=302) - res = self.testapp.get('/FrontPage', status=200) - self.assertTrue(b'Logout' in res.body) - - def test_logout_link_not_present_after_logged_out(self): - self.testapp.get(self.basic_login, status=302) - self.testapp.get('/FrontPage', status=200) - res = self.testapp.get('/logout', status=302) - self.assertTrue(b'Logout' not in res.body) - - def test_anonymous_user_cannot_edit(self): - res = self.testapp.get('/FrontPage/edit_page', status=302).follow() - self.assertTrue(b'Login' in res.body) - - def test_anonymous_user_cannot_add(self): - res = self.testapp.get('/add_page/NewPage', status=302).follow() - self.assertTrue(b'Login' in res.body) - - def test_basic_user_cannot_edit_front(self): - self.testapp.get(self.basic_login, status=302) - res = self.testapp.get('/FrontPage/edit_page', status=302).follow() - self.assertTrue(b'Login' in res.body) - - def test_basic_user_can_edit_back(self): - self.testapp.get(self.basic_login, status=302) - res = self.testapp.get('/BackPage/edit_page', status=200) - self.assertTrue(b'Editing' in res.body) - - def test_basic_user_can_add(self): - self.testapp.get(self.basic_login, status=302) - res = self.testapp.get('/add_page/NewPage', status=200) - self.assertTrue(b'Editing' in res.body) - - def test_editors_member_user_can_edit(self): - self.testapp.get(self.editor_login, status=302) - res = self.testapp.get('/FrontPage/edit_page', status=200) - self.assertTrue(b'Editing' in res.body) - - def test_editors_member_user_can_add(self): - self.testapp.get(self.editor_login, status=302) - res = self.testapp.get('/add_page/NewPage', status=200) - self.assertTrue(b'Editing' in res.body) - - def test_editors_member_user_can_view(self): - self.testapp.get(self.editor_login, status=302) - res = self.testapp.get('/FrontPage', status=200) - self.assertTrue(b'FrontPage' in res.body) - - def test_redirect_to_edit_for_existing_page(self): - self.testapp.get(self.editor_login, status=302) - res = self.testapp.get('/add_page/FrontPage', status=302) - self.assertTrue(b'FrontPage' in res.body) + +from tutorial import models + + +basic_login = dict(login='basic', password='basic') +editor_login = dict(login='editor', password='editor') + +@pytest.fixture(scope='session', autouse=True) +def dummy_data(app): + """ + Add some dummy data to the database. + + Note that this is a session fixture that commits data to the database. + Think about it similarly to running the ``initialize_db`` script at the + start of the test suite. + + This data should not conflict with any other data added throughout the + test suite or there will be issues - so be careful with this pattern! + + """ + tm = transaction.TransactionManager(explicit=True) + with tm: + dbsession = models.get_tm_session(app.registry['dbsession_factory'], tm) + editor = models.User(name='editor', role='editor') + editor.set_password('editor') + basic = models.User(name='basic', role='basic') + basic.set_password('basic') + page1 = models.Page(name='FrontPage', data='This is the front page') + page1.creator = editor + page2 = models.Page(name='BackPage', data='This is the back page') + page2.creator = basic + dbsession.add_all([basic, editor, page1, page2]) + +def test_root(testapp): + res = testapp.get('/', status=303) + assert res.location == 'http://example.com/FrontPage' + +def test_FrontPage(testapp): + res = testapp.get('/FrontPage', status=200) + assert b'FrontPage' in res.body + +def test_missing_page(testapp): + res = testapp.get('/SomePage', status=404) + assert b'404' in res.body + +def test_successful_log_in(testapp): + params = dict( + **basic_login, + csrf_token=testapp.get_csrf_token(), + ) + res = testapp.post('/login', params, status=303) + assert res.location == 'http://example.com/' + +def test_successful_log_with_next(testapp): + params = dict( + **basic_login, + next='WikiPage', + csrf_token=testapp.get_csrf_token(), + ) + res = testapp.post('/login', params, status=303) + assert res.location == 'http://example.com/WikiPage' + +def test_failed_log_in(testapp): + params = dict( + login='basic', + password='incorrect', + csrf_token=testapp.get_csrf_token(), + ) + res = testapp.post('/login', params, status=400) + assert b'login' in res.body + +def test_logout_link_present_when_logged_in(testapp): + testapp.login(basic_login) + res = testapp.get('/FrontPage', status=200) + assert b'Logout' in res.body + +def test_logout_link_not_present_after_logged_out(testapp): + testapp.login(basic_login) + testapp.get('/FrontPage', status=200) + params = dict(csrf_token=testapp.get_csrf_token()) + res = testapp.post('/logout', params, status=303) + assert b'Logout' not in res.body + +def test_anonymous_user_cannot_edit(testapp): + res = testapp.get('/FrontPage/edit_page', status=303).follow() + assert b'Login' in res.body + +def test_anonymous_user_cannot_add(testapp): + res = testapp.get('/add_page/NewPage', status=303).follow() + assert b'Login' in res.body + +def test_basic_user_cannot_edit_front(testapp): + testapp.login(basic_login) + res = testapp.get('/FrontPage/edit_page', status=403) + assert b'403' in res.body + +def test_basic_user_can_edit_back(testapp): + testapp.login(basic_login) + res = testapp.get('/BackPage/edit_page', status=200) + assert b'Editing' in res.body + +def test_basic_user_can_add(testapp): + testapp.login(basic_login) + res = testapp.get('/add_page/NewPage', status=200) + assert b'Editing' in res.body + +def test_editors_member_user_can_edit(testapp): + testapp.login(editor_login) + res = testapp.get('/FrontPage/edit_page', status=200) + assert b'Editing' in res.body + +def test_editors_member_user_can_add(testapp): + testapp.login(editor_login) + res = testapp.get('/add_page/NewPage', status=200) + assert b'Editing' in res.body + +def test_editors_member_user_can_view(testapp): + testapp.login(editor_login) + res = testapp.get('/FrontPage', status=200) + assert b'FrontPage' in res.body + +def test_redirect_to_edit_for_existing_page(testapp): + testapp.login(editor_login) + res = testapp.get('/add_page/FrontPage', status=303) + assert b'FrontPage' in res.body diff --git a/docs/tutorials/wiki2/src/tests/tests/test_initdb.py b/docs/tutorials/wiki2/src/tests/tests/test_initdb.py deleted file mode 100644 index a66945ccc..000000000 --- a/docs/tutorials/wiki2/src/tests/tests/test_initdb.py +++ /dev/null @@ -1,10 +0,0 @@ -import os -import unittest - - -class TestInitializeDB(unittest.TestCase): - - def test_usage(self): - from tutorial.scripts.initialize_db import main - with self.assertRaises(SystemExit): - main(argv=['foo']) diff --git a/docs/tutorials/wiki2/src/tests/tests/test_security.py b/docs/tutorials/wiki2/src/tests/tests/test_security.py deleted file mode 100644 index 9a1455ef9..000000000 --- a/docs/tutorials/wiki2/src/tests/tests/test_security.py +++ /dev/null @@ -1,23 +0,0 @@ -import unittest -from pyramid.testing import DummyRequest - - -class TestMyAuthenticationPolicy(unittest.TestCase): - - def test_no_user(self): - request = DummyRequest() - request.user = None - - from tutorial.security import MyAuthenticationPolicy - policy = MyAuthenticationPolicy(None) - self.assertEqual(policy.authenticated_userid(request), None) - - def test_authenticated_user(self): - from tutorial.models import User - request = DummyRequest() - request.user = User() - request.user.id = 'foo' - - from tutorial.security import MyAuthenticationPolicy - policy = MyAuthenticationPolicy(None) - self.assertEqual(policy.authenticated_userid(request), 'foo') diff --git a/docs/tutorials/wiki2/src/tests/tests/test_user_model.py b/docs/tutorials/wiki2/src/tests/tests/test_user_model.py index 21904da6b..f91116360 100644 --- a/docs/tutorials/wiki2/src/tests/tests/test_user_model.py +++ b/docs/tutorials/wiki2/src/tests/tests/test_user_model.py @@ -1,67 +1,23 @@ -import unittest -import transaction +from tutorial import models -from pyramid import testing +def test_password_hash_saved(): + user = models.User(name='foo', role='bar') + assert user.password_hash is None -class BaseTest(unittest.TestCase): + user.set_password('secret') + assert user.password_hash is not None - def setUp(self): - from tutorial.models import get_tm_session - self.config = testing.setUp(settings={ - 'sqlalchemy.url': 'sqlite:///:memory:' - }) - self.config.include('tutorial.models') - self.config.include('tutorial.routes') +def test_password_hash_not_set(): + user = models.User(name='foo', role='bar') + assert not user.check_password('secret') - session_factory = self.config.registry['dbsession_factory'] - self.session = get_tm_session(session_factory, transaction.manager) +def test_correct_password(): + user = models.User(name='foo', role='bar') + user.set_password('secret') + assert user.check_password('secret') - self.init_database() - - def init_database(self): - from tutorial.models.meta import Base - session_factory = self.config.registry['dbsession_factory'] - engine = session_factory.kw['bind'] - Base.metadata.create_all(engine) - - def tearDown(self): - testing.tearDown() - transaction.abort() - - def makeUser(self, name, role): - from tutorial.models import User - return User(name=name, role=role) - - -class TestSetPassword(BaseTest): - - def test_password_hash_saved(self): - user = self.makeUser(name='foo', role='bar') - self.assertFalse(user.password_hash) - - user.set_password('secret') - self.assertTrue(user.password_hash) - - -class TestCheckPassword(BaseTest): - - def test_password_hash_not_set(self): - user = self.makeUser(name='foo', role='bar') - self.assertFalse(user.password_hash) - - self.assertFalse(user.check_password('secret')) - - def test_correct_password(self): - user = self.makeUser(name='foo', role='bar') - user.set_password('secret') - self.assertTrue(user.password_hash) - - self.assertTrue(user.check_password('secret')) - - def test_incorrect_password(self): - user = self.makeUser(name='foo', role='bar') - user.set_password('secret') - self.assertTrue(user.password_hash) - - self.assertFalse(user.check_password('incorrect')) +def test_incorrect_password(): + user = models.User(name='foo', role='bar') + user.set_password('secret') + assert not user.check_password('incorrect') diff --git a/docs/tutorials/wiki2/src/tests/tests/test_views.py b/docs/tutorials/wiki2/src/tests/tests/test_views.py index 5c17457dd..1ec2795ab 100644 --- a/docs/tutorials/wiki2/src/tests/tests/test_views.py +++ b/docs/tutorials/wiki2/src/tests/tests/test_views.py @@ -1,168 +1,107 @@ -import unittest -import transaction +from tutorial import models -from pyramid import testing +def makeUser(name, role): + return models.User(name=name, role=role) -def dummy_request(dbsession): - return testing.DummyRequest(dbsession=dbsession) - - -class BaseTest(unittest.TestCase): - def setUp(self): - from tutorial.models import get_tm_session - self.config = testing.setUp(settings={ - 'sqlalchemy.url': 'sqlite:///:memory:' - }) - self.config.include('tutorial.models') - self.config.include('tutorial.routes') - - session_factory = self.config.registry['dbsession_factory'] - self.session = get_tm_session(session_factory, transaction.manager) - - self.init_database() - - def init_database(self): - from tutorial.models.meta import Base - session_factory = self.config.registry['dbsession_factory'] - engine = session_factory.kw['bind'] - Base.metadata.create_all(engine) - - def tearDown(self): - testing.tearDown() - transaction.abort() - - def makeUser(self, name, role, password='dummy'): - from tutorial.models import User - user = User(name=name, role=role) - user.set_password(password) - return user - - def makePage(self, name, data, creator): - from tutorial.models import Page - return Page(name=name, data=data, creator=creator) - - -class ViewWikiTests(unittest.TestCase): - def setUp(self): - self.config = testing.setUp() - self.config.include('tutorial.routes') - - def tearDown(self): - testing.tearDown() +def makePage(name, data, creator): + return models.Page(name=name, data=data, creator=creator) +class Test_view_wiki: def _callFUT(self, request): from tutorial.views.default import view_wiki return view_wiki(request) - def test_it(self): - request = testing.DummyRequest() - response = self._callFUT(request) - self.assertEqual(response.location, 'http://example.com/FrontPage') + def test_it(self, dummy_request): + response = self._callFUT(dummy_request) + assert response.location == 'http://example.com/FrontPage' - -class ViewPageTests(BaseTest): +class Test_view_page: def _callFUT(self, request): from tutorial.views.default import view_page return view_page(request) - def test_it(self): + def test_it(self, dummy_request, dbsession): from tutorial.routes import PageResource # add a page to the db - user = self.makeUser('foo', 'editor') - page = self.makePage('IDoExist', 'Hello CruelWorld IDoExist', user) - self.session.add_all([page, user]) + user = makeUser('foo', 'editor') + page = makePage('IDoExist', 'Hello CruelWorld IDoExist', user) + dbsession.add_all([page, user]) # create a request asking for the page we've created - request = dummy_request(self.session) - request.context = PageResource(page) + dummy_request.context = PageResource(page) # call the view we're testing and check its behavior - info = self._callFUT(request) - self.assertEqual(info['page'], page) - self.assertEqual( - info['content'], + info = self._callFUT(dummy_request) + assert info['page'] is page + assert info['content'] == ( '<div class="document">\n' '<p>Hello <a href="http://example.com/add_page/CruelWorld">' 'CruelWorld</a> ' '<a href="http://example.com/IDoExist">' 'IDoExist</a>' - '</p>\n</div>\n') - self.assertEqual(info['edit_url'], - 'http://example.com/IDoExist/edit_page') - + '</p>\n</div>\n' + ) + assert info['edit_url'] == 'http://example.com/IDoExist/edit_page' -class AddPageTests(BaseTest): +class Test_add_page: def _callFUT(self, request): from tutorial.views.default import add_page return add_page(request) - def test_it_pageexists(self): - from tutorial.models import Page + def test_get(self, dummy_request, dbsession): from tutorial.routes import NewPage - request = testing.DummyRequest({'form.submitted': True, - 'body': 'Hello yo!'}, - dbsession=self.session) - request.user = self.makeUser('foo', 'editor') - request.context = NewPage('AnotherPage') - self._callFUT(request) - pagecount = self.session.query(Page).filter_by(name='AnotherPage').count() - self.assertGreater(pagecount, 0) - - def test_it_notsubmitted(self): - from tutorial.routes import NewPage - request = dummy_request(self.session) - request.user = self.makeUser('foo', 'editor') - request.context = NewPage('AnotherPage') - info = self._callFUT(request) - self.assertEqual(info['pagedata'], '') - self.assertEqual(info['save_url'], - 'http://example.com/add_page/AnotherPage') - - def test_it_submitted(self): - from tutorial.models import Page - from tutorial.routes import NewPage - request = testing.DummyRequest({'form.submitted': True, - 'body': 'Hello yo!'}, - dbsession=self.session) - request.user = self.makeUser('foo', 'editor') - request.context = NewPage('AnotherPage') - self._callFUT(request) - page = self.session.query(Page).filter_by(name='AnotherPage').one() - self.assertEqual(page.data, 'Hello yo!') + dummy_request.user = makeUser('foo', 'editor') + dummy_request.context = NewPage('AnotherPage') + info = self._callFUT(dummy_request) + assert info['pagedata'] == '' + assert info['save_url'] == 'http://example.com/add_page/AnotherPage' + + def test_submit_works(self, dummy_request, dbsession): + from tutorial.routes import NewPage -class EditPageTests(BaseTest): + dummy_request.method = 'POST' + dummy_request.POST['body'] = 'Hello yo!' + dummy_request.context = NewPage('AnotherPage') + dummy_request.user = makeUser('foo', 'editor') + self._callFUT(dummy_request) + page = ( + dbsession.query(models.Page) + .filter_by(name='AnotherPage') + .one() + ) + assert page.data == 'Hello yo!' + +class Test_edit_page: def _callFUT(self, request): from tutorial.views.default import edit_page return edit_page(request) - def makeContext(self, page): + def _makeContext(self, page): from tutorial.routes import PageResource return PageResource(page) - def test_it_notsubmitted(self): - user = self.makeUser('foo', 'editor') - page = self.makePage('abc', 'hello', user) - self.session.add_all([page, user]) - - request = dummy_request(self.session) - request.context = self.makeContext(page) - info = self._callFUT(request) - self.assertEqual(info['pagename'], 'abc') - self.assertEqual(info['save_url'], - 'http://example.com/abc/edit_page') - - def test_it_submitted(self): - user = self.makeUser('foo', 'editor') - page = self.makePage('abc', 'hello', user) - self.session.add_all([page, user]) - - request = testing.DummyRequest({'form.submitted': True, - 'body': 'Hello yo!'}, - dbsession=self.session) - request.context = self.makeContext(page) - response = self._callFUT(request) - self.assertEqual(response.location, 'http://example.com/abc') - self.assertEqual(page.data, 'Hello yo!') + def test_get(self, dummy_request, dbsession): + user = makeUser('foo', 'editor') + page = makePage('abc', 'hello', user) + dbsession.add_all([page, user]) + + dummy_request.context = self._makeContext(page) + info = self._callFUT(dummy_request) + assert info['pagename'] == 'abc' + assert info['save_url'] == 'http://example.com/abc/edit_page' + + def test_submit_works(self, dummy_request, dbsession): + user = makeUser('foo', 'editor') + page = makePage('abc', 'hello', user) + dbsession.add_all([page, user]) + + dummy_request.method = 'POST' + dummy_request.POST['body'] = 'Hello yo!' + dummy_request.user = user + dummy_request.context = self._makeContext(page) + response = self._callFUT(dummy_request) + assert response.location == 'http://example.com/abc' + assert page.data == 'Hello yo!' diff --git a/docs/tutorials/wiki2/src/tests/tutorial/__init__.py b/docs/tutorials/wiki2/src/tests/tutorial/__init__.py index ce2e9f12a..81a22c68c 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/__init__.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/__init__.py @@ -5,9 +5,9 @@ def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ with Configurator(settings=settings) as config: - config.include('.models') config.include('pyramid_jinja2') - config.include('.routes') config.include('.security') + config.include('.routes') + config.include('.models') config.scan() return config.make_wsgi_app() diff --git a/docs/tutorials/wiki2/src/tests/tutorial/models/__init__.py b/docs/tutorials/wiki2/src/tests/tutorial/models/__init__.py index a4209a6e9..47d77ef01 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/models/__init__.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/models/__init__.py @@ -66,13 +66,21 @@ def includeme(config): # use pyramid_retry to retry a request when transient exceptions occur config.include('pyramid_retry') - session_factory = get_session_factory(get_engine(settings)) + # hook to share the dbengine fixture in testing + dbengine = settings.get('dbengine') + if not dbengine: + dbengine = get_engine(settings) + + session_factory = get_session_factory(dbengine) config.registry['dbsession_factory'] = session_factory # make request.dbsession available for use in Pyramid - config.add_request_method( - # r.tm is the transaction manager used by pyramid_tm - lambda r: get_tm_session(session_factory, r.tm), - 'dbsession', - reify=True - ) + def dbsession(request): + # hook to share the dbsession fixture in testing + dbsession = request.environ.get('app.dbsession') + if dbsession is None: + # request.tm is the transaction manager used by pyramid_tm + dbsession = get_tm_session(session_factory, request.tm) + return dbsession + + config.add_request_method(dbsession, reify=True) diff --git a/docs/tutorials/wiki2/src/tests/tutorial/routes.py b/docs/tutorials/wiki2/src/tests/tutorial/routes.py index 1fd45a994..f016d7541 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/routes.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/routes.py @@ -1,6 +1,6 @@ from pyramid.httpexceptions import ( HTTPNotFound, - HTTPFound, + HTTPSeeOther, ) from pyramid.security import ( Allow, @@ -9,6 +9,7 @@ from pyramid.security import ( from . import models + def includeme(config): config.add_static_view('static', 'static', cache_max_age=3600) config.add_route('view_wiki', '/') @@ -24,7 +25,7 @@ def new_page_factory(request): pagename = request.matchdict['pagename'] if request.dbsession.query(models.Page).filter_by(name=pagename).count() > 0: next_url = request.route_url('edit_page', pagename=pagename) - raise HTTPFound(location=next_url) + raise HTTPSeeOther(location=next_url) return NewPage(pagename) class NewPage(object): @@ -52,5 +53,5 @@ class PageResource(object): return [ (Allow, Everyone, 'view'), (Allow, 'role:editor', 'edit'), - (Allow, str(self.page.creator_id), 'edit'), + (Allow, 'u:' + str(self.page.creator_id), 'edit'), ] diff --git a/docs/tutorials/wiki2/src/tests/tutorial/scripts/initialize_db.py b/docs/tutorials/wiki2/src/tests/tutorial/scripts/initialize_db.py index e6350fb36..c8034e5a5 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/scripts/initialize_db.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/scripts/initialize_db.py @@ -8,6 +8,10 @@ from .. import models def setup_models(dbsession): + """ + Add or update models / fixtures in the database. + + """ editor = models.User(name='editor', role='editor') editor.set_password('editor') dbsession.add(editor) diff --git a/docs/tutorials/wiki2/src/tests/tutorial/security.py b/docs/tutorials/wiki2/src/tests/tutorial/security.py index 1ce1c8753..448183c95 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/security.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/security.py @@ -1,5 +1,6 @@ -from pyramid.authentication import AuthTktAuthenticationPolicy -from pyramid.authorization import ACLAuthorizationPolicy +from pyramid.authentication import AuthTktCookieHelper +from pyramid.authorization import ACLHelper +from pyramid.csrf import CookieCSRFStoragePolicy from pyramid.security import ( Authenticated, Everyone, @@ -8,33 +9,50 @@ from pyramid.security import ( from . import models -class MyAuthenticationPolicy(AuthTktAuthenticationPolicy): +class MySecurityPolicy: + def __init__(self, secret): + self.authtkt = AuthTktCookieHelper(secret) + self.acl = ACLHelper() + + def authenticated_identity(self, request): + identity = self.authtkt.identify(request) + if identity is None: + return None + + userid = identity['userid'] + user = request.dbsession.query(models.User).get(userid) + return user + def authenticated_userid(self, request): - user = request.user + user = self.authenticated_identity(request) if user is not None: return user.id + def remember(self, request, userid, **kw): + return self.authtkt.remember(request, userid, **kw) + + def forget(self, request, **kw): + return self.authtkt.forget(request, **kw) + + def permits(self, request, context, permission): + principals = self.effective_principals(request) + return self.acl.permits(context, principals, permission) + def effective_principals(self, request): principals = [Everyone] - user = request.user + user = self.authenticated_identity(request) if user is not None: principals.append(Authenticated) - principals.append(str(user.id)) + principals.append('u:' + str(user.id)) principals.append('role:' + user.role) return principals -def get_user(request): - user_id = request.unauthenticated_userid - if user_id is not None: - user = request.dbsession.query(models.User).get(user_id) - return user - def includeme(config): settings = config.get_settings() - authn_policy = MyAuthenticationPolicy( - settings['auth.secret'], - hashalg='sha512', - ) - config.set_authentication_policy(authn_policy) - config.set_authorization_policy(ACLAuthorizationPolicy()) - config.add_request_method(get_user, 'user', reify=True) + + config.set_csrf_storage_policy(CookieCSRFStoragePolicy()) + config.set_default_csrf_options(require_csrf=True) + + config.set_security_policy(MySecurityPolicy(settings['auth.secret'])) + config.add_request_method( + lambda request: request.authenticated_identity, 'user', property=True) diff --git a/docs/tutorials/wiki2/src/tests/tutorial/templates/403.jinja2 b/docs/tutorials/wiki2/src/tests/tutorial/templates/403.jinja2 new file mode 100644 index 000000000..7a6f523bc --- /dev/null +++ b/docs/tutorials/wiki2/src/tests/tutorial/templates/403.jinja2 @@ -0,0 +1,6 @@ +{% extends "layout.jinja2" %} + +{% block content %} +<h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Starter project</span></h1> +<p class="lead"><span class="font-semi-bold">403</span> Forbidden</p> +{% endblock content %} diff --git a/docs/tutorials/wiki2/src/tests/tutorial/templates/404.jinja2 b/docs/tutorials/wiki2/src/tests/tutorial/templates/404.jinja2 index aaf12413f..5edb15285 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/templates/404.jinja2 +++ b/docs/tutorials/wiki2/src/tests/tutorial/templates/404.jinja2 @@ -1,8 +1,6 @@ {% extends "layout.jinja2" %} {% block content %} -<div class="content"> - <h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Starter project</span></h1> - <p class="lead"><span class="font-semi-bold">404</span> Page Not Found</p> -</div> +<h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Starter project</span></h1> +<p class="lead"><span class="font-semi-bold">404</span> Page Not Found</p> {% endblock content %} diff --git a/docs/tutorials/wiki2/src/tests/tutorial/templates/edit.jinja2 b/docs/tutorials/wiki2/src/tests/tutorial/templates/edit.jinja2 index 7db25c674..27b545054 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/templates/edit.jinja2 +++ b/docs/tutorials/wiki2/src/tests/tutorial/templates/edit.jinja2 @@ -10,11 +10,12 @@ Editing <strong>{{pagename}}</strong> <a href="{{request.route_url('view_page', pagename='FrontPage')}}">FrontPage</a>. </p> <form action="{{ save_url }}" method="post"> +<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}"> <div class="form-group"> <textarea class="form-control" name="body" rows="10" cols="60">{{ pagedata }}</textarea> </div> <div class="form-group"> - <button type="submit" name="form.submitted" value="Save" class="btn btn-default">Save</button> + <button type="submit" class="btn btn-default">Save</button> </div> </form> {% endblock content %} diff --git a/docs/tutorials/wiki2/src/tests/tutorial/templates/layout.jinja2 b/docs/tutorials/wiki2/src/tests/tutorial/templates/layout.jinja2 index 4016b26c9..64a1db0c5 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/templates/layout.jinja2 +++ b/docs/tutorials/wiki2/src/tests/tutorial/templates/layout.jinja2 @@ -35,18 +35,29 @@ <div class="content"> {% if request.user is none %} <p class="pull-right"> - <a href="{{ request.route_url('login') }}">Login</a> + <a href="{{ request.route_url('login') }}">Login</a> </p> {% else %} - <p class="pull-right"> - {{request.user.name}} <a href="{{request.route_url('logout')}}">Logout</a> - </p> + <form class="pull-right" action="{{ request.route_url('logout') }}" method="post"> + {{request.user.name}} + <input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}"> + <button class="btn btn-link" type="submit">Logout</button> + </form> {% endif %} {% block content %}{% endblock %} </div> </div> </div> <div class="row"> + <div class="links"> + <ul> + <li><i class="glyphicon glyphicon-cog icon-muted"></i><a href="https://github.com/Pylons/pyramid">Github Project</a></li> + <li><i class="glyphicon glyphicon-globe icon-muted"></i><a href="https://webchat.freenode.net/?channels=pyramid">IRC Channel</a></li> + <li><i class="glyphicon glyphicon-home icon-muted"></i><a href="https://pylonsproject.org">Pylons Project</a></li> + </ul> + </div> + </div> + <div class="row"> <div class="copyright"> Copyright © Pylons Project </div> diff --git a/docs/tutorials/wiki2/src/tests/tutorial/templates/login.jinja2 b/docs/tutorials/wiki2/src/tests/tutorial/templates/login.jinja2 index 1806de0ff..058b7254b 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/templates/login.jinja2 +++ b/docs/tutorials/wiki2/src/tests/tutorial/templates/login.jinja2 @@ -10,6 +10,7 @@ {{ message }} </p> <form action="{{ url }}" method="post"> +<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}"> <input type="hidden" name="next" value="{{ next_url }}"> <div class="form-group"> <label for="login">Username</label> @@ -20,7 +21,7 @@ <input type="password" name="password"> </div> <div class="form-group"> - <button type="submit" name="form.submitted" value="Log In" class="btn btn-default">Log In</button> + <button type="submit" class="btn btn-default">Log In</button> </div> </form> {% endblock content %} diff --git a/docs/tutorials/wiki2/src/tests/tutorial/views/auth.py b/docs/tutorials/wiki2/src/tests/tutorial/views/auth.py index 16fa616e5..e1a564415 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/views/auth.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/views/auth.py @@ -1,14 +1,15 @@ -from pyramid.httpexceptions import HTTPFound +from pyramid.csrf import new_csrf_token +from pyramid.httpexceptions import HTTPSeeOther from pyramid.security import ( remember, forget, - ) +) from pyramid.view import ( forbidden_view_config, view_config, ) -from ..models import User +from .. import models @view_config(route_name='login', renderer='tutorial:templates/login.jinja2') @@ -18,29 +19,43 @@ def login(request): next_url = request.route_url('view_wiki') message = '' login = '' - if 'form.submitted' in request.params: + if request.method == 'POST': login = request.params['login'] password = request.params['password'] - user = request.dbsession.query(User).filter_by(name=login).first() + user = ( + request.dbsession.query(models.User) + .filter_by(name=login) + .first() + ) if user is not None and user.check_password(password): + new_csrf_token(request) headers = remember(request, user.id) - return HTTPFound(location=next_url, headers=headers) + return HTTPSeeOther(location=next_url, headers=headers) message = 'Failed login' + request.response.status = 400 return dict( message=message, url=request.route_url('login'), next_url=next_url, login=login, - ) + ) @view_config(route_name='logout') def logout(request): - headers = forget(request) next_url = request.route_url('view_wiki') - return HTTPFound(location=next_url, headers=headers) + if request.method == 'POST': + new_csrf_token(request) + headers = forget(request) + return HTTPSeeOther(location=next_url, headers=headers) + + return HTTPSeeOther(location=next_url) + +@forbidden_view_config(renderer='tutorial:templates/403.jinja2') +def forbidden_view(exc, request): + if request.user is None: + next_url = request.route_url('login', _query={'next': request.url}) + return HTTPSeeOther(location=next_url) -@forbidden_view_config() -def forbidden_view(request): - next_url = request.route_url('login', _query={'next': request.url}) - return HTTPFound(location=next_url) + request.response.status = 403 + return {} diff --git a/docs/tutorials/wiki2/src/tests/tutorial/views/default.py b/docs/tutorials/wiki2/src/tests/tutorial/views/default.py index de0bcd816..214788357 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/views/default.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/views/default.py @@ -1,19 +1,19 @@ -from html import escape -import re from docutils.core import publish_parts - -from pyramid.httpexceptions import HTTPFound +from html import escape +from pyramid.httpexceptions import HTTPSeeOther from pyramid.view import view_config +import re from .. import models + # regular expression used to find WikiWords wikiwords = re.compile(r"\b([A-Z]\w+[A-Z]+\w+)") @view_config(route_name='view_wiki') def view_wiki(request): next_url = request.route_url('view_page', pagename='FrontPage') - return HTTPFound(location=next_url) + return HTTPSeeOther(location=next_url) @view_config(route_name='view_page', renderer='tutorial:templates/view.jinja2', permission='view') @@ -39,26 +39,26 @@ def view_page(request): permission='edit') def edit_page(request): page = request.context.page - if 'form.submitted' in request.params: + if request.method == 'POST': page.data = request.params['body'] next_url = request.route_url('view_page', pagename=page.name) - return HTTPFound(location=next_url) + return HTTPSeeOther(location=next_url) return dict( pagename=page.name, pagedata=page.data, save_url=request.route_url('edit_page', pagename=page.name), - ) + ) @view_config(route_name='add_page', renderer='tutorial:templates/edit.jinja2', permission='create') def add_page(request): pagename = request.context.pagename - if 'form.submitted' in request.params: + if request.method == 'POST': body = request.params['body'] page = models.Page(name=pagename, data=body) page.creator = request.user request.dbsession.add(page) next_url = request.route_url('view_page', pagename=pagename) - return HTTPFound(location=next_url) + return HTTPSeeOther(location=next_url) save_url = request.route_url('add_page', pagename=pagename) return dict(pagename=pagename, pagedata='', save_url=save_url) diff --git a/docs/tutorials/wiki2/src/views/testing.ini b/docs/tutorials/wiki2/src/views/testing.ini index 85e5e1ae9..5caa1a8dc 100644 --- a/docs/tutorials/wiki2/src/views/testing.ini +++ b/docs/tutorials/wiki2/src/views/testing.ini @@ -31,7 +31,7 @@ file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s [server:main] use = egg:waitress#main -listen = *:6543 +listen = localhost:6543 ### # logging configuration @@ -48,11 +48,11 @@ keys = console keys = generic [logger_root] -level = WARN +level = INFO handlers = console [logger_tutorial] -level = WARN +level = DEBUG handlers = qualname = tutorial diff --git a/docs/tutorials/wiki2/tests.rst b/docs/tutorials/wiki2/tests.rst index c7d1a0f31..8a3e79363 100644 --- a/docs/tutorials/wiki2/tests.rst +++ b/docs/tutorials/wiki2/tests.rst @@ -8,101 +8,142 @@ We will now add tests for the models and views as well as a few functional tests in a new ``tests`` package. Tests ensure that an application works, and that it continues to work when changes are made in the future. -The file ``tests/test_it.py`` at the root of our project directory was generated from choosing the ``sqlalchemy`` backend option. + +Test harness +============ + +The project came bootstrapped with some tests and a basic harness. +These are located in the ``tests`` package at the top-level of the project. It is a common practice to put tests into a ``tests`` package alongside the application package, especially as projects grow in size and complexity. -Each module in the test package should contain tests for its corresponding module in our application. -Each corresponding pair of modules should have the same names, except the test module should have the prefix ``test_``. +A useful convention is for each module in the application to contain a corresponding module in the ``tests`` package. +The test module would have the same name with the prefix ``test_``. -Start by deleting ``tests/test_it.py``. +The harness consists of the following setup: -.. warning:: +- ``pytest.ini`` - controls basic ``pytest`` config including where to find the tests. + We have configured ``pytest`` to search for tests in the application package and in the ``tests`` package. - It is very important when refactoring a Python module into a package to be - sure to delete the cache files (``.pyc`` files or ``__pycache__`` folders) - sitting around! Python will prioritize the cache files before traversing - into folders, using the old code, and you will wonder why none of your - changes are working! +- ``.coveragerc`` - controls coverage config. + In our setup, it works with the ``pytest-cov`` plugin that we use via the ``--cov`` options to the ``pytest`` command. +- ``testing.ini`` - a mirror of ``development.ini`` and ``production.ini`` that contains settings used for executing the test suite. + Most importantly, it contains the database connection information used by tests that require the database. -Test the views -============== +- ``tests_require`` in ``setup.py`` - controls the dependencies installed when testing. + When the list is changed, it's necessary to re-run ``$VENV/bin/pip install -e ".[testing]"`` to ensure the new dependencies are installed. -We'll create a new ``tests/test_views.py`` file, adding a ``BaseTest`` class -used as the base for other test classes. Next we'll add tests for each view -function we previously added to our application. We'll add four test classes: -``ViewWikiTests``, ``ViewPageTests``, ``AddPageTests``, and ``EditPageTests``. -These test the ``view_wiki``, ``view_page``, ``add_page``, and ``edit_page`` -views. +- ``tests/conftest.py`` - the core fixtures available throughout our tests. + The fixtures are explained in more detail below. -Functional tests -================ +Session-scoped test fixtures +---------------------------- -We'll test the whole application, covering security aspects that are not tested -in the unit tests, like logging in, logging out, checking that the ``basic`` -user cannot edit pages that it didn't create but the ``editor`` user can, and -so on. +- ``app_settings`` - the settings ``dict`` parsed from the ``testing.ini`` file that would normally be passed by ``pserve`` into your app's ``main`` function. +- ``dbengine`` - initializes the database. + It's important to start each run of the test suite from a known state, and this fixture is responsible for preparing the database appropriately. + This includes deleting any existing tables, running migrations, and potentially even loading some fixture data into the tables for use within the tests. -View the results of all our edits to ``tests`` package -====================================================== +- ``app`` - the :app:`Pyramid` WSGI application, implementing the :class:`pyramid.interfaces.IRouter` interface. + Most commonly this would be used for functional tests. -Create ``tests/test_views.py`` such that it appears as follows: -.. literalinclude:: src/tests/tests/test_views.py - :linenos: - :language: python +Per-test fixtures +----------------- -Create ``tests/test_functional.py`` such that it appears as follows: +- ``tm`` - a :class:`transaction.TransactionManager` object controlling a transaction lifecycle. + Generally other fixtures would join to the ``tm`` fixture to control their lifecycle and ensure they are aborted at the end of the test. -.. literalinclude:: src/tests/tests/test_functional.py - :linenos: - :language: python +- ``dbsession`` - a :class:`sqlalchemy.orm.session.Session` object connected to the database. + The session is scoped to the ``tm`` fixture. + Any changes made will be aborted at the end of the test. -Create ``tests/test_initdb.py`` such that it appears as follows: +- ``testapp`` - a :class:`webtest.TestApp` instance wrapping the ``app`` and is used to sending requests into the application and return full response objects that can be inspected. + The ``testapp`` is able to mutate the request environ such that the ``dbsession`` and ``tm`` fixtures are injected and used by any code that's touching ``request.dbsession`` and ``request.tm``. + The ``testapp`` maintains a cookiejar, so it can be used to share state across requests, as well as the transaction database connection. -.. literalinclude:: src/tests/tests/test_initdb.py - :linenos: - :language: python +- ``app_request`` - a :class:`pyramid.request.Request` object that can be used for more lightweight tests versus the full ``testapp``. + The ``app_request`` can be passed to view functions and other code that need a fully functional request object. + +- ``dummy_request`` - a :class:`pyramid.testing.DummyRequest` object that is very lightweight. + This is a great object to pass to view functions that have minimal side-effects as it'll be fast and simple. + + +Modifying the fixtures +---------------------- -Create ``tests/test_security.py`` such that it appears as follows: +We're going to make a few application-specific changes to the test harness. +It's always good to come up with patterns for things that are done often to avoid lots of boilerplate. -.. literalinclude:: src/tests/tests/test_security.py +- Initialize the cookiejar with a CSRF token. + Remember our application is using :class:`pyramid.csrf.CookieCSRFStoragePolicy`. + +- ``testapp.get_csrf_token()`` - every POST/PUT/DELETE/PATCH request must contain the current CSRF token to prove to our app that the client isn't a third-party. + So we want an easy way to grab the current CSRF token and add it to the request. + +- ``testapp.login(params)`` - many pages are only accessible by logged in users so we want a simple way to login a user at the start of a test. + +Update ``tests/conftest.py`` to look like the following, adding the highlighted lines: + +.. literalinclude:: src/tests/tests/conftest.py :linenos: + :emphasize-lines: 10,68-103,110,117-119 :language: python + +Unit tests +========== + +We can test individual APIs within our codebase to ensure they fulfill the expected contract that the rest of the application expects. +For example, we'll test the password hashing features we added to the ``tutorial.models.User`` object. + Create ``tests/test_user_model.py`` such that it appears as follows: .. literalinclude:: src/tests/tests/test_user_model.py :linenos: :language: python -.. note:: - We're utilizing the excellent WebTest_ package to do functional testing of - the application. This is defined in the ``tests_require`` section of our - ``setup.py``. Any other dependencies needed only for testing purposes can be - added there and will be installed automatically when running - ``setup.py test``. +Integration tests +================= + +We can directly execute the view code, bypassing :app:`Pyramid` and testing just the code that we've written. +These tests use dummy requests that we'll prepare appropriately to set the conditions each view expects. +For example, setting ``request.user``, or adding some dummy data to the session. + +Update ``tests/test_views.py`` such that it appears as follows: + +.. literalinclude:: src/tests/tests/test_views.py + :linenos: + :language: python + + +Functional tests +================ + +We'll test the whole application, covering security aspects that are not tested in the unit and integration tests, like logging in, logging out, checking that the ``basic`` user cannot edit pages that it didn't create but the ``editor`` user can, and so on. + +Update ``tests/test_functional.py`` such that it appears as follows: + +.. literalinclude:: src/tests/tests/test_functional.py + :linenos: + :language: python Running the tests ================= -We can run these tests similarly to how we did in :ref:`running_tests`, but first delete the SQLite database ``tutorial.sqlite``. If you do not delete the database, then you will see an integrity error when running the tests. - On Unix: .. code-block:: bash - rm tutorial.sqlite $VENV/bin/pytest -q On Windows: .. code-block:: doscon - del tutorial.sqlite %VENV%\Scripts\pytest -q The expected result should look like the following: |
