Adding Tests

We will now add tests for the models and views as well as a few functional tests in a new tests subpackage. Tests ensure that an application works, and that it continues to work when changes are made in the future.

The file tests.py was generated as part of the alchemy scaffold, but it is a common practice to put tests into a tests subpackage, especially as projects grow in size and complexity. Each module in the test subpackage 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_.

Start by deleting tests.py, then create a new directory to contain our new tests as well as a new empty file tests/__init__.py.

Warning

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!

Test the views

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.

Functional tests

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.

View the results of all our edits to tests subpackage

Open tutorial/tests/test_views.py, and edit it such that it appears as follows:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
import unittest
import transaction

from pyramid import testing


def dummy_request(dbsession):
    return testing.DummyRequest(dbsession=dbsession)


class BaseTest(unittest.TestCase):
    def setUp(self):
        from ..models import get_tm_session
        self.config = testing.setUp(settings={
            'sqlalchemy.url': 'sqlite:///:memory:'
        })
        self.config.include('..models')
        self.config.include('..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 ..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 ..models import User
        user = User(name=name, role=role)
        user.set_password(password)
        return user

    def makePage(self, name, data, creator):
        from ..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('..routes')

    def tearDown(self):
        testing.tearDown()

    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')


class ViewPageTests(BaseTest):
    def _callFUT(self, request):
        from tutorial.views.default import view_page
        return view_page(request)

    def test_it(self):
        from ..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])

        # create a request asking for the page we've created
        request = dummy_request(self.session)
        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'],
            '<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')


class AddPageTests(BaseTest):
    def _callFUT(self, request):
        from tutorial.views.default import add_page
        return add_page(request)

    def test_it_pageexists(self):
        from ..models import Page
        from ..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 ..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 ..models import Page
        from ..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!')


class EditPageTests(BaseTest):
    def _callFUT(self, request):
        from tutorial.views.default import edit_page
        return edit_page(request)

    def makeContext(self, page):
        from ..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!')

Open tutorial/tests/test_functional.py, and edit it such that it appears as follows:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
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')
    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_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)

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.

Running the tests

We can run these tests similarly to how we did in Run the tests:

On UNIX:

$ $VENV/bin/py.test -q

On Windows:

c:\pyramidtut\tutorial> %VENV%\Scripts\py.test -q

The expected result should look like the following:

......................
22 passed, 1 pytest-warnings in 5.81 seconds

Note

If you use Python 3 during this tutorial, you will see deprecation warnings in the output, which we will choose to ignore. In making this tutorial run on both Python 2 and 3, the authors prioritized simplicity and focus for the learner over accommodating warnings. In your own app or as extra credit, you may choose to either drop Python 2 support or hack your code to work without warnings on both Python 2 and 3.