├── .coveragerc ├── .github └── workflows │ ├── actions.yml │ └── lint.yml ├── .gitignore ├── AUTHORS.txt ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── docs ├── Makefile ├── auth_helpers.rst ├── cbvtestcase.rst ├── conf.py ├── disable_logging.rst ├── index.rst ├── low_query_counts.rst ├── make.bat ├── methods.rst ├── modules │ ├── modules.rst │ └── test_plus.rst └── usage.rst ├── noxfile.py ├── pytest.ini ├── setup.cfg ├── setup.py ├── test_plus ├── __init__.py ├── compat.py ├── plugin.py ├── runner.py ├── status_codes.py └── test.py └── test_project ├── manage.py ├── test_app ├── __init__.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── templates │ ├── form_errors.html │ ├── other.html │ └── test.html ├── tests │ ├── test_pytest.py │ └── test_unittests.py ├── urls.py └── views.py └── test_project ├── __init__.py ├── settings.py ├── templates ├── base.html └── registration │ └── login.html └── wsgi.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | test_project/* 4 | */.virtualenvs/* 5 | */site-packages/* 6 | test_plus/compat.py 7 | .eggs/* 8 | .tox/* -------------------------------------------------------------------------------- /.github/workflows/actions.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-20.04 12 | env: 13 | PYTHONDONTWRITEBYTECODE: true 14 | PYTHONPATH: test_project 15 | strategy: 16 | # By default, GitHub will maximize the number of jobs run in parallel 17 | # depending on the available runners on GitHub-hosted virtual machines. 18 | # max-parallel: 8 19 | fail-fast: false 20 | matrix: 21 | python-version: 22 | - "3.8" 23 | - "3.9" 24 | - "3.10" 25 | - "3.11" 26 | - "3.12" 27 | django-version: 28 | - "3.2" # LTS 29 | - "4.2" # LTS 30 | - "5.0" 31 | - "5.1" 32 | drf-version: 33 | - "" 34 | - "3.14" # only testing latest version for now 35 | exclude: 36 | # Python 3.11 is compatible with Django 4.0+ 37 | - python-version: "3.11" 38 | django-version: "3.2" 39 | # Python 3.12 is compatible with Django 4.0+ 40 | - python-version: "3.12" 41 | django-version: "3.2" 42 | # Django 5.0 is compatible with Python 3.10+ 43 | - python-version: "3.8" 44 | django-version: "5.0" 45 | - python-version: "3.9" 46 | django-version: "5.0" 47 | # Django 5.1 is compatible with Python 3.10+ 48 | - python-version: "3.8" 49 | django-version: "5.1" 50 | - python-version: "3.9" 51 | django-version: "5.1" 52 | 53 | steps: 54 | - uses: actions/checkout@v4 55 | 56 | - name: Set up Python ${{ matrix.python-version }} 57 | uses: actions/setup-python@v5 58 | with: 59 | python-version: ${{ matrix.python-version }} 60 | cache: "pip" 61 | cache-dependency-path: '**/setup.cfg' 62 | 63 | - name: Install dependencies 64 | run: | 65 | python -m pip install uv 66 | 67 | - name: Install Django ${{ matrix.django-version }} 68 | if: ${{ matrix.drf-version == '' }} 69 | run: | 70 | python -m uv pip install --system "Django~=${{ matrix.django-version }}.0" 71 | 72 | - name: Install DRF ${{ matrix.drf-version }} and Django ${{ matrix.django-version }} 73 | if: ${{ matrix.drf-version }} 74 | run: | 75 | python -m uv pip install --system pytz "djangorestframework~=${{ matrix.drf-version }}.0" "Django~=${{ matrix.django-version }}.0" 76 | 77 | - name: Install dependencies 78 | run: | 79 | python -m uv pip install --system -e ".[test]" 80 | 81 | - run: | 82 | pytest . 83 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-20.04 12 | env: 13 | PYTHONDONTWRITEBYTECODE: true 14 | PYTHONPATH: test_project 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Set up Python 3.10 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.10" 22 | cache: "pip" 23 | cache-dependency-path: '**/setup.cfg' 24 | 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install uv 28 | python -m uv pip install --system -e "." 29 | python -m uv pip install --system -e ".[test]" 30 | 31 | - name: Lint with flake8 32 | run: | 33 | flake8 . --ignore=E501,E402 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | -------------------------------------------------------------------------------- /AUTHORS.txt: -------------------------------------------------------------------------------- 1 | django-test-plus was originally created by Frank Wiles 2 | 3 | With contributions from: 4 | 5 | Graham Ullrich 6 | Brent O'Connor 7 | Gert Van Gool 8 | Daniel Roy Greenfeld 9 | Manu Phatak 10 | Andrew Pinkham 11 | Gary Reynolds 12 | Patrick Beeson 13 | Guinslym 14 | David Arcos - http://davidarcos.net 15 | Malik Junaid - https://www.facebook.com/malik.junaid27 16 | Jeff Triplett 17 | Fábio C. Barrionuevo da Lu 18 | Lacey Williams Henschel 19 | Anton - https://github.com/singleton11 20 | Natalia Bidart - https://github.com/nessita 21 | KNiski - https://github.com/KaczuH 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changes 2 | 3 | ## Version 2.2.4 - June 24th, 2024 4 | 5 | - Fix bug with APITest case 6 | 7 | ## Version 2.2.3 - July 11th, 2023 8 | 9 | - Fix bug where email addresses were not created by make_user() 10 | 11 | ## Version 2.2.2 - June 27, 2023 12 | 13 | - Fix issue with User creation helper when User model doesn't have a username field 14 | - Improve assertNumQueriesLessThan 15 | - Add assertInContext 16 | 17 | ## version 2.2.1 - October 12, 2022 18 | 19 | - Add Django 4.2 support 20 | 21 | ## version 2.2.0 - May 19th, 2021 22 | 23 | - Add support for Django 3.2. 24 | 25 | ## version 2.1.1 - May 19th, 2021 26 | 27 | - Add official support for Python 3.9. 28 | 29 | ## version 2.0.1 - May 19th, 2021 30 | 31 | - Make assertLoginRequired work for pytest tp fixture. 32 | 33 | ## version 2.0.0 - May 18th, 2021 34 | 35 | - Drops Python 2.7, 3.4, and pypy and Django 1.11 support. 36 | - Add Django 3.1 support. 37 | 38 | ## version 1.4.0 - December 3rd, 2019 39 | 40 | - Added Django 3.0 support 41 | - Misc dependency updates 42 | 43 | ## version 1.3.1 - July 31st, 2019 44 | 45 | - Made `make_user` and `get_instance` class based methods, so they can be used 46 | in `setupUpTestData`. Thanks @avelis for the report. 47 | 48 | ## version 1.3.0 - July 31st, 2019 49 | 50 | - Add `tp_api` pytest fixture. 51 | 52 | ## version 1.2.0 - May 5h, 2019 53 | 54 | - Add optional `msg` argument to assertEqual method. Thanks @davitovmasyan. 55 | 56 | ## version 1.1.1 - July 2nd, 2018 57 | 58 | - Fix premature loading of Django settings under pytest 59 | 60 | ## version 1.1.0 - May 20th, 2018 61 | 62 | - Added real pytest fixture support! 63 | - Stopped testing support below Django 1.11.x. django-test-plus should probably continue to work for a long time, but Django 1.11 is the only pre-2.x version that is still supported so all we are going to worry about. 64 | - Moved README and docs to Markdown 65 | 66 | ## version 1.0.22 - January 9th, 2018 67 | 68 | - Fix bug where we did not pass data dictionary to RequestFactory.get() properly 69 | 70 | ## version 1.0.21 - December 15th, 2017 71 | 72 | - Add response_204 method 73 | 74 | ## version 1.0.20 - October 31st, 2017 75 | 76 | - The Halloween Release! 77 | - Fixes to CI to ensure we really test Django 2.0 78 | 79 | ## version 1.0.19 - October 24th, 2017 80 | 81 | - Django 2.0 support 82 | - Dropped support for Python 3.3 83 | - Dropped support for Django < 1.8 84 | - Added APITestCase for better DRF testing 85 | 86 | ## version 1.0.18 - June 26th, 2017 87 | 88 | - Allow custom Request objects in get() and post() 89 | - Begin testing against Python 3.6 and Django 1.11 90 | 91 | ## version 1.0.17 - January 31st, 2017 92 | 93 | - Added assertResponseHeaders 94 | 95 | ## version 1.0.16 - October 19th, 2016 96 | 97 | - Added print_form_errors utility 98 | 99 | ## version 1.0.15 - August 18th, 2016 100 | 101 | - Added helper methods for more HTTP methods like put, patch, and trace 102 | - Added assertResponseContains and assertResponseNotContains 103 | 104 | ## version 1.0.14 - June 25th, 2016 105 | 106 | - Fixed documentation typo 107 | - Added response_400() test 108 | - Added Guinslym and David Arcos to AUTHORS.txt 109 | 110 | ## version 1.0.13 - May 23rd, 2016 111 | 112 | - Added response_401() test 113 | - Fixed situation where User models without a 'username' field could not be 114 | used as easily. Now credential field is automatically determined. 115 | - Fixed assertLoginRequired when settings.LOGIN_URL is a named URL pattern 116 | - Removed support for Django 1.4.x as it is well beyond it's end of life and causes a headache for supporting newer releases 117 | 118 | ## version 1.0.12 - March 4th, 2016 119 | 120 | - Fixed incorrect documentation 121 | - Added response_405 and response_410 test methods 122 | 123 | ## version 1.0.11 - November 11, 2015 124 | 125 | - Fixed bad README typos and merge artifacts 126 | 127 | ## version 1.0.10 - November 11, 2015 128 | 129 | - Added response_405() test 130 | - requirements.txt typo 131 | 132 | ## version 1.0.9 - August 28, 2015 133 | 134 | - README typo 135 | - Fix more bad argument handling in CBVTest methods 136 | - Fix alias issue with PyCharm 137 | 138 | ## version 1.0.8 - August 12, 2015 139 | 140 | - Bug fix with argument order 141 | 142 | ## version 1.0.7 - July 31st, 2015 143 | 144 | - get/post test methods now accept the `follow` boolean. 145 | 146 | ## version 1.0.6 - July 12th, 2015 147 | 148 | - Allow overriding password to be not just 'password' 149 | - Added CBVTestCase to be able to test generic CBVs without hitting routing or middleware 150 | 151 | ## version 1.0.5 - June 16th, 2015 152 | 153 | - Allow 'from test_plus import TestCase' 154 | - Make response_XXX() be able to use last_response 155 | - Add extra lazy option of passing full URL to get() and post() 156 | - Pass along QUERY_STRING information via data kwargs on gets() 157 | 158 | ## version 1.0.4 - May 29th, 2015 159 | 160 | - README formatting fixes 161 | - Added get_context() method 162 | - Added assertContext() method 163 | 164 | ## version 1.0.3 - May 28th, 2015 165 | 166 | - Added extras kwargs to be able to pass to url resolution 167 | - Added response_403 168 | - README typo 169 | 170 | ## version 1.0.2 - May 23rd, 2015 171 | 172 | - Actually fixing README by moving README.md to README.rst 173 | - Added docs for assertNuMQueriesLessThan() 174 | 175 | ## version 1.0.1 - May 23rd, 2015 176 | 177 | - Fixing README markdown on PyPI issue 178 | 179 | ## version 1.0.0 - May 23rd, 2015 180 | 181 | - Initial release 182 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Revolution Systems, LLC and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of django-test-plus nor the names of its contributors 15 | may be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE AUTHORS.txt CHANGELOG.md pytest.ini setup.cfg 2 | recursive-include tests *.py 3 | prune .eggs 4 | prune build 5 | prune dist 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | release: 2 | rm -rf build dist 3 | python setup.py sdist bdist_wheel 4 | git push --tags 5 | twine upload dist/* 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-test-plus 2 | 3 | Useful additions to Django's default TestCase from [REVSYS](https://www.revsys.com/) 4 | 5 | [![pypi](https://img.shields.io/pypi/v/django-test-plus.svg)](https://pypi.org/project/django-test-plus/) 6 | [![build matrix demo](https://github.com/revsys/django-test-plus/actions/workflows/actions.yml/badge.svg)](https://github.com/revsys/django-test-plus/actions/workflows/actions.yml) 7 | 8 | ## Rationale 9 | 10 | Let's face it, writing tests isn't always fun. Part of the reason for 11 | that is all of the boilerplate you end up writing. django-test-plus is 12 | an attempt to cut down on some of that when writing Django tests. We 13 | guarantee it will increase the time before you get carpal tunnel by at 14 | least 3 weeks! 15 | 16 | If you would like to get started testing your Django apps or improve how your 17 | team is testing we offer [TestStart](https://www.revsys.com/teststart/) 18 | to help your team dramatically improve your productivity. 19 | 20 | ## Support 21 | 22 | Supports: Python 3.8, 3.9, 3.10, 3.11, and 3.12. 23 | 24 | Supports Django Versions: 3.2, 4.2, 5.0, and 5.1. 25 | 26 | ## Documentation 27 | 28 | Full documentation is available at http://django-test-plus.readthedocs.org 29 | 30 | ## Installation 31 | 32 | ```shell 33 | $ pip install django-test-plus 34 | ``` 35 | 36 | ## Usage 37 | 38 | To use django-test-plus, have your tests inherit from test_plus.test.TestCase rather than the normal django.test.TestCase:: 39 | 40 | ```python 41 | from test_plus.test import TestCase 42 | 43 | class MyViewTests(TestCase): 44 | ... 45 | ``` 46 | 47 | This is sufficient to get things rolling, but you are encouraged to 48 | create *your own* sub-classes for your projects. This will allow you 49 | to add your own project-specific helper methods. 50 | 51 | For example, if you have a django project named 'myproject', you might 52 | create the following in `myproject/test.py`: 53 | 54 | ```python 55 | from test_plus.test import TestCase as PlusTestCase 56 | 57 | class TestCase(PlusTestCase): 58 | pass 59 | ``` 60 | 61 | And then in your tests use: 62 | 63 | ```python 64 | from myproject.test import TestCase 65 | 66 | class MyViewTests(TestCase): 67 | ... 68 | ``` 69 | 70 | This import, which is similar to the way you would import Django's TestCase, 71 | is also valid: 72 | 73 | ```python 74 | from test_plus import TestCase 75 | ``` 76 | 77 | ## pytest Usage 78 | 79 | You can get a TestCase like object as a pytest fixture now by asking for `tp`. All of the methods below would then work in pytest functions. For 80 | example: 81 | 82 | ```python 83 | def test_url_reverse(tp): 84 | expected_url = '/api/' 85 | reversed_url = tp.reverse('api') 86 | assert expected_url == reversed_url 87 | ``` 88 | 89 | The `tp_api` fixture will provide a `TestCase` that uses django-rest-framework's `APIClient()`: 90 | 91 | ```python 92 | def test_url_reverse(tp_api): 93 | response = tp_api.client.post("myapi", format="json") 94 | assert response.status_code == 200 95 | ``` 96 | 97 | ## Methods 98 | 99 | ### `reverse(url_name, *args, **kwargs)` 100 | 101 | When testing views you often find yourself needing to reverse the URL's name. With django-test-plus there is no need for the `from django.core.urlresolvers import reverse` boilerplate. Instead, use: 102 | 103 | ```python 104 | def test_something(self): 105 | url = self.reverse('my-url-name') 106 | slug_url = self.reverse('name-takes-a-slug', slug='my-slug') 107 | pk_url = self.reverse('name-takes-a-pk', pk=12) 108 | ``` 109 | 110 | As you can see our reverse also passes along any args or kwargs you need 111 | to pass in. 112 | 113 | ## `get(url_name, follow=True, *args, **kwargs)` 114 | 115 | Another thing you do often is HTTP get urls. Our `get()` method 116 | assumes you are passing in a named URL with any args or kwargs necessary 117 | to reverse the url_name. 118 | If needed, place kwargs for `TestClient.get()` in an 'extra' dictionary.: 119 | 120 | ```python 121 | def test_get_named_url(self): 122 | response = self.get('my-url-name') 123 | # Get XML data via AJAX request 124 | xml_response = self.get( 125 | 'my-url-name', 126 | extra={'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'}) 127 | ``` 128 | 129 | When using this get method two other things happen for you: we store the 130 | last response in `self.last_response` and the response's Context in `self.context`. 131 | 132 | So instead of: 133 | 134 | ```python 135 | def test_default_django(self): 136 | response = self.client.get(reverse('my-url-name')) 137 | self.assertTrue('foo' in response.context) 138 | self.assertEqual(response.context['foo'], 12) 139 | ``` 140 | 141 | You can write: 142 | 143 | ```python 144 | def test_testplus_get(self): 145 | self.get('my-url-name') 146 | self.assertInContext('foo') 147 | self.assertEqual(self.context['foo'], 12) 148 | ``` 149 | 150 | It's also smart about already reversed URLs, so you can be lazy and do: 151 | 152 | ```python 153 | def test_testplus_get(self): 154 | url = self.reverse('my-url-name') 155 | self.get(url) 156 | self.response_200() 157 | ``` 158 | 159 | If you need to pass query string parameters to your url name, you can do so like this. Assuming the name 'search' maps to '/search/' then: 160 | 161 | ```python 162 | def test_testplus_get_query(self): 163 | self.get('search', data={'query': 'testing'}) 164 | ``` 165 | 166 | Would GET `/search/?query=testing`. 167 | 168 | ## `post(url_name, data, follow=True, *args, **kwargs)` 169 | 170 | Our `post()` method takes a named URL, an optional dictionary of data you wish 171 | to post and any args or kwargs necessary to reverse the url_name. 172 | If needed, place kwargs for `TestClient.post()` in an 'extra' dictionary.: 173 | 174 | ```python 175 | def test_post_named_url(self): 176 | response = self.post('my-url-name', data={'coolness-factor': 11.0}, 177 | extra={'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'}) 178 | ``` 179 | 180 | *NOTE* Along with the frequently used get and post, we support all of the HTTP verbs such as put, patch, head, trace, options, and delete in the same fashion. 181 | 182 | ## `get_context(key)` 183 | 184 | Often you need to get things out of the template context: 185 | 186 | ```python 187 | def test_context_data(self): 188 | self.get('my-view-with-some-context') 189 | slug = self.get_context('slug') 190 | ``` 191 | 192 | ## `assertInContext(key)` 193 | 194 | You can ensure a specific key exists in the last response's context by 195 | using: 196 | 197 | ```python 198 | def test_in_context(self): 199 | self.get('my-view-with-some-context') 200 | self.assertInContext('some-key') 201 | ``` 202 | 203 | ## `assertContext(key, value)` 204 | 205 | We can get context values and ensure they exist, but we can also test 206 | equality while we're at it. This asserts that key == value: 207 | 208 | ```python 209 | def test_in_context(self): 210 | self.get('my-view-with-some-context') 211 | self.assertContext('some-key', 'expected value') 212 | ``` 213 | 214 | ## `assert_http_###_(response, msg=None)` - status code checking 215 | 216 | Another test you often need to do is check that a response has a certain 217 | HTTP status code. With Django's default TestCase you would write: 218 | 219 | ```python 220 | from django.core.urlresolvers import reverse 221 | 222 | def test_status(self): 223 | response = self.client.get(reverse('my-url-name')) 224 | self.assertEqual(response.status_code, 200) 225 | ``` 226 | 227 | With django-test-plus you can shorten that to be: 228 | 229 | ```python 230 | def test_better_status(self): 231 | response = self.get('my-url-name') 232 | self.assert_http_200_ok(response) 233 | ``` 234 | 235 | Django-test-plus provides a majority of the status codes assertions for you. The status assertions 236 | can be found in their own [mixin](https://github.com/revsys/django-test-plus/blob/main/test_plus/status_codes.py) 237 | and should be searchable if you're using an IDE like pycharm. It should be noted that in previous 238 | versions, django-test-plus had assertion methods in the pattern of `response_###()`, which are still 239 | available but have since been deprecated. See below for a list of those methods. 240 | 241 | Each of the assertion methods takes an optional Django test client `response` and a string `msg` argument 242 | that, if specified, is used as the error message when a failure occurs. The methods, 243 | `assert_http_301_moved_permanently` and `assert_http_302_found` also take an optional `url` argument that 244 | if passed, will check to make sure the `response.url` matches. 245 | 246 | If it's available, the `assert_http_###_` methods will use the last response. So you 247 | can do: 248 | 249 | ```python 250 | def test_status(self): 251 | self.get('my-url-name') 252 | self.assert_http_200_ok() 253 | ``` 254 | 255 | Which is a bit shorter. 256 | 257 | The `response_###()` methods that are deprecated, but still available for use, include: 258 | 259 | - `response_200()` 260 | - `response_201()` 261 | - `response_204()` 262 | - `response_301()` 263 | - `response_302()` 264 | - `response_400()` 265 | - `response_401()` 266 | - `response_403()` 267 | - `response_404()` 268 | - `response_405()` 269 | - `response_409()` 270 | - `response_410()` 271 | 272 | All of which take an optional Django test client response and a str msg argument that, if specified, is used as the error message when a failure occurs. Just like the `assert_http_###_()` methods, these methods will use the last response if it's available. 273 | 274 | ## `get_check_200(url_name, *args, **kwargs)` 275 | 276 | GETing and checking views return status 200 is a common test. This method makes it more convenient:: 277 | 278 | ```python 279 | def test_even_better_status(self): 280 | response = self.get_check_200('my-url-name') 281 | ``` 282 | 283 | ## make_user(username='testuser', password='password', perms=None) 284 | 285 | When testing out views you often need to create various users to ensure 286 | all of your logic is safe and sound. To make this process easier, this 287 | method will create a user for you: 288 | 289 | ```python 290 | def test_user_stuff(self) 291 | user1 = self.make_user('u1') 292 | user2 = self.make_user('u2') 293 | ``` 294 | 295 | If creating a User in your project is more complicated, say for example 296 | you removed the `username` field from the default Django Auth model, 297 | you can provide a [Factory 298 | Boy](https://factoryboy.readthedocs.org/en/latest/) factory to create 299 | it or override this method on your own sub-class. 300 | 301 | To use a Factory Boy factory, create your class like this:: 302 | 303 | ```python 304 | from test_plus.test import TestCase 305 | from .factories import UserFactory 306 | 307 | 308 | class MySpecialTest(TestCase): 309 | user_factory = UserFactory 310 | 311 | def test_special_creation(self): 312 | user1 = self.make_user('u1') 313 | ``` 314 | 315 | **NOTE:** Users created by this method will have their password 316 | set to the string 'password' by default, in order to ease testing. 317 | If you need a specific password, override the `password` parameter. 318 | 319 | You can also pass in user permissions by passing in a string of 320 | '`.`' or '`.*`'. For example: 321 | 322 | ```python 323 | user2 = self.make_user(perms=['myapp.create_widget', 'otherapp.*']) 324 | ``` 325 | 326 | ## `print_form_errors(response_or_form=None)` 327 | 328 | When debugging a failing test for a view with a form, this method helps you 329 | quickly look at any form errors. 330 | 331 | Example usage: 332 | 333 | ```python 334 | class MyFormTest(TestCase): 335 | 336 | self.post('my-url-name', data={}) 337 | self.print_form_errors() 338 | 339 | # or 340 | 341 | resp = self.post('my-url-name', data={}) 342 | self.print_form_errors(resp) 343 | 344 | # or 345 | 346 | form = MyForm(data={}) 347 | self.print_form_errors(form) 348 | ``` 349 | 350 | ## Authentication Helpers 351 | 352 | ### `assertLoginRequired(url_name, *args, **kwargs)` 353 | 354 | This method helps you test that a given named URL requires authorization: 355 | 356 | ```python 357 | def test_auth(self): 358 | self.assertLoginRequired('my-restricted-url') 359 | self.assertLoginRequired('my-restricted-object', pk=12) 360 | self.assertLoginRequired('my-restricted-object', slug='something') 361 | ``` 362 | 363 | ### `login()` context 364 | 365 | Along with ensuring a view requires login and creating users, the next 366 | thing you end up doing is logging in as various users to test your 367 | restriction logic: 368 | 369 | ```python 370 | def test_restrictions(self): 371 | user1 = self.make_user('u1') 372 | user2 = self.make_user('u2') 373 | 374 | self.assertLoginRequired('my-protected-view') 375 | 376 | with self.login(username=user1.username, password='password'): 377 | response = self.get('my-protected-view') 378 | # Test user1 sees what they should be seeing 379 | 380 | with self.login(username=user2.username, password='password'): 381 | response = self.get('my-protected-view') 382 | # Test user2 see what they should be seeing 383 | ``` 384 | 385 | Since we're likely creating our users using `make_user()` from above, 386 | the login context assumes the password is 'password' unless specified 387 | otherwise. Therefore you you can do: 388 | 389 | ```python 390 | def test_restrictions(self): 391 | user1 = self.make_user('u1') 392 | 393 | with self.login(username=user1.username): 394 | response = self.get('my-protected-view') 395 | ``` 396 | 397 | We can also derive the username if we're using `make_user()` so we can 398 | shorten that up even further like this: 399 | 400 | ```python 401 | def test_restrictions(self): 402 | user1 = self.make_user('u1') 403 | 404 | with self.login(user1): 405 | response = self.get('my-protected-view') 406 | ``` 407 | 408 | ## Ensuring low query counts 409 | 410 | ### `assertNumQueriesLessThan(number)` - context 411 | 412 | Django provides 413 | [`assertNumQueries`](https://docs.djangoproject.com/en/1.8/topics/testing/tools/#django.test.TransactionTestCase.assertNumQueries) 414 | which is great when your code generates a specific number of 415 | queries. However, if this number varies due to the nature of your data, with 416 | this method you can still test to ensure the code doesn't start producing a ton 417 | more queries than you expect: 418 | 419 | ```python 420 | def test_something_out(self): 421 | 422 | with self.assertNumQueriesLessThan(7): 423 | self.get('some-view-with-6-queries') 424 | ``` 425 | 426 | ### `assertGoodView(url_name, *args, **kwargs)` 427 | 428 | This method does a few things for you. It: 429 | 430 | - Retrieves the name URL 431 | - Ensures the view does not generate more than 50 queries 432 | - Ensures the response has status code 200 433 | - Returns the response 434 | 435 | Often a wide, sweeping test like this is better than no test at all. You 436 | can use it like this: 437 | 438 | ```python 439 | def test_better_than_nothing(self): 440 | response = self.assertGoodView('my-url-name') 441 | ``` 442 | 443 | ## Testing DRF views 444 | 445 | To take advantage of the convenience of DRF's test client, you can create a subclass of `TestCase` and set the `client_class` property: 446 | 447 | ```python 448 | from test_plus import TestCase 449 | from rest_framework.test import APIClient 450 | 451 | 452 | class APITestCase(TestCase): 453 | client_class = APIClient 454 | ``` 455 | 456 | For convenience, `test_plus` ships with `APITestCase`, which does just that: 457 | 458 | ```python 459 | from test_plus import APITestCase 460 | 461 | 462 | class MyAPITestCase(APITestCase): 463 | 464 | def test_post(self): 465 | data = {'testing': {'prop': 'value'}} 466 | self.post('view-json', data=data, extra={'format': 'json'}) 467 | self.assert_http_200_ok() 468 | ``` 469 | 470 | Note that using `APITestCase` requires Django >= 1.8 and having installed `django-rest-framework`. 471 | 472 | ## Testing class-based "generic" views 473 | 474 | The TestCase methods `get()` and `post()` work for both function-based 475 | and class-based views. However, in doing so they invoke Django's 476 | URL resolution, middleware, template processing, and decorator systems. 477 | For integration testing this is desirable, as you want to ensure your 478 | URLs resolve properly, view permissions are enforced, etc. 479 | For unit testing this is costly because all these Django request/response 480 | systems are invoked in addition to your method, and they typically do not 481 | affect the end result. 482 | 483 | Class-based views (derived from Django's `generic.models.View` class) 484 | contain methods and mixins which makes granular unit testing (more) feasible. 485 | Quite often your usage of a generic view class comprises an override 486 | of an existing method. Invoking the entire view and the Django request/response 487 | stack is a waste of time when you really want to call the overridden 488 | method directly and test the result. 489 | 490 | CBVTestCase to the rescue! 491 | 492 | As with TestCase above, have your tests inherit 493 | from test_plus.test.CBVTestCase rather than TestCase like so: 494 | 495 | ```python 496 | from test_plus.test import CBVTestCase 497 | 498 | class MyViewTests(CBVTestCase): 499 | ``` 500 | 501 | ## Methods 502 | 503 | ### `get_instance(cls, initkwargs=None, request=None, *args, **kwargs)` 504 | 505 | This core method simplifies the instantiation of your class, giving you 506 | a way to invoke class methods directly. 507 | 508 | Returns an instance of `cls`, initialized with `initkwargs`. 509 | Sets `request`, `args`, and `kwargs` attributes on the class instance. 510 | `args` and `kwargs` are the same values you would pass to `reverse()`. 511 | 512 | Sample usage: 513 | 514 | ```python 515 | from django.views import generic 516 | from test_plus.test import CBVTestCase 517 | 518 | class MyClass(generic.DetailView) 519 | 520 | def get_context_data(self, **kwargs): 521 | kwargs['answer'] = 42 522 | return kwargs 523 | 524 | class MyTests(CBVTestCase): 525 | 526 | def test_context_data(self): 527 | my_view = self.get_instance(MyClass, {'object': some_object}) 528 | context = my_view.get_context_data() 529 | self.assertEqual(context['answer'], 42) 530 | ``` 531 | 532 | ### `get(cls, initkwargs=None, *args, **kwargs)` 533 | 534 | Invokes `cls.get()` and returns the response, rendering template if possible. 535 | Builds on the `CBVTestCase.get_instance()` foundation. 536 | 537 | All test_plus.test.TestCase methods are valid, so the following works: 538 | 539 | ```python 540 | response = self.get(MyClass) 541 | self.assertContext('my_key', expected_value) 542 | ``` 543 | 544 | All test_plus TestCase side-effects are honored and all test_plus 545 | TestCase assertion methods work with `CBVTestCase.get()`. 546 | 547 | **NOTE:** This method bypasses Django's middleware, and therefore context 548 | variables created by middleware are not available. If this affects your 549 | template/context testing, you should use TestCase instead of CBVTestCase. 550 | 551 | ### `post(cls, data=None, initkwargs=None, *args, **kwargs)` 552 | 553 | Invokes `cls.post()` and returns the response, rendering template if possible. 554 | Builds on the `CBVTestCase.get_instance()` foundation. 555 | 556 | Example: 557 | 558 | ```python 559 | response = self.post(MyClass, data={'search_term': 'revsys'}) 560 | self.response_200(response) 561 | self.assertContext('company_name', 'RevSys') 562 | ``` 563 | 564 | All test_plus TestCase side-effects are honored and all test_plus 565 | TestCase assertion methods work with `CBVTestCase.post()`. 566 | 567 | **NOTE:** This method bypasses Django's middleware, and therefore context 568 | variables created by middleware are not available. If this affects your 569 | template/context testing you should use TestCase instead of CBVTestCase. 570 | 571 | ### `get_check_200(cls, initkwargs=None, *args, **kwargs)` 572 | 573 | Works just like `TestCase.get_check_200()`. 574 | Caller must provide a view class instead of a URL name or path parameter. 575 | 576 | All test_plus TestCase side-effects are honored and all test_plus 577 | TestCase assertion methods work with `CBVTestCase.post()`. 578 | 579 | ### `assertGoodView(cls, initkwargs=None, *args, **kwargs)` 580 | 581 | Works just like `TestCase.assertGoodView()`. 582 | Caller must provide a view class instead of a URL name or path parameter. 583 | 584 | All test_plus TestCase side-effects are honored and all test_plus 585 | TestCase assertion methods work with `CBVTestCase.post()`. 586 | 587 | ## Development 588 | 589 | To work on django-test-plus itself, clone this repository and run the following command: 590 | 591 | ```shell 592 | $ pip install -e . 593 | $ pip install -e .[test] 594 | ``` 595 | 596 | ## To run all tests: 597 | 598 | ```shell 599 | $ nox 600 | ``` 601 | 602 | **NOTE**: You will also need to ensure that the `test_project` directory, located 603 | at the root of this repo, is in your virtualenv's path. 604 | 605 | ## Keep in touch! 606 | 607 | If you have a question about this project, please open a GitHub issue. If you love us and want to keep track of our goings-on, here's where you can find us online: 608 | 609 | 610 | 611 | 612 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-test-plus.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-test-plus.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-test-plus" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-test-plus" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /docs/auth_helpers.rst: -------------------------------------------------------------------------------- 1 | Authentication Helpers 2 | ---------------------- 3 | 4 | assertLoginRequired(url\_name, \*args, \*\*kwargs) 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | 7 | This method helps you test that a given named URL requires authorization:: 8 | 9 | def test_auth(self): 10 | self.assertLoginRequired('my-restricted-url') 11 | self.assertLoginRequired('my-restricted-object', pk=12) 12 | self.assertLoginRequired('my-restricted-object', slug='something') 13 | 14 | login context 15 | ~~~~~~~~~~~~~ 16 | 17 | Along with ensuing a view requires login and creating users, the next 18 | thing you end up doing is logging in as various users to test our your 19 | restriction logic:: 20 | 21 | def test_restrictions(self): 22 | user1 = self.make_user('u1') 23 | user2 = self.make_user('u2') 24 | 25 | self.assertLoginRequired('my-protected-view') 26 | 27 | with self.login(username=user1.username, password='password'): 28 | response = self.get('my-protected-view') 29 | # Test user1 sees what they should be seeing 30 | 31 | with self.login(username=user2.username, password='password'): 32 | response = self.get('my-protected-view') 33 | # Test user2 see what they should be seeing 34 | 35 | Since we're likely creating our users using ``make_user()`` from above, 36 | the login context assumes the password is 'password' unless specified 37 | otherwise. Therefore you you can do:: 38 | 39 | def test_restrictions(self): 40 | user1 = self.make_user('u1') 41 | 42 | with self.login(username=user1.username): 43 | response = self.get('my-protected-view') 44 | 45 | We can also derive the username if we're using ``make_user()`` so we can 46 | shorten that up even further like this:: 47 | 48 | def test_restrictions(self): 49 | user1 = self.make_user('u1') 50 | 51 | with self.login(user1): 52 | response = self.get('my-protected-view') 53 | -------------------------------------------------------------------------------- /docs/cbvtestcase.rst: -------------------------------------------------------------------------------- 1 | Testing class-based "generic" views 2 | ===================================== 3 | 4 | The TestCase methods ``get()`` and ``post()`` work for both function-based 5 | and class-based views. However, in doing so they invoke Django's 6 | URL resolution, middleware, template processing, and decorator systems. 7 | For integration testing this is desirable, as you want to ensure your 8 | URLs resolve properly, view permissions are enforced, etc. 9 | For unit testing this is costly because all these Django request/response 10 | systems are invoked in addition to your method, and they typically do not 11 | affect the end result. 12 | 13 | Class-based views (derived from Django's ``generic.models.View`` class) 14 | contain methods and mixins which makes granular unit testing (more) feasible. 15 | Quite often usage of a generic view class comprises a simple method override. 16 | Invoking the entire view and the Django request/response stack is a waste of 17 | time... you really want to test the overridden method directly. 18 | 19 | CBVTestCase to the rescue! 20 | 21 | As with TestCase above, have your tests inherit 22 | from test\_plus.test.CBVTestCase rather than TestCase like so:: 23 | 24 | from test_plus.test import CBVTestCase 25 | 26 | class MyViewTests(CBVTestCase): 27 | 28 | Methods 29 | ------- 30 | 31 | get_instance(cls, initkwargs=None, request=None, \*args, \*\*kwargs) 32 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 33 | 34 | This core method simplifies the instantiation of your class, giving you 35 | a way to invoke class methods directly. 36 | 37 | Returns an instance of ``cls``, initialized with ``initkwargs``. 38 | Sets ``request``, ``args``, and ``kwargs`` attributes on the class instance. 39 | ``args`` and ``kwargs`` are the same values you would pass to ``reverse()``. 40 | 41 | Sample usage:: 42 | 43 | from django.views import generic 44 | from test_plus.test import CBVTestCase 45 | 46 | class MyViewClass(generic.DetailView) 47 | 48 | def get_context_data(self, **kwargs): 49 | kwargs = super(MyViewClass, self).get_context_data(**kwargs) 50 | if hasattr(self.request, 'some_data'): 51 | kwargs.update({ 52 | 'some_data': self.request.some_data 53 | }) 54 | if hasattr(self, 'special_value'): 55 | kwargs.update({ 56 | 'special_value': self.special_value 57 | }) 58 | return kwargs 59 | 60 | class MyViewTests(CBVTestCase): 61 | 62 | def test_context_data(self): 63 | my_view = self.get_instance(MyViewClass, initkwargs={'special_value': 42}) 64 | context = my_view.get_context_data() 65 | self.assertContext('special_value', 42) 66 | 67 | def test_request_attribute(self): 68 | request = django.test.RequestFactory().get('/') 69 | request.some_data = 5 70 | my_view = self.get_instance(MyViewClass, request=request) 71 | context = my_view.get_context_data() 72 | self.assertContext('some_data', 5) 73 | 74 | get(cls, \*args, \*\*kwargs) 75 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 76 | 77 | Invokes ``cls.get()`` and returns the response, rendering template if possible. 78 | Builds on the ``CBVTestCase.get_instance()`` foundation. 79 | 80 | All test\_plus.test.TestCase methods are valid, so the following works:: 81 | 82 | response = self.get(MyViewClass) 83 | self.assertContext('my_key', expected_value) 84 | 85 | All test\_plus TestCase side-effects are honored and all test\_plus 86 | TestCase assertion methods work with ``CBVTestCase.get()``. 87 | 88 | If you need special request attributes, i.e. 'user', you can create a 89 | custom Request with RequestFactory, assign to ``request.user``, 90 | and use that in the ``get()``:: 91 | 92 | def test_request_attribute(self): 93 | request = django.test.RequestFactory().get('/') 94 | request.user = some_user 95 | self.get(MyViewClass, request=request, pk=data.pk) 96 | self.assertContext('user', some_user) 97 | 98 | **NOTE:** This method bypasses Django's middleware, and therefore context 99 | variables created by middleware are not available. If this affects your 100 | template/context testing you should use ``TestCase`` instead of ``CBVTestCase``. 101 | 102 | post(cls, \*args, \*\*kwargs) 103 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 104 | 105 | Invokes ``cls.post()`` and returns the response, rendering template if possible. 106 | Builds on the ``CBVTestCase.get_instance()`` foundation. 107 | 108 | Example:: 109 | 110 | response = self.post(MyViewClass, data={'search_term': 'revsys'}) 111 | self.response_200(response) 112 | self.assertContext('company_name', 'RevSys') 113 | 114 | All test\_plus TestCase side-effects are honored and all test\_plus 115 | TestCase assertion methods work with ``CBVTestCase.post()``. 116 | 117 | If you need special request attributes, i.e. 'user', you can create a 118 | custom Request with RequestFactory, assign to ``request.user``, 119 | and use that in the ``post()``:: 120 | 121 | def test_request_attribute(self): 122 | request = django.test.RequestFactory().post('/') 123 | request.user = some_user 124 | self.post(MyViewClass, request=request, pk=self.data.pk, data={}) 125 | self.assertContext('user', some_user) 126 | 127 | **NOTE:** This method bypasses Django's middleware, and therefore context 128 | variables created by middleware are not available. If this affects your 129 | template/context testing you should use ``TestCase`` instead of ``CBVTestCase``. 130 | 131 | get_check_200(cls, initkwargs=None, \*args, \*\*kwargs) 132 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 133 | 134 | Works just like ``TestCase.get_check_200()``. 135 | Caller must provide a view class instead of a URL name or path parameter. 136 | 137 | All test\_plus TestCase side-effects are honored and all test\_plus 138 | TestCase assertion methods work with ``CBVTestCase.post()``. 139 | 140 | assertGoodView(cls, initkwargs=None, \*args, \*\*kwargs) 141 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 142 | 143 | Works just like ``TestCase.assertGoodView()``. 144 | Caller must provide a view class instead of a URL name or path parameter. 145 | 146 | All test\_plus TestCase side-effects are honored and all test\_plus 147 | TestCase assertion methods work with ``CBVTestCase.post()``. 148 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # django-test-plus documentation build configuration file, created by 5 | # sphinx-quickstart on Wed Nov 4 19:30:39 2015. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | import sys 17 | import os 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | sys.path.insert(0, os.path.abspath('../')) 23 | sys.path.insert(0, os.path.abspath('../test_project/')) 24 | 25 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 26 | import django 27 | django.setup() 28 | 29 | # -- General configuration ------------------------------------------------ 30 | 31 | # If your documentation needs a minimal Sphinx version, state it here. 32 | # needs_sphinx = '1.0' 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 36 | # ones. 37 | extensions = [ 38 | 'sphinx.ext.autodoc', 39 | 'sphinx.ext.viewcode', 40 | ] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | # The suffix(es) of source filenames. 46 | # You can specify multiple suffix as a list of string: 47 | # source_suffix = ['.rst', '.md'] 48 | source_suffix = '.rst' 49 | 50 | # The encoding of source files. 51 | # source_encoding = 'utf-8-sig' 52 | 53 | # The master toctree document. 54 | master_doc = 'index' 55 | 56 | # General information about the project. 57 | project = 'django-test-plus' 58 | copyright = '2015, Frank Wiles' 59 | author = 'Frank Wiles' 60 | 61 | # The version info for the project you're documenting, acts as replacement for 62 | # |version| and |release|, also used in various other places throughout the 63 | # built documents. 64 | # 65 | # The short X.Y version. 66 | version = '2.2.3' 67 | # The full version, including alpha/beta/rc tags. 68 | release = version 69 | 70 | # The language for content autogenerated by Sphinx. Refer to documentation 71 | # for a list of supported languages. 72 | # 73 | # This is also used if you do content translation via gettext catalogs. 74 | # Usually you set "language" from the command line for these cases. 75 | language = None 76 | 77 | # There are two options for replacing |today|: either, you set today to some 78 | # non-false value, then it is used: 79 | # today = '' 80 | # Else, today_fmt is used as the format for a strftime call. 81 | # today_fmt = '%B %d, %Y' 82 | 83 | # List of patterns, relative to source directory, that match files and 84 | # directories to ignore when looking for source files. 85 | exclude_patterns = ['_build'] 86 | 87 | # The reST default role (used for this markup: `text`) to use for all 88 | # documents. 89 | # default_role = None 90 | 91 | # If true, '()' will be appended to :func: etc. cross-reference text. 92 | # add_function_parentheses = True 93 | 94 | # If true, the current module name will be prepended to all description 95 | # unit titles (such as .. function::). 96 | # add_module_names = True 97 | 98 | # If true, sectionauthor and moduleauthor directives will be shown in the 99 | # output. They are ignored by default. 100 | # show_authors = False 101 | 102 | # The name of the Pygments (syntax highlighting) style to use. 103 | pygments_style = 'sphinx' 104 | 105 | # A list of ignored prefixes for module index sorting. 106 | # modindex_common_prefix = [] 107 | 108 | # If true, keep warnings as "system message" paragraphs in the built documents. 109 | # keep_warnings = False 110 | 111 | # If true, `todo` and `todoList` produce output, else they produce nothing. 112 | todo_include_todos = False 113 | 114 | 115 | # -- Options for HTML output ---------------------------------------------- 116 | 117 | # The theme to use for HTML and HTML Help pages. See the documentation for 118 | # a list of builtin themes. 119 | html_theme = 'alabaster' 120 | 121 | # Theme options are theme-specific and customize the look and feel of a theme 122 | # further. For a list of options available for each theme, see the 123 | # documentation. 124 | # html_theme_options = {} 125 | 126 | # Add any paths that contain custom themes here, relative to this directory. 127 | # html_theme_path = [] 128 | 129 | # The name for this set of Sphinx documents. If None, it defaults to 130 | # " v documentation". 131 | # html_title = None 132 | 133 | # A shorter title for the navigation bar. Default is the same as html_title. 134 | # html_short_title = None 135 | 136 | # The name of an image file (relative to this directory) to place at the top 137 | # of the sidebar. 138 | # html_logo = None 139 | 140 | # The name of an image file (within the static path) to use as favicon of the 141 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 142 | # pixels large. 143 | # html_favicon = None 144 | 145 | # Add any paths that contain custom static files (such as style sheets) here, 146 | # relative to this directory. They are copied after the builtin static files, 147 | # so a file named "default.css" will overwrite the builtin "default.css". 148 | html_static_path = ['_static'] 149 | 150 | # Add any extra paths that contain custom files (such as robots.txt or 151 | # .htaccess) here, relative to this directory. These files are copied 152 | # directly to the root of the documentation. 153 | # html_extra_path = [] 154 | 155 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 156 | # using the given strftime format. 157 | # html_last_updated_fmt = '%b %d, %Y' 158 | 159 | # If true, SmartyPants will be used to convert quotes and dashes to 160 | # typographically correct entities. 161 | # html_use_smartypants = True 162 | 163 | # Custom sidebar templates, maps document names to template names. 164 | # html_sidebars = {} 165 | 166 | # Additional templates that should be rendered to pages, maps page names to 167 | # template names. 168 | # html_additional_pages = {} 169 | 170 | # If false, no module index is generated. 171 | # html_domain_indices = True 172 | 173 | # If false, no index is generated. 174 | # html_use_index = True 175 | 176 | # If true, the index is split into individual pages for each letter. 177 | # html_split_index = False 178 | 179 | # If true, links to the reST sources are added to the pages. 180 | # html_show_sourcelink = True 181 | 182 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 183 | # html_show_sphinx = True 184 | 185 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 186 | # html_show_copyright = True 187 | 188 | # If true, an OpenSearch description file will be output, and all pages will 189 | # contain a tag referring to it. The value of this option must be the 190 | # base URL from which the finished HTML is served. 191 | # html_use_opensearch = '' 192 | 193 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 194 | # html_file_suffix = None 195 | 196 | # Language to be used for generating the HTML full-text search index. 197 | # Sphinx supports the following languages: 198 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 199 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' 200 | # html_search_language = 'en' 201 | 202 | # A dictionary with options for the search language support, empty by default. 203 | # Now only 'ja' uses this config value 204 | # html_search_options = {'type': 'default'} 205 | 206 | # The name of a javascript file (relative to the configuration directory) that 207 | # implements a search results scorer. If empty, the default will be used. 208 | # html_search_scorer = 'scorer.js' 209 | 210 | # Output file base name for HTML help builder. 211 | htmlhelp_basename = 'django-test-plusdoc' 212 | 213 | # -- Options for LaTeX output --------------------------------------------- 214 | 215 | latex_elements = { 216 | # The paper size ('letterpaper' or 'a4paper'). 217 | # 'papersize': 'letterpaper', 218 | 219 | # The font size ('10pt', '11pt' or '12pt'). 220 | # 'pointsize': '10pt', 221 | 222 | # Additional stuff for the LaTeX preamble. 223 | # 'preamble': '', 224 | 225 | # Latex figure (float) alignment 226 | # 'figure_align': 'htbp', 227 | } 228 | 229 | # Grouping the document tree into LaTeX files. List of tuples 230 | # (source start file, target name, title, 231 | # author, documentclass [howto, manual, or own class]). 232 | latex_documents = [ 233 | (master_doc, 'django-test-plus.tex', 'django-test-plus Documentation', 234 | 'Frank Wiles', 'manual'), 235 | ] 236 | 237 | # The name of an image file (relative to this directory) to place at the top of 238 | # the title page. 239 | # latex_logo = None 240 | 241 | # For "manual" documents, if this is true, then toplevel headings are parts, 242 | # not chapters. 243 | # latex_use_parts = False 244 | 245 | # If true, show page references after internal links. 246 | # latex_show_pagerefs = False 247 | 248 | # If true, show URL addresses after external links. 249 | # latex_show_urls = False 250 | 251 | # Documents to append as an appendix to all manuals. 252 | # latex_appendices = [] 253 | 254 | # If false, no module index is generated. 255 | # latex_domain_indices = True 256 | 257 | 258 | # -- Options for manual page output --------------------------------------- 259 | 260 | # One entry per manual page. List of tuples 261 | # (source start file, name, description, authors, manual section). 262 | man_pages = [ 263 | (master_doc, 'django-test-plus', 'django-test-plus Documentation', 264 | [author], 1) 265 | ] 266 | 267 | # If true, show URL addresses after external links. 268 | # man_show_urls = False 269 | 270 | 271 | # -- Options for Texinfo output ------------------------------------------- 272 | 273 | # Grouping the document tree into Texinfo files. List of tuples 274 | # (source start file, target name, title, author, 275 | # dir menu entry, description, category) 276 | texinfo_documents = [ 277 | (master_doc, 'django-test-plus', 'django-test-plus Documentation', 278 | author, 'django-test-plus', 'One line description of project.', 279 | 'Miscellaneous'), 280 | ] 281 | 282 | # Documents to append as an appendix to all manuals. 283 | # texinfo_appendices = [] 284 | 285 | # If false, no module index is generated. 286 | # texinfo_domain_indices = True 287 | 288 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 289 | # texinfo_show_urls = 'footnote' 290 | 291 | # If true, do not generate a @detailmenu in the "Top" node's menu. 292 | # texinfo_no_detailmenu = False 293 | 294 | 295 | # -- Options for Epub output ---------------------------------------------- 296 | 297 | # Bibliographic Dublin Core info. 298 | epub_title = project 299 | epub_author = author 300 | epub_publisher = author 301 | epub_copyright = copyright 302 | 303 | # The basename for the epub file. It defaults to the project name. 304 | # epub_basename = project 305 | 306 | # The HTML theme for the epub output. Since default themes are not optimized 307 | # for small screen space, using the same theme for HTML and epub output is 308 | # usually not wise. This defaults to 'epub', a theme designed to save visual 309 | # space. 310 | # epub_theme = 'epub' 311 | 312 | # The language of the text. It defaults to the language option 313 | # or 'en' if the language is not set. 314 | # epub_language = '' 315 | 316 | # The scheme of the identifier. Typical schemes are ISBN or URL. 317 | # epub_scheme = '' 318 | 319 | # The unique identifier of the text. This can be a ISBN number 320 | # or the project homepage. 321 | # epub_identifier = '' 322 | 323 | # A unique identification for the text. 324 | # epub_uid = '' 325 | 326 | # A tuple containing the cover image and cover page html template filenames. 327 | # epub_cover = () 328 | 329 | # A sequence of (type, uri, title) tuples for the guide element of content.opf. 330 | # epub_guide = () 331 | 332 | # HTML files that should be inserted before the pages created by sphinx. 333 | # The format is a list of tuples containing the path and title. 334 | # epub_pre_files = [] 335 | 336 | # HTML files that should be inserted after the pages created by sphinx. 337 | # The format is a list of tuples containing the path and title. 338 | # epub_post_files = [] 339 | 340 | # A list of files that should not be packed into the epub file. 341 | epub_exclude_files = ['search.html'] 342 | 343 | # The depth of the table of contents in toc.ncx. 344 | # epub_tocdepth = 3 345 | 346 | # Allow duplicate toc entries. 347 | # epub_tocdup = True 348 | 349 | # Choose between 'default' and 'includehidden'. 350 | # epub_tocscope = 'default' 351 | 352 | # Fix unsupported image types using the Pillow. 353 | # epub_fix_images = False 354 | 355 | # Scale large images. 356 | # epub_max_image_width = 0 357 | 358 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 359 | # epub_show_urls = 'inline' 360 | 361 | # If false, no index is generated. 362 | # epub_use_index = True 363 | -------------------------------------------------------------------------------- /docs/disable_logging.rst: -------------------------------------------------------------------------------- 1 | Disable logging 2 | --------------- 3 | 4 | You can disable logging during testing by changing the `TEST\_RUNNER 5 | `_ 6 | in your settings file to:: 7 | 8 | TEST_RUNNER = 'test_plus.runner.NoLoggingRunner' 9 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. django-test-plus documentation master file, created by 2 | sphinx-quickstart on Wed Nov 4 19:30:39 2015. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to django-test-plus's documentation! 7 | ============================================ 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | usage 15 | methods 16 | auth_helpers 17 | low_query_counts 18 | cbvtestcase 19 | disable_logging 20 | 21 | 22 | 23 | Indices and tables 24 | ================== 25 | 26 | * :ref:`genindex` 27 | * :ref:`modindex` 28 | * :ref:`search` 29 | -------------------------------------------------------------------------------- /docs/low_query_counts.rst: -------------------------------------------------------------------------------- 1 | Ensuring low query counts 2 | ------------------------- 3 | 4 | assertNumQueriesLessThan(number) - context 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | 7 | Django provides 8 | `assertNumQueries `__ 9 | which is great when your code generates a specific number of 10 | queries. However, if this number varies due to the nature of your data, 11 | with this method you can still test to ensure the code doesn't start producing a ton 12 | more queries than you expect:: 13 | 14 | def test_something_out(self): 15 | 16 | with self.assertNumQueriesLessThan(7): 17 | self.get('some-view-with-6-queries') 18 | 19 | 20 | assertGoodView(url\_name, \*args, \*\*kwargs) 21 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 22 | 23 | This method does a few things for you. It: 24 | 25 | - Retrieves the name URL 26 | - Ensures the view does not generate more than 50 queries 27 | - Ensures the response has status code 200 28 | - Returns the response 29 | 30 | Often a wide, sweeping test like this is better than no test at all. You 31 | can use it like this:: 32 | 33 | def test_better_than_nothing(self): 34 | response = self.assertGoodView('my-url-name') 35 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | echo. coverage to run coverage check of the documentation if enabled 41 | goto end 42 | ) 43 | 44 | if "%1" == "clean" ( 45 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 46 | del /q /s %BUILDDIR%\* 47 | goto end 48 | ) 49 | 50 | 51 | REM Check if sphinx-build is available and fallback to Python version if any 52 | %SPHINXBUILD% 2> nul 53 | if errorlevel 9009 goto sphinx_python 54 | goto sphinx_ok 55 | 56 | :sphinx_python 57 | 58 | set SPHINXBUILD=python -m sphinx.__init__ 59 | %SPHINXBUILD% 2> nul 60 | if errorlevel 9009 ( 61 | echo. 62 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 63 | echo.installed, then set the SPHINXBUILD environment variable to point 64 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 65 | echo.may add the Sphinx directory to PATH. 66 | echo. 67 | echo.If you don't have Sphinx installed, grab it from 68 | echo.http://sphinx-doc.org/ 69 | exit /b 1 70 | ) 71 | 72 | :sphinx_ok 73 | 74 | 75 | if "%1" == "html" ( 76 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 80 | goto end 81 | ) 82 | 83 | if "%1" == "dirhtml" ( 84 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 88 | goto end 89 | ) 90 | 91 | if "%1" == "singlehtml" ( 92 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 93 | if errorlevel 1 exit /b 1 94 | echo. 95 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 96 | goto end 97 | ) 98 | 99 | if "%1" == "pickle" ( 100 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 101 | if errorlevel 1 exit /b 1 102 | echo. 103 | echo.Build finished; now you can process the pickle files. 104 | goto end 105 | ) 106 | 107 | if "%1" == "json" ( 108 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 109 | if errorlevel 1 exit /b 1 110 | echo. 111 | echo.Build finished; now you can process the JSON files. 112 | goto end 113 | ) 114 | 115 | if "%1" == "htmlhelp" ( 116 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 117 | if errorlevel 1 exit /b 1 118 | echo. 119 | echo.Build finished; now you can run HTML Help Workshop with the ^ 120 | .hhp project file in %BUILDDIR%/htmlhelp. 121 | goto end 122 | ) 123 | 124 | if "%1" == "qthelp" ( 125 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 129 | .qhcp project file in %BUILDDIR%/qthelp, like this: 130 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-test-plus.qhcp 131 | echo.To view the help file: 132 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-test-plus.ghc 133 | goto end 134 | ) 135 | 136 | if "%1" == "devhelp" ( 137 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. 141 | goto end 142 | ) 143 | 144 | if "%1" == "epub" ( 145 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 149 | goto end 150 | ) 151 | 152 | if "%1" == "latex" ( 153 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 157 | goto end 158 | ) 159 | 160 | if "%1" == "latexpdf" ( 161 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 162 | cd %BUILDDIR%/latex 163 | make all-pdf 164 | cd %~dp0 165 | echo. 166 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdfja" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf-ja 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "text" ( 181 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 182 | if errorlevel 1 exit /b 1 183 | echo. 184 | echo.Build finished. The text files are in %BUILDDIR%/text. 185 | goto end 186 | ) 187 | 188 | if "%1" == "man" ( 189 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 190 | if errorlevel 1 exit /b 1 191 | echo. 192 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 193 | goto end 194 | ) 195 | 196 | if "%1" == "texinfo" ( 197 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 198 | if errorlevel 1 exit /b 1 199 | echo. 200 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 201 | goto end 202 | ) 203 | 204 | if "%1" == "gettext" ( 205 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 206 | if errorlevel 1 exit /b 1 207 | echo. 208 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 209 | goto end 210 | ) 211 | 212 | if "%1" == "changes" ( 213 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 214 | if errorlevel 1 exit /b 1 215 | echo. 216 | echo.The overview file is in %BUILDDIR%/changes. 217 | goto end 218 | ) 219 | 220 | if "%1" == "linkcheck" ( 221 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 222 | if errorlevel 1 exit /b 1 223 | echo. 224 | echo.Link check complete; look for any errors in the above output ^ 225 | or in %BUILDDIR%/linkcheck/output.txt. 226 | goto end 227 | ) 228 | 229 | if "%1" == "doctest" ( 230 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 231 | if errorlevel 1 exit /b 1 232 | echo. 233 | echo.Testing of doctests in the sources finished, look at the ^ 234 | results in %BUILDDIR%/doctest/output.txt. 235 | goto end 236 | ) 237 | 238 | if "%1" == "coverage" ( 239 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 240 | if errorlevel 1 exit /b 1 241 | echo. 242 | echo.Testing of coverage in the sources finished, look at the ^ 243 | results in %BUILDDIR%/coverage/python.txt. 244 | goto end 245 | ) 246 | 247 | if "%1" == "xml" ( 248 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 249 | if errorlevel 1 exit /b 1 250 | echo. 251 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 252 | goto end 253 | ) 254 | 255 | if "%1" == "pseudoxml" ( 256 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 257 | if errorlevel 1 exit /b 1 258 | echo. 259 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 260 | goto end 261 | ) 262 | 263 | :end 264 | -------------------------------------------------------------------------------- /docs/methods.rst: -------------------------------------------------------------------------------- 1 | Methods 2 | ------- 3 | 4 | reverse(url\_name, \*args, \*\*kwargs) 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | 7 | When testing views you often find yourself needing to reverse the URL's name. With django-test-plus there is no need for the ``from django.core.urlresolvers import reverse`` boilerplate. Instead, use:: 8 | 9 | def test_something(self): 10 | url = self.reverse('my-url-name') 11 | slug_url = self.reverse('name-takes-a-slug', slug='my-slug') 12 | pk_url = self.reverse('name-takes-a-pk', pk=12) 13 | 14 | As you can see our reverse also passes along any args or kwargs you need 15 | to pass in. 16 | 17 | get(url\_name, follow=False, \*args, \*\*kwargs) 18 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 19 | 20 | Another thing you do often is HTTP get urls. Our ``get()`` method 21 | assumes you are passing in a named URL with any args or kwargs necessary 22 | to reverse the url\_name. 23 | If needed, place kwargs for ``TestClient.get()`` in an 'extra' dictionary.:: 24 | 25 | def test_get_named_url(self): 26 | response = self.get('my-url-name') 27 | # Get XML data via AJAX request 28 | xml_response = self.get( 29 | 'my-url-name', 30 | extra={'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'}) 31 | 32 | When using this get method two other things happen for you: we store the 33 | last response in ``self.last\_response`` and the response's Context in ``self.context``. 34 | So instead of:: 35 | 36 | def test_default_django(self): 37 | response = self.client.get(reverse('my-url-name')) 38 | self.assertTrue('foo' in response.context) 39 | self.assertEqual(response.context['foo'], 12) 40 | 41 | You can write:: 42 | 43 | def test_testplus_get(self): 44 | self.get('my-url-name') 45 | self.assertInContext('foo') 46 | self.assertEqual(self.context['foo'], 12) 47 | 48 | It's also smart about already reversed URLs, so you can be lazy and do:: 49 | 50 | def test_testplus_get(self): 51 | url = self.reverse('my-url-name') 52 | self.get(url) 53 | self.response_200() 54 | 55 | If you need to pass query string parameters to your url name, you can do so like this. Assuming the name 'search' maps to '/search/' then:: 56 | 57 | def test_testplus_get_query(self): 58 | self.get('search', data={'query': 'testing'}) 59 | 60 | Would GET /search/?query=testing 61 | 62 | post(url\_name, follow=False, \*args, \*\*kwargs) 63 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 64 | 65 | Our ``post()`` method takes a named URL, an optional dictionary of data you wish 66 | to post and any args or kwargs necessary to reverse the url\_name. 67 | If needed, place kwargs for ``TestClient.post()`` in an 'extra' dictionary.:: 68 | 69 | def test_post_named_url(self): 70 | response = self.post('my-url-name', data={'coolness-factor': 11.0}, 71 | extra={'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'}) 72 | 73 | 74 | put(url\_name, follow=False, \*args, \*\*kwargs) 75 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 76 | 77 | To support all HTTP methods 78 | 79 | patch(url\_name, follow=False, \*args, \*\*kwargs) 80 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 81 | 82 | To support all HTTP methods 83 | 84 | head(url\_name, follow=False, \*args, \*\*kwargs) 85 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 86 | 87 | To support all HTTP methods 88 | 89 | trace(url\_name, follow=False, \*args, \*\*kwargs) 90 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 91 | 92 | To support all HTTP methods 93 | 94 | options(url\_name, follow=False, \*args, \*\*kwargs) 95 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 96 | 97 | To support all HTTP methods 98 | 99 | delete(url\_name, follow=False, \*args, \*\*kwargs) 100 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 101 | 102 | To support all HTTP methods 103 | 104 | get_context(key) 105 | ~~~~~~~~~~~~~~~~ 106 | 107 | Often you need to get things out of the template context:: 108 | 109 | def test_context_data(self): 110 | self.get('my-view-with-some-context') 111 | slug = self.get_context('slug') 112 | 113 | assertInContext(key) 114 | ~~~~~~~~~~~~~~~~~~~~ 115 | 116 | You can ensure a specific key exists in the last response's context by 117 | using:: 118 | 119 | def test_in_context(self): 120 | self.get('my-view-with-some-context') 121 | self.assertInContext('some-key') 122 | 123 | assertContext(key, value) 124 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 125 | 126 | We can get context values and ensure they exist, but we can also test 127 | equality while we're at it. This asserts that key == value:: 128 | 129 | def test_in_context(self): 130 | self.get('my-view-with-some-context') 131 | self.assertContext('some-key', 'expected value') 132 | 133 | assert\_http\_XXX_\(response, msg=None) - status code checking 134 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 135 | 136 | Another test you often need to do is check that a response has a certain 137 | HTTP status code. With Django's default TestCase you would write:: 138 | 139 | from django.core.urlresolvers import reverse 140 | 141 | def test_status(self): 142 | response = self.client.get(reverse('my-url-name')) 143 | self.assertEqual(response.status_code, 200) 144 | 145 | With django-test-plus you can shorten that to be:: 146 | 147 | def test_better_status(self): 148 | response = self.get('my-url-name') 149 | self.assert_http_200_ok(response) 150 | 151 | Django-test-plus provides a majority of the status codes assertions for you. The status assertions can be found in their own `mixin `__ and should be searchable if you're using an IDE like pycharm. It should be noted that in previous versions, django-test-plus had assertion methods in the pattern of ``response_###()``, which are still available but have since been deprecated. See below for a list of those methods. 152 | 153 | Each of the assertion methods takes an optional Django test client ``response`` and a string ``msg`` argument that, if specified, is used as the error message when a failure occurs. The methods, ``assert_http_301_moved_permanently`` and ``assert_http_302_found`` also take an optional ``url`` argument that if passed, will check to make sure the ``response.url`` matches. 154 | 155 | If it's available, the ``assert_http_###_`` methods will use the last response. So you can do::: 156 | 157 | def test_status(self): 158 | self.get('my-url-name') 159 | self.assert_http_200_ok() 160 | 161 | Which is a bit shorter. 162 | 163 | The ``response_###()`` methods that are deprecated, but still available for use, include: 164 | 165 | - ``response_200()`` 166 | - ``response_201()`` 167 | - ``response_204()`` 168 | - ``response_301()`` 169 | - ``response_302()`` 170 | - ``response_400()`` 171 | - ``response_401()`` 172 | - ``response_403()`` 173 | - ``response_404()`` 174 | - ``response_405()`` 175 | - ``response_410()`` 176 | 177 | All of which take an optional Django test client response and a str msg argument that, if specified, is used as the error message when a failure occurs. Just like the ``assert_http_###_()`` methods, these methods will use the last response if it's available. 178 | 179 | assertResponseContains(text, response=None, html=True) 180 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 181 | 182 | You often want to check that the last response contains a chunk of HTML. With 183 | Django's default TestCase you would write:: 184 | 185 | from django.core.urlresolvers import reverse 186 | 187 | def test_response_contains(self): 188 | response = self.client.get(reverse('hello-world')) 189 | self.assertContains(response, '

Hello, World!

', html=True) 190 | 191 | With django-test-plus you can shorten that to be:: 192 | 193 | def test_response_contains(self): 194 | self.get('hello-world') 195 | self.assertResponseContains('

Hello, World!

') 196 | 197 | assertResponseNotContains(text, response=None, html=True) 198 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 199 | 200 | The inverse of the above test, this method makes sure the last response does not include 201 | the chunk of HTML:: 202 | 203 | def test_response_not_contains(self): 204 | self.get('hello-world') 205 | self.assertResponseNotContains('

Hello, Frank!

') 206 | 207 | assertResponseHeaders(headers, response=None) 208 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 209 | 210 | Sometimes your views or middleware will set custom headers:: 211 | 212 | def test_custom_headers(self): 213 | self.get('my-url-name') 214 | self.assertResponseHeaders({'X-Custom-Header': 'Foo'}) 215 | self.assertResponseHeaders({'X-Does-Not-Exist': None}) 216 | 217 | You might also want to check standard headers:: 218 | 219 | def test_content_type(self): 220 | self.get('my-json-view') 221 | self.assertResponseHeaders({'Content-Type': 'application/json'}) 222 | 223 | get\_check\_200(url\_name, \*args, \*\*kwargs) 224 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 225 | 226 | GETing and checking views return status 200 is a common test. This method makes it more convenient:: 227 | 228 | def test_even_better_status(self): 229 | response = self.get_check_200('my-url-name') 230 | 231 | make\_user(username='testuser', password='password', perms=None) 232 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 233 | 234 | When testing out views you often need to create various users to ensure 235 | all of your logic is safe and sound. To make this process easier, this 236 | method will create a user for you:: 237 | 238 | def test_user_stuff(self) 239 | user1 = self.make_user('u1') 240 | user2 = self.make_user('u2') 241 | 242 | If creating a User in your project is more complicated, say for example 243 | you removed the ``username`` field from the default Django Auth model, 244 | you can provide a `Factory 245 | Boy `__ factory to create 246 | it or override this method on your own sub-class. 247 | 248 | To use a Factory Boy factory, create your class like this:: 249 | 250 | from test_plus.test import TestCase 251 | from .factories import UserFactory 252 | 253 | 254 | class MySpecialTest(TestCase): 255 | user_factory = UserFactory 256 | 257 | def test_special_creation(self): 258 | user1 = self.make_user('u1') 259 | 260 | **NOTE:** Users created by this method will have their password 261 | set to the string 'password' by default, in order to ease testing. 262 | If you need a specific password, override the ``password`` parameter. 263 | 264 | You can also pass in user permissions by passing in a string of 265 | '``.``' or '``.*``'. For example:: 266 | 267 | user2 = self.make_user(perms=['myapp.create_widget', 'otherapp.*']) 268 | -------------------------------------------------------------------------------- /docs/modules/modules.rst: -------------------------------------------------------------------------------- 1 | test_plus 2 | ========= 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | test_plus 8 | -------------------------------------------------------------------------------- /docs/modules/test_plus.rst: -------------------------------------------------------------------------------- 1 | test_plus package 2 | ================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | test_plus.runner module 8 | ----------------------- 9 | 10 | .. automodule:: test_plus.runner 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | test_plus.test module 16 | --------------------- 17 | 18 | .. automodule:: test_plus.test 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | 24 | Module contents 25 | --------------- 26 | 27 | .. automodule:: test_plus 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ----- 3 | 4 | To use django-test-plus, have your tests inherit 5 | from test\_plus.test.TestCase rather than the normal 6 | django.test.TestCase:: 7 | 8 | from test_plus.test import TestCase 9 | 10 | class MyViewTests(TestCase): 11 | ... 12 | 13 | This is sufficient to get things rolling, but you are encouraged to 14 | create *your own* sub-classes for your projects. This will allow you 15 | to add your own project-specific helper methods. 16 | 17 | For example, if you have a Django project named 'myproject', you might 18 | create the following in ``myproject/test.py``:: 19 | 20 | from test_plus.test import TestCase as PlusTestCase 21 | 22 | class TestCase(PlusTestCase): 23 | pass 24 | 25 | And then in your tests use:: 26 | 27 | from myproject.test import TestCase 28 | 29 | class MyViewTests(TestCase): 30 | ... 31 | 32 | This import, which is similar to the way you would import Django's TestCase, 33 | is also valid:: 34 | 35 | from test_plus import TestCase 36 | 37 | pytest Usage 38 | ~~~~~~~~~~~~ 39 | 40 | You can get a TestCase like object as a pytest fixture now by asking for `tp`. All of the methods below would then work in pytest functions. For 41 | example:: 42 | 43 | def test_url_reverse(tp): 44 | expected_url = '/api/' 45 | reversed_url = tp.reverse('api') 46 | assert expected_url == reversed_url 47 | 48 | The ``tp_api`` fixture will provide a ``TestCase`` that uses django-rest-framework's `APIClient()`:: 49 | 50 | def test_url_reverse(tp_api): 51 | response = tp_api.client.post("myapi", format="json") 52 | assert response.status_code == 200 53 | 54 | 55 | Testing DRF views 56 | ~~~~~~~~~~~~~~~~~ 57 | 58 | To take advantage of the convenience of DRF's test client, you can create a subclass of ``TestCase`` and set the ``client_class`` property:: 59 | 60 | from test_plus import TestCase 61 | from rest_framework.test import APIClient 62 | 63 | 64 | class APITestCase(TestCase): 65 | client_class = APIClient 66 | 67 | For convenience, ``test_plus`` ships with ``APITestCase``, which does just that:: 68 | 69 | from test_plus import APITestCase 70 | 71 | 72 | class MyAPITestCase(APITestCase): 73 | 74 | def test_post(self): 75 | data = {'testing': {'prop': 'value'}} 76 | self.post('view-json', data=data, extra={'format': 'json'}) 77 | self.response_200() 78 | 79 | Note that using ``APITestCase`` requires Django >= 1.8 and having installed ``django-rest-framework``. 80 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import nox 2 | 3 | DJANGO_VERSIONS = ["3.2", "4.2", "5.0", "5.1"] 4 | DRF_VERSIONS = ["3.11", "3.12", "3.13", "3.14", ] 5 | PYTHON_VERSIONS = ["3.8", "3.9", "3.10", "3.11", "3.12"] 6 | 7 | INVALID_PYTHON_DJANGO_SESSIONS = [ 8 | ("3.9", "5.0"), 9 | ("3.8", "5.1"), 10 | ("3.9", "5.1"), 11 | ("3.11", "3.2"), 12 | ("3.12", "3.2"), 13 | ] 14 | 15 | nox.options.default_venv_backend = "uv|venv" 16 | nox.options.reuse_existing_virtualenvs = True 17 | 18 | 19 | @nox.session(python=PYTHON_VERSIONS, tags=["django"], venv_backend="uv") 20 | @nox.parametrize("django", DJANGO_VERSIONS) 21 | def tests(session: nox.Session, django: str) -> None: 22 | if (session.python, django) in INVALID_PYTHON_DJANGO_SESSIONS: 23 | session.skip() 24 | session.install(".[test]") 25 | session.install(f"django~={django}") 26 | session.run("pytest", *session.posargs) 27 | 28 | 29 | @nox.session(python=["3.10"], tags=["drf"], venv_backend="uv") 30 | @nox.parametrize("django", ["4.2"]) 31 | @nox.parametrize("drf", DRF_VERSIONS) 32 | def tests_drf(session: nox.Session, django: str, drf: str) -> None: 33 | session.install(".[test]") 34 | session.install(f"django~={django}") 35 | session.install(f"djangorestframework~={drf}") 36 | session.run("pytest", *session.posargs) 37 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE=test_project.settings 3 | addopts = --reuse-db 4 | norecursedirs = build dist docs .eggs/* *.egg-info htmlcov test_plus .git .tox 5 | python_files = test*.py 6 | pythonpath = test_project/ 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 2.2.4 3 | commit = True 4 | tag = True 5 | 6 | [metadata] 7 | name = django-test-plus 8 | version = 2.2.4 9 | description = "django-test-plus provides useful additions to Django's default TestCase" 10 | long_description = file: README.md 11 | long_description_content_type = text/markdown 12 | author = Frank Wiles 13 | author_email = frank@revsys.com 14 | url = https://github.com/revsys/django-test-plus/ 15 | classifiers = 16 | Development Status :: 5 - Production/Stable 17 | Environment :: Web Environment 18 | Framework :: Django 19 | Framework :: Django :: 3.2 20 | Framework :: Django :: 4.2 21 | Framework :: Django :: 5.0 22 | Framework :: Django :: 5.1 23 | Framework :: Pytest 24 | Intended Audience :: Developers 25 | License :: OSI Approved :: BSD License 26 | Operating System :: OS Independent 27 | Programming Language :: Python :: 3 28 | Programming Language :: Python :: 3.8 29 | Programming Language :: Python :: 3.9 30 | Programming Language :: Python :: 3.10 31 | Programming Language :: Python :: 3.11 32 | Programming Language :: Python :: 3.12 33 | 34 | [options] 35 | include_package_data = True 36 | packages = find: 37 | zip_safe = False 38 | setup_requires = 39 | packaging 40 | pytest-runner 41 | pytest-django 42 | tests_require = django-test-plus[test] 43 | python_requires = >=3.8 44 | 45 | [options.entry_points] 46 | pytest11 = 47 | test_plus = test_plus.plugin 48 | 49 | [options.extras_require] 50 | test = 51 | factory-boy 52 | flake8 53 | pyflakes 54 | pytest-cov 55 | pytest-django 56 | pytest 57 | 58 | [aliases] 59 | test = pytest 60 | 61 | [bumpversion:file:docs/conf.py] 62 | search = version = '{current_version}' 63 | replace = version = '{new_version}' 64 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /test_plus/__init__.py: -------------------------------------------------------------------------------- 1 | from .test import APITestCase, TestCase 2 | 3 | __all__ = [ 4 | 'APITestCase', 5 | 'TestCase', 6 | ] 7 | -------------------------------------------------------------------------------- /test_plus/compat.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase as DjangoTestCase 2 | 3 | try: 4 | from django.urls import reverse, NoReverseMatch 5 | except ImportError: 6 | from django.core.urlresolvers import reverse, NoReverseMatch # noqa 7 | 8 | try: 9 | import rest_framework # noqa 10 | DRF = True 11 | except ImportError: 12 | DRF = False 13 | 14 | 15 | def get_api_client(): 16 | try: 17 | from rest_framework.test import APIClient 18 | except ImportError: 19 | from django.core.exceptions import ImproperlyConfigured 20 | 21 | def APIClient(*args, **kwargs): 22 | raise ImproperlyConfigured('django-rest-framework must be installed in order to use APITestCase.') 23 | return APIClient 24 | 25 | 26 | if hasattr(DjangoTestCase, 'assertURLEqual'): 27 | assertURLEqual = DjangoTestCase.assertURLEqual 28 | else: 29 | def assertURLEqual(t, url1, url2, msg_prefix=''): 30 | raise NotImplementedError("Your version of Django does not support `assertURLEqual`") 31 | -------------------------------------------------------------------------------- /test_plus/plugin.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from .compat import get_api_client 4 | from .test import TestCase as BaseTestCase 5 | 6 | 7 | class TestCase(BaseTestCase): 8 | """ 9 | pytest plugin version of test_plus.TestCase with helpful additional features 10 | """ 11 | user_factory = None 12 | 13 | def __init__(self, *args, **kwargs): 14 | self.last_response = None 15 | super(TestCase, self).__init__(*args, **kwargs) 16 | 17 | 18 | @pytest.fixture 19 | def api_client(): 20 | return get_api_client()() 21 | 22 | 23 | @pytest.fixture 24 | def tp(client): 25 | t = TestCase() 26 | t.client = client 27 | return t 28 | 29 | 30 | @pytest.fixture 31 | def tp_api(api_client): 32 | t = TestCase() 33 | t.client = api_client 34 | return t 35 | -------------------------------------------------------------------------------- /test_plus/runner.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import logging 5 | 6 | from django.test.runner import DiscoverRunner as DefaultRunner 7 | 8 | 9 | class NoLoggingRunner(DefaultRunner): 10 | def run_tests(self, test_labels, extra_tests=None, **kwargs): 11 | # Disable logging below CRITICAL while running the tests 12 | logging.disable(logging.CRITICAL) 13 | 14 | return super(NoLoggingRunner, self).run_tests(test_labels, 15 | extra_tests, 16 | **kwargs) 17 | -------------------------------------------------------------------------------- /test_plus/status_codes.py: -------------------------------------------------------------------------------- 1 | class StatusCodeAssertionMixin(object): 2 | """ 3 | The following `assert_http_###_status_name` methods were intentionally added statically instead of dynamically so 4 | that code completion in IDEs like PyCharm would work. It is preferred to use these methods over the response_XXX 5 | methods, which could be deprecated at some point. The assert methods contain both the number and the status name 6 | slug so that people that remember them best by their numeric code and people that remember best by their name will 7 | be able to easily find the assertion they need. This was also directly patterned off of what the `Django Rest 8 | Framework uses `_. 9 | """ 10 | 11 | def _assert_http_status(self, status_code, response=None, msg=None, url=None): 12 | response = self._which_response(response) 13 | self.assertEqual(response.status_code, status_code, msg) 14 | if url is not None: 15 | self.assertEqual(response.url, url) 16 | 17 | def assert_http_100_continue(self, response=None, msg=None): 18 | self._assert_http_status(100, response=response, msg=msg) 19 | 20 | def assert_http_101_switching_protocols(self, response=None, msg=None): 21 | self._assert_http_status(101, response=response, msg=msg) 22 | 23 | def assert_http_200_ok(self, response=None, msg=None): 24 | self._assert_http_status(200, response=response, msg=msg) 25 | 26 | def assert_http_201_created(self, response=None, msg=None): 27 | self._assert_http_status(201, response=response, msg=msg) 28 | 29 | def assert_http_202_accepted(self, response=None, msg=None): 30 | self._assert_http_status(202, response=response, msg=msg) 31 | 32 | def assert_http_203_non_authoritative_information(self, response=None, msg=None): 33 | self._assert_http_status(203, response=response, msg=msg) 34 | 35 | def assert_http_204_no_content(self, response=None, msg=None): 36 | self._assert_http_status(204, response=response, msg=msg) 37 | 38 | def assert_http_205_reset_content(self, response=None, msg=None): 39 | self._assert_http_status(205, response=response, msg=msg) 40 | 41 | def assert_http_206_partial_content(self, response=None, msg=None): 42 | self._assert_http_status(206, response=response, msg=msg) 43 | 44 | def assert_http_207_multi_status(self, response=None, msg=None): 45 | self._assert_http_status(207, response=response, msg=msg) 46 | 47 | def assert_http_208_already_reported(self, response=None, msg=None): 48 | self._assert_http_status(208, response=response, msg=msg) 49 | 50 | def assert_http_226_im_used(self, response=None, msg=None): 51 | self._assert_http_status(226, response=response, msg=msg) 52 | 53 | def assert_http_300_multiple_choices(self, response=None, msg=None): 54 | self._assert_http_status(300, response=response, msg=msg) 55 | 56 | def assert_http_301_moved_permanently(self, response=None, msg=None, url=None): 57 | self._assert_http_status(301, response=response, msg=msg, url=url) 58 | 59 | def assert_http_302_found(self, response=None, msg=None, url=None): 60 | self._assert_http_status(302, response=response, msg=msg, url=url) 61 | 62 | def assert_http_303_see_other(self, response=None, msg=None): 63 | self._assert_http_status(303, response=response, msg=msg) 64 | 65 | def assert_http_304_not_modified(self, response=None, msg=None): 66 | self._assert_http_status(304, response=response, msg=msg) 67 | 68 | def assert_http_305_use_proxy(self, response=None, msg=None): 69 | self._assert_http_status(305, response=response, msg=msg) 70 | 71 | def assert_http_306_reserved(self, response=None, msg=None): 72 | self._assert_http_status(306, response=response, msg=msg) 73 | 74 | def assert_http_307_temporary_redirect(self, response=None, msg=None): 75 | self._assert_http_status(307, response=response, msg=msg) 76 | 77 | def assert_http_308_permanent_redirect(self, response=None, msg=None): 78 | self._assert_http_status(308, response=response, msg=msg) 79 | 80 | def assert_http_400_bad_request(self, response=None, msg=None): 81 | self._assert_http_status(400, response=response, msg=msg) 82 | 83 | def assert_http_401_unauthorized(self, response=None, msg=None): 84 | self._assert_http_status(401, response=response, msg=msg) 85 | 86 | def assert_http_402_payment_required(self, response=None, msg=None): 87 | self._assert_http_status(402, response=response, msg=msg) 88 | 89 | def assert_http_403_forbidden(self, response=None, msg=None): 90 | self._assert_http_status(403, response=response, msg=msg) 91 | 92 | def assert_http_404_not_found(self, response=None, msg=None): 93 | self._assert_http_status(404, response=response, msg=msg) 94 | 95 | def assert_http_405_method_not_allowed(self, response=None, msg=None): 96 | self._assert_http_status(405, response=response, msg=msg) 97 | 98 | def assert_http_406_not_acceptable(self, response=None, msg=None): 99 | self._assert_http_status(406, response=response, msg=msg) 100 | 101 | def assert_http_407_proxy_authentication_required(self, response=None, msg=None): 102 | self._assert_http_status(407, response=response, msg=msg) 103 | 104 | def assert_http_408_request_timeout(self, response=None, msg=None): 105 | self._assert_http_status(408, response=response, msg=msg) 106 | 107 | def assert_http_409_conflict(self, response=None, msg=None): 108 | self._assert_http_status(409, response=response, msg=msg) 109 | 110 | def assert_http_410_gone(self, response=None, msg=None): 111 | self._assert_http_status(410, response=response, msg=msg) 112 | 113 | def assert_http_411_length_required(self, response=None, msg=None): 114 | self._assert_http_status(411, response=response, msg=msg) 115 | 116 | def assert_http_412_precondition_failed(self, response=None, msg=None): 117 | self._assert_http_status(412, response=response, msg=msg) 118 | 119 | def assert_http_413_request_entity_too_large(self, response=None, msg=None): 120 | self._assert_http_status(413, response=response, msg=msg) 121 | 122 | def assert_http_414_request_uri_too_long(self, response=None, msg=None): 123 | self._assert_http_status(414, response=response, msg=msg) 124 | 125 | def assert_http_415_unsupported_media_type(self, response=None, msg=None): 126 | self._assert_http_status(415, response=response, msg=msg) 127 | 128 | def assert_http_416_requested_range_not_satisfiable(self, response=None, msg=None): 129 | self._assert_http_status(416, response=response, msg=msg) 130 | 131 | def assert_http_417_expectation_failed(self, response=None, msg=None): 132 | self._assert_http_status(417, response=response, msg=msg) 133 | 134 | def assert_http_422_unprocessable_entity(self, response=None, msg=None): 135 | self._assert_http_status(422, response=response, msg=msg) 136 | 137 | def assert_http_423_locked(self, response=None, msg=None): 138 | self._assert_http_status(423, response=response, msg=msg) 139 | 140 | def assert_http_424_failed_dependency(self, response=None, msg=None): 141 | self._assert_http_status(424, response=response, msg=msg) 142 | 143 | def assert_http_426_upgrade_required(self, response=None, msg=None): 144 | self._assert_http_status(426, response=response, msg=msg) 145 | 146 | def assert_http_428_precondition_required(self, response=None, msg=None): 147 | self._assert_http_status(428, response=response, msg=msg) 148 | 149 | def assert_http_429_too_many_requests(self, response=None, msg=None): 150 | self._assert_http_status(429, response=response, msg=msg) 151 | 152 | def assert_http_431_request_header_fields_too_large(self, response=None, msg=None): 153 | self._assert_http_status(431, response=response, msg=msg) 154 | 155 | def assert_http_451_unavailable_for_legal_reasons(self, response=None, msg=None): 156 | self._assert_http_status(451, response=response, msg=msg) 157 | 158 | def assert_http_500_internal_server_error(self, response=None, msg=None): 159 | self._assert_http_status(500, response=response, msg=msg) 160 | 161 | def assert_http_501_not_implemented(self, response=None, msg=None): 162 | self._assert_http_status(501, response=response, msg=msg) 163 | 164 | def assert_http_502_bad_gateway(self, response=None, msg=None): 165 | self._assert_http_status(502, response=response, msg=msg) 166 | 167 | def assert_http_503_service_unavailable(self, response=None, msg=None): 168 | self._assert_http_status(503, response=response, msg=msg) 169 | 170 | def assert_http_504_gateway_timeout(self, response=None, msg=None): 171 | self._assert_http_status(504, response=response, msg=msg) 172 | 173 | def assert_http_505_http_version_not_supported(self, response=None, msg=None): 174 | self._assert_http_status(505, response=response, msg=msg) 175 | 176 | def assert_http_506_variant_also_negotiates(self, response=None, msg=None): 177 | self._assert_http_status(506, response=response, msg=msg) 178 | 179 | def assert_http_507_insufficient_storage(self, response=None, msg=None): 180 | self._assert_http_status(507, response=response, msg=msg) 181 | 182 | def assert_http_508_loop_detected(self, response=None, msg=None): 183 | self._assert_http_status(508, response=response, msg=msg) 184 | 185 | def assert_http_509_bandwidth_limit_exceeded(self, response=None, msg=None): 186 | self._assert_http_status(509, response=response, msg=msg) 187 | 188 | def assert_http_510_not_extended(self, response=None, msg=None): 189 | self._assert_http_status(510, response=response, msg=msg) 190 | 191 | def assert_http_511_network_authentication_required(self, response=None, msg=None): 192 | self._assert_http_status(511, response=response, msg=msg) 193 | -------------------------------------------------------------------------------- /test_plus/test.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | try: 4 | from packaging.version import parse as parse_version 5 | except ImportError: 6 | from distutils.version import LooseVersion as parse_version 7 | 8 | from django.conf import settings 9 | from django.contrib.auth import get_user_model 10 | from django.core.exceptions import ImproperlyConfigured 11 | from django.db import connections, DEFAULT_DB_ALIAS 12 | from django.db.models import Q 13 | from django.shortcuts import resolve_url 14 | from django.test import RequestFactory, signals, TestCase as DjangoTestCase 15 | from django.test.client import store_rendered_templates 16 | from django.test.utils import CaptureQueriesContext 17 | from functools import partial 18 | 19 | from test_plus.status_codes import StatusCodeAssertionMixin 20 | from .compat import assertURLEqual, reverse, NoReverseMatch, get_api_client 21 | 22 | 23 | class NoPreviousResponse(Exception): 24 | pass 25 | 26 | 27 | # Build a real context 28 | 29 | CAPTURE = True 30 | 31 | 32 | class _AssertNumQueriesLessThanContext(CaptureQueriesContext): 33 | def __init__(self, test_case, num, connection, verbose=False): 34 | self.test_case = test_case 35 | self.num = num 36 | self.verbose = verbose 37 | super(_AssertNumQueriesLessThanContext, self).__init__(connection) 38 | 39 | def __exit__(self, exc_type, exc_value, traceback): 40 | super(_AssertNumQueriesLessThanContext, self).__exit__(exc_type, exc_value, traceback) 41 | if exc_type is not None: 42 | return 43 | executed = len(self) 44 | msg = "%d queries executed, expected less than %d" % (executed, self.num) 45 | if self.verbose: 46 | queries = "\n\n".join(q["sql"] for q in self.captured_queries) 47 | msg += ". Executed queries were:\n\n%s" % queries 48 | self.test_case.assertLess(executed, self.num, msg) 49 | 50 | 51 | class login(object): 52 | """ 53 | A useful login context for Django tests. If the first argument is 54 | a User, we will login with that user's username. If no password is 55 | given we will use 'password'. 56 | """ 57 | 58 | def __init__(self, testcase, *args, **credentials): 59 | self.testcase = testcase 60 | User = get_user_model() 61 | 62 | if args and isinstance(args[0], User): 63 | USERNAME_FIELD = getattr(User, 'USERNAME_FIELD', 'username') 64 | credentials.update({ 65 | USERNAME_FIELD: getattr(args[0], USERNAME_FIELD), 66 | }) 67 | 68 | if not credentials.get('password', False): 69 | credentials['password'] = 'password' 70 | 71 | success = testcase.client.login(**credentials) 72 | self.testcase.assertTrue( 73 | success, 74 | "login failed with credentials=%r" % (credentials) 75 | ) 76 | 77 | def __enter__(self): 78 | pass 79 | 80 | def __exit__(self, *args): 81 | self.testcase.client.logout() 82 | 83 | 84 | class BaseTestCase(StatusCodeAssertionMixin): 85 | """ 86 | Django TestCase with helpful additional features 87 | """ 88 | user_factory = None 89 | 90 | def __init__(self, *args, **kwargs): 91 | self.last_response = None 92 | 93 | def tearDown(self): 94 | self.client.logout() 95 | 96 | def print_form_errors(self, response_or_form=None): 97 | """A utility method for quickly debugging responses with form errors.""" 98 | 99 | if response_or_form is None: 100 | response_or_form = self.last_response 101 | 102 | if hasattr(response_or_form, 'errors'): 103 | form = response_or_form 104 | elif hasattr(response_or_form, 'context'): 105 | form = response_or_form.context['form'] 106 | else: 107 | raise Exception('print_form_errors requires the response_or_form argument to either be a Django http response or a form instance.') 108 | 109 | print(form.errors.as_text()) 110 | 111 | def request(self, method_name, url_name, *args, **kwargs): 112 | """ 113 | Request url by name using reverse() through method 114 | 115 | If reverse raises NoReverseMatch attempt to use it as a URL. 116 | """ 117 | follow = kwargs.pop("follow", False) 118 | extra = kwargs.pop("extra", {}) 119 | data = kwargs.pop("data", {}) 120 | 121 | valid_method_names = [ 122 | 'get', 123 | 'post', 124 | 'put', 125 | 'patch', 126 | 'head', 127 | 'trace', 128 | 'options', 129 | 'delete' 130 | ] 131 | 132 | if method_name in valid_method_names: 133 | method = getattr(self.client, method_name) 134 | else: 135 | raise LookupError("Cannot find the method {0}".format(method_name)) 136 | 137 | try: 138 | self.last_response = method(reverse(url_name, args=args, kwargs=kwargs), data=data, follow=follow, **extra) 139 | except NoReverseMatch: 140 | self.last_response = method(url_name, data=data, follow=follow, **extra) 141 | 142 | self.context = self.last_response.context 143 | return self.last_response 144 | 145 | def get(self, url_name, *args, **kwargs): 146 | return self.request('get', url_name, *args, **kwargs) 147 | 148 | def post(self, url_name, *args, **kwargs): 149 | return self.request('post', url_name, *args, **kwargs) 150 | 151 | def put(self, url_name, *args, **kwargs): 152 | return self.request('put', url_name, *args, **kwargs) 153 | 154 | def patch(self, url_name, *args, **kwargs): 155 | return self.request('patch', url_name, *args, **kwargs) 156 | 157 | def head(self, url_name, *args, **kwargs): 158 | return self.request('head', url_name, *args, **kwargs) 159 | 160 | def trace(self, url_name, *args, **kwargs): 161 | if parse_version(django.get_version()) >= parse_version('1.8.2'): 162 | return self.request('trace', url_name, *args, **kwargs) 163 | else: 164 | raise LookupError("client.trace is not available for your version of django. Please\ 165 | update your django version.") 166 | 167 | def options(self, url_name, *args, **kwargs): 168 | return self.request('options', url_name, *args, **kwargs) 169 | 170 | def delete(self, url_name, *args, **kwargs): 171 | return self.request('delete', url_name, *args, **kwargs) 172 | 173 | def _which_response(self, response=None): 174 | if response is None and self.last_response is not None: 175 | return self.last_response 176 | else: 177 | return response 178 | 179 | def _assert_response_code(self, status_code, response=None, msg=None): 180 | response = self._which_response(response) 181 | self.assertEqual(response.status_code, status_code, msg) 182 | 183 | def response_200(self, response=None, msg=None): 184 | """ Given response has status_code 200 """ 185 | self._assert_response_code(200, response, msg) 186 | 187 | def response_201(self, response=None, msg=None): 188 | """ Given response has status_code 201 """ 189 | self._assert_response_code(201, response, msg) 190 | 191 | def response_204(self, response=None, msg=None): 192 | """ Given response has status_code 204 """ 193 | self._assert_response_code(204, response, msg) 194 | 195 | def response_301(self, response=None, msg=None): 196 | """ Given response has status_code 301 """ 197 | self._assert_response_code(301, response, msg) 198 | 199 | def response_302(self, response=None, msg=None): 200 | """ Given response has status_code 302 """ 201 | self._assert_response_code(302, response, msg) 202 | 203 | def response_400(self, response=None, msg=None): 204 | """ Given response has status_code 400 """ 205 | self._assert_response_code(400, response, msg) 206 | 207 | def response_401(self, response=None, msg=None): 208 | """ Given response has status_code 401 """ 209 | self._assert_response_code(401, response, msg) 210 | 211 | def response_403(self, response=None, msg=None): 212 | """ Given response has status_code 403 """ 213 | self._assert_response_code(403, response, msg) 214 | 215 | def response_404(self, response=None, msg=None): 216 | """ Given response has status_code 404 """ 217 | self._assert_response_code(404, response, msg) 218 | 219 | def response_405(self, response=None, msg=None): 220 | """ Given response has status_code 405 """ 221 | self._assert_response_code(405, response, msg) 222 | 223 | def response_409(self, response=None, msg=None): 224 | """ Given response has status_code 409 """ 225 | self._assert_response_code(409, response, msg) 226 | 227 | def response_410(self, response=None, msg=None): 228 | """ Given response has status_code 410 """ 229 | self._assert_response_code(410, response, msg) 230 | 231 | def get_check_200(self, url, *args, **kwargs): 232 | """ Test that we can GET a page and it returns a 200 """ 233 | response = self.get(url, *args, **kwargs) 234 | self.response_200(response) 235 | return response 236 | 237 | def assertLoginRequired(self, url, *args, **kwargs): 238 | """ Ensure login is required to GET this URL """ 239 | response = self.get(url, *args, **kwargs) 240 | reversed_url = reverse(url, args=args, kwargs=kwargs) 241 | login_url = str(resolve_url(settings.LOGIN_URL)) 242 | expected_url = "{0}?next={1}".format(login_url, reversed_url) 243 | self.assertRedirects(response, expected_url) 244 | 245 | assertRedirects = DjangoTestCase.assertRedirects 246 | assertURLEqual = assertURLEqual 247 | 248 | def login(self, *args, **credentials): 249 | """ Login a user """ 250 | return login(self, *args, **credentials) 251 | 252 | def reverse(self, name, *args, **kwargs): 253 | """ Reverse a url, convenience to avoid having to import reverse in tests """ 254 | return reverse(name, args=args, kwargs=kwargs) 255 | 256 | @classmethod 257 | def make_user(cls, username='testuser', password='password', perms=None): 258 | """ 259 | Build a user with and password of 'password' for testing 260 | purposes. 261 | """ 262 | if cls.user_factory: 263 | User = cls.user_factory._meta.model 264 | user_factory = cls.user_factory 265 | else: 266 | User = get_user_model() 267 | user_factory = User.objects.create_user 268 | 269 | USERNAME_FIELD = getattr(User, 'USERNAME_FIELD', 'username') 270 | user_data = {USERNAME_FIELD: username} 271 | EMAIL_FIELD = getattr(User, 'EMAIL_FIELD', None) 272 | if EMAIL_FIELD is not None and cls.user_factory is None: 273 | user_data[EMAIL_FIELD] = '{}@example.com'.format(username) 274 | test_user = user_factory(**user_data) 275 | test_user.set_password(password) 276 | test_user.save() 277 | 278 | if perms: 279 | from django.contrib.auth.models import Permission 280 | _filter = Q() 281 | for perm in perms: 282 | if '.' not in perm: 283 | raise ImproperlyConfigured( 284 | 'The permission in the perms argument needs to be either ' 285 | 'app_label.codename or app_label.* (e.g. accounts.change_user or accounts.*)' 286 | ) 287 | 288 | app_label, codename = perm.split('.') 289 | if codename == '*': 290 | _filter = _filter | Q(content_type__app_label=app_label) 291 | else: 292 | _filter = _filter | Q(content_type__app_label=app_label, codename=codename) 293 | 294 | test_user.user_permissions.add(*list(Permission.objects.filter(_filter))) 295 | 296 | return test_user 297 | 298 | def assertNumQueriesLessThan(self, num, *args, **kwargs): 299 | func = kwargs.pop('func', None) 300 | using = kwargs.pop("using", DEFAULT_DB_ALIAS) 301 | verbose = kwargs.pop("verbose", False) 302 | conn = connections[using] 303 | 304 | context = _AssertNumQueriesLessThanContext(self, num, conn, verbose=verbose) 305 | if func is None: 306 | return context 307 | 308 | with context: 309 | func(*args, **kwargs) 310 | 311 | def assertGoodView(self, url_name, *args, verbose=False, **kwargs): 312 | """ 313 | Quick-n-dirty testing of a given url name. 314 | Ensures URL returns a 200 status and that generates less than 50 315 | database queries. 316 | """ 317 | query_count = kwargs.pop('test_query_count', 50) 318 | 319 | with self.assertNumQueriesLessThan(query_count, verbose=verbose): 320 | response = self.get(url_name, *args, **kwargs) 321 | 322 | self.response_200(response) 323 | 324 | return response 325 | 326 | def assertResponseContains(self, text, response=None, html=True, **kwargs): 327 | """ Convenience wrapper for assertContains """ 328 | response = self._which_response(response) 329 | self.assertContains(response, text, html=html, **kwargs) 330 | 331 | def assertResponseNotContains(self, text, response=None, html=True, **kwargs): 332 | """ Convenience wrapper for assertNotContains """ 333 | response = self._which_response(response) 334 | self.assertNotContains(response, text, html=html, **kwargs) 335 | 336 | def assertResponseHeaders(self, headers, response=None): 337 | """ 338 | Check that the headers in the response are as expected. 339 | 340 | Only headers defined in `headers` are compared, other keys present on 341 | the `response` will be ignored. 342 | 343 | :param headers: Mapping of header names to expected values 344 | :type headers: :class:`collections.Mapping` 345 | :param response: Response to check headers against 346 | :type response: :class:`django.http.response.HttpResponse` 347 | """ 348 | response = self._which_response(response) 349 | compare = {h: response.get(h) for h in headers} 350 | self.assertEqual(compare, headers) 351 | 352 | def get_context(self, key): 353 | if self.last_response is not None: 354 | self.assertIn(key, self.last_response.context) 355 | return self.last_response.context[key] 356 | else: 357 | raise NoPreviousResponse("There isn't a previous response to query") 358 | 359 | def assertInContext(self, key): 360 | return self.get_context(key) 361 | 362 | def assertContext(self, key, value): 363 | self.assertEqual(self.get_context(key), value) 364 | 365 | 366 | class TestCase(DjangoTestCase, BaseTestCase): 367 | """ 368 | Django TestCase with helpful additional features 369 | """ 370 | user_factory = None 371 | 372 | def __init__(self, *args, **kwargs): 373 | self.last_response = None 374 | super(TestCase, self).__init__(*args, **kwargs) 375 | 376 | 377 | class APITestCase(TestCase): 378 | def __init__(self, *args, **kwargs): 379 | self.client_class = get_api_client() 380 | super(APITestCase, self).__init__(*args, **kwargs) 381 | 382 | 383 | # Note this class inherits from TestCase defined above. 384 | class CBVTestCase(TestCase): 385 | """ 386 | Directly calls class-based generic view methods, 387 | bypassing the Django test Client. 388 | 389 | This process bypasses middleware invocation and URL resolvers. 390 | 391 | Example usage: 392 | 393 | from myapp.views import MyClass 394 | 395 | class MyClassTest(CBVTestCase): 396 | 397 | def test_special_method(self): 398 | request = RequestFactory().get('/') 399 | instance = self.get_instance(MyClass, request=request) 400 | 401 | # invoke a MyClass method 402 | result = instance.special_method() 403 | 404 | # make assertions 405 | self.assertTrue(result) 406 | """ 407 | 408 | @staticmethod 409 | def get_instance(view_cls, *args, **kwargs): 410 | """ 411 | Returns a decorated instance of a class-based generic view class. 412 | 413 | Use `initkwargs` to set expected class attributes. 414 | For example, set the `object` attribute on MyDetailView class: 415 | 416 | instance = self.get_instance(MyDetailView, initkwargs={'object': obj}, request) 417 | 418 | because SingleObjectMixin (part of generic.DetailView) 419 | expects self.object to be set before invoking get_context_data(). 420 | 421 | Pass a "request" kwarg in order for your tests to have particular 422 | request attributes. 423 | """ 424 | initkwargs = kwargs.pop('initkwargs', None) 425 | request = kwargs.pop('request', None) 426 | if initkwargs is None: 427 | initkwargs = {} 428 | instance = view_cls(**initkwargs) 429 | instance.request = request 430 | instance.args = args 431 | instance.kwargs = kwargs 432 | return instance 433 | 434 | def get(self, view_cls, *args, **kwargs): 435 | """ 436 | Calls view_cls.get() method after instantiating view class. 437 | Renders view templates and sets context if appropriate. 438 | """ 439 | data = kwargs.pop('data', None) 440 | instance = self.get_instance(view_cls, *args, **kwargs) 441 | if not instance.request: 442 | # Use a basic request 443 | instance.request = RequestFactory().get('/', data) 444 | self.last_response = self.get_response(instance.request, instance.get) 445 | self.context = self.last_response.context 446 | return self.last_response 447 | 448 | def post(self, view_cls, *args, **kwargs): 449 | """ 450 | Calls view_cls.post() method after instantiating view class. 451 | Renders view templates and sets context if appropriate. 452 | """ 453 | data = kwargs.pop('data', None) 454 | if data is None: 455 | data = {} 456 | instance = self.get_instance(view_cls, *args, **kwargs) 457 | if not instance.request: 458 | # Use a basic request 459 | instance.request = RequestFactory().post('/', data) 460 | self.last_response = self.get_response(instance.request, instance.post) 461 | self.context = self.last_response.context 462 | return self.last_response 463 | 464 | def get_response(self, request, view_func): 465 | """ 466 | Obtain response from view class method (typically get or post). 467 | 468 | No middleware is invoked, but templates are rendered 469 | and context saved if appropriate. 470 | """ 471 | # Curry (using functools.partial) a data dictionary into 472 | # an instance of the template renderer callback function. 473 | data = {} 474 | on_template_render = partial(store_rendered_templates, data) 475 | signal_uid = "template-render-%s" % id(request) 476 | signals.template_rendered.connect(on_template_render, dispatch_uid=signal_uid) 477 | try: 478 | response = view_func(request) 479 | 480 | if hasattr(response, 'render') and callable(response.render): 481 | response = response.render() 482 | # Add any rendered template detail to the response. 483 | response.templates = data.get("templates", []) 484 | response.context = data.get("context") 485 | else: 486 | response.templates = None 487 | response.context = None 488 | 489 | return response 490 | finally: 491 | signals.template_rendered.disconnect(dispatch_uid=signal_uid) 492 | 493 | def get_check_200(self, url, *args, **kwargs): 494 | """ Test that we can GET a page and it returns a 200 """ 495 | response = super(CBVTestCase, self).get(url, *args, **kwargs) 496 | self.response_200(response) 497 | return response 498 | 499 | def assertLoginRequired(self, url, *args, **kwargs): 500 | """ Ensure login is required to GET this URL """ 501 | response = super(CBVTestCase, self).get(url, *args, **kwargs) 502 | reversed_url = reverse(url, args=args, kwargs=kwargs) 503 | login_url = str(resolve_url(settings.LOGIN_URL)) 504 | expected_url = "{0}?next={1}".format(login_url, reversed_url) 505 | self.assertRedirects(response, expected_url) 506 | 507 | def assertGoodView(self, url_name, *args, **kwargs): 508 | """ 509 | Quick-n-dirty testing of a given view. 510 | Ensures view returns a 200 status and that generates less than 50 511 | database queries. 512 | """ 513 | query_count = kwargs.pop('test_query_count', 50) 514 | 515 | with self.assertNumQueriesLessThan(query_count): 516 | response = super(CBVTestCase, self).get(url_name, *args, **kwargs) 517 | self.response_200(response) 518 | return response 519 | -------------------------------------------------------------------------------- /test_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /test_project/test_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revsys/django-test-plus/1292d172fc69c9a6e8fc0d5a93fa085820946fe5/test_project/test_app/__init__.py -------------------------------------------------------------------------------- /test_project/test_app/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from .models import Data 4 | 5 | 6 | class NameForm(forms.Form): 7 | name = forms.CharField(max_length=255) 8 | 9 | 10 | class DataForm(forms.ModelForm): 11 | 12 | class Meta: 13 | model = Data 14 | fields = ['name'] 15 | -------------------------------------------------------------------------------- /test_project/test_app/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Data', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 17 | ('name', models.CharField(max_length=50)), 18 | ], 19 | options={ 20 | }, 21 | bases=(models.Model,), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /test_project/test_app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revsys/django-test-plus/1292d172fc69c9a6e8fc0d5a93fa085820946fe5/test_project/test_app/migrations/__init__.py -------------------------------------------------------------------------------- /test_project/test_app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Data(models.Model): 5 | """ Simple model to test our query assertions """ 6 | name = models.CharField(max_length=50) 7 | -------------------------------------------------------------------------------- /test_project/test_app/templates/form_errors.html: -------------------------------------------------------------------------------- 1 | {# stub file used for a test #} 2 | -------------------------------------------------------------------------------- /test_project/test_app/templates/other.html: -------------------------------------------------------------------------------- 1 |

Another template

-------------------------------------------------------------------------------- /test_project/test_app/templates/test.html: -------------------------------------------------------------------------------- 1 |

Hello world

-------------------------------------------------------------------------------- /test_project/test_app/tests/test_pytest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from test_plus.compat import DRF 4 | 5 | 6 | def test_something(tp): 7 | response = tp.get("view-200") 8 | assert response.status_code == 200 9 | 10 | 11 | @pytest.mark.skipif(DRF is False, reason="DRF is not installed.") 12 | def test_api(tp_api): 13 | response = tp_api.post("view-json", extra={"format": "json"}) 14 | assert response.status_code == 200 15 | 16 | 17 | def test_assert_login_required(tp): 18 | tp.assertLoginRequired("view-needs-login") 19 | 20 | 21 | def test_assert_in_context(tp): 22 | response = tp.get('view-context-with') 23 | assert 'testvalue' in response.context 24 | tp.assertInContext('testvalue') 25 | -------------------------------------------------------------------------------- /test_project/test_app/tests/test_unittests.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import uuid 4 | 5 | import django 6 | import factory.django 7 | import sys 8 | import unittest 9 | 10 | import pytest 11 | 12 | from contextlib import contextmanager 13 | from django.contrib.auth import get_user_model 14 | from django.core.exceptions import ImproperlyConfigured 15 | 16 | try: 17 | from StringIO import StringIO 18 | except ImportError: 19 | from io import StringIO 20 | 21 | from test_plus.test import ( 22 | CBVTestCase, 23 | NoPreviousResponse, 24 | TestCase, 25 | APITestCase, 26 | ) 27 | from test_plus.compat import DRF 28 | 29 | from test_app.forms import NameForm 30 | from test_app.models import Data 31 | from test_app.views import ( 32 | CBDataView, 33 | CBTemplateView, 34 | CBView, 35 | ) 36 | 37 | 38 | User = get_user_model() 39 | 40 | 41 | @contextmanager 42 | def redirect_stdout(new_target): 43 | old_target, sys.stdout = sys.stdout, new_target 44 | try: 45 | yield new_target 46 | finally: 47 | sys.stdout = old_target 48 | 49 | 50 | class UserFactory(factory.django.DjangoModelFactory): 51 | username = factory.Sequence(lambda n: 'user{}'.format(n)) 52 | email = factory.Sequence(lambda n: 'user{}@example.com'.format(n)) 53 | 54 | class Meta: 55 | model = User 56 | 57 | 58 | class TestPlusUserFactoryOption(TestCase): 59 | user_factory = UserFactory 60 | 61 | def test_make_user_factory(self): 62 | u1 = self.make_user('factory') 63 | self.assertEqual(u1.username, 'factory') 64 | self.assertEqual(u1.email, 'user1@example.com') 65 | 66 | def test_invalid_perms_for_user(self): 67 | with self.assertRaises(ImproperlyConfigured): 68 | self.make_user(perms=['fake']) 69 | 70 | 71 | class TestMakeUser(TestCase): 72 | 73 | def test_make_user(self): 74 | u1 = self.make_user() 75 | self.assertEqual(u1.username, 'testuser') 76 | self.assertEqual(u1.email, 'testuser@example.com') 77 | 78 | 79 | class TestPlusViewTests(TestCase): 80 | 81 | def test_get(self): 82 | res = self.get('view-200') 83 | self.assertEqual(res.status_code, 200) 84 | 85 | url = self.reverse('view-200') 86 | res = self.get(url) 87 | self.assertEqual(res.status_code, 200) 88 | 89 | def test_print_form_errors(self): 90 | 91 | with self.assertRaisesMessage(Exception, 'print_form_errors requires the response_or_form argument to either be a Django http response or a form instance.'): 92 | self.print_form_errors('my-bad-argument') 93 | 94 | form = NameForm(data={}) 95 | self.assertFalse(form.is_valid()) 96 | 97 | output = StringIO() 98 | with redirect_stdout(output): 99 | self.print_form_errors(form) 100 | output = output.getvalue().strip() 101 | self.assertTrue('This field is required.' in output) 102 | 103 | self.post('form-errors') 104 | self.response_200() 105 | output = StringIO() 106 | with redirect_stdout(output): 107 | self.print_form_errors() 108 | output = output.getvalue().strip() 109 | self.assertTrue('This field is required.' in output) 110 | 111 | def test_get_follow(self): 112 | # Expect 302 status code 113 | res = self.get('view-redirect') 114 | self.assertEqual(res.status_code, 302) 115 | # Expect 200 status code 116 | url = self.reverse('view-redirect') 117 | res = self.get(url, follow=True) 118 | self.assertEqual(res.status_code, 200) 119 | 120 | def test_get_query(self): 121 | res = self.get('view-200', data={'query': 'foo'}) 122 | self.assertEqual(res.status_code, 200) 123 | self.assertEqual(res.request['QUERY_STRING'], 'query=foo') 124 | 125 | def test_post(self): 126 | url = self.reverse('view-200') 127 | data = {'testing': True} 128 | res = self.post(url, data=data) 129 | self.assertTrue(res.status_code, 200) 130 | 131 | def test_post_follow(self): 132 | url = self.reverse('view-redirect') 133 | data = {'testing': True} 134 | # Expect 302 status code 135 | res = self.post(url, data=data) 136 | self.assertTrue(res.status_code, 302) 137 | # Expect 200 status code 138 | res = self.post(url, data=data, follow=True) 139 | self.assertTrue(res.status_code, 200) 140 | 141 | def test_put(self): 142 | url = self.reverse('view-200') 143 | res = self.put(url) 144 | self.assertTrue(res.status_code, 200) 145 | 146 | def test_put_follow(self): 147 | url = self.reverse('view-redirect') 148 | # Expect 302 status code 149 | res = self.put(url) 150 | self.assertTrue(res.status_code, 302) 151 | # Expect 200 status code 152 | res = self.put(url, follow=True) 153 | self.assertTrue(res.status_code, 200) 154 | 155 | def test_patch(self): 156 | url = self.reverse('view-200') 157 | res = self.patch(url) 158 | self.assertTrue(res.status_code, 200) 159 | 160 | def test_patch_follow(self): 161 | url = self.reverse('view-redirect') 162 | # Expect 302 status code 163 | res = self.patch(url) 164 | self.assertTrue(res.status_code, 302) 165 | # Expect 200 status code 166 | res = self.patch(url, follow=True) 167 | self.assertTrue(res.status_code, 200) 168 | 169 | def test_head(self): 170 | url = self.reverse('view-200') 171 | res = self.head(url) 172 | self.assertTrue(res.status_code, 200) 173 | 174 | def test_head_follow(self): 175 | url = self.reverse('view-redirect') 176 | # Expect 302 status code 177 | res = self.head(url) 178 | self.assertTrue(res.status_code, 302) 179 | # Expect 200 status code 180 | res = self.head(url, follow=True) 181 | self.assertTrue(res.status_code, 200) 182 | 183 | def test_trace(self): 184 | url = self.reverse('view-200') 185 | res = self.trace(url) 186 | self.assertTrue(res.status_code, 200) 187 | 188 | def test_trace_follow(self): 189 | url = self.reverse('view-redirect') 190 | # Expect 302 status code 191 | res = self.trace(url) 192 | self.assertTrue(res.status_code, 302) 193 | # Expect 200 status code 194 | res = self.trace(url, follow=True) 195 | self.assertTrue(res.status_code, 200) 196 | 197 | def test_options(self): 198 | url = self.reverse('view-200') 199 | res = self.options(url) 200 | self.assertTrue(res.status_code, 200) 201 | 202 | def test_options_follow(self): 203 | url = self.reverse('view-redirect') 204 | # Expect 302 status code 205 | res = self.options(url) 206 | self.assertTrue(res.status_code, 302) 207 | # Expect 200 status code 208 | res = self.options(url, follow=True) 209 | self.assertTrue(res.status_code, 200) 210 | 211 | def test_delete(self): 212 | url = self.reverse('view-200') 213 | res = self.delete(url) 214 | self.assertTrue(res.status_code, 200) 215 | 216 | def test_delete_follow(self): 217 | url = self.reverse('view-redirect') 218 | # Expect 302 status code 219 | res = self.delete(url) 220 | self.assertTrue(res.status_code, 302) 221 | # Expect 200 status code 222 | res = self.delete(url, follow=True) 223 | self.assertTrue(res.status_code, 200) 224 | 225 | @staticmethod 226 | def _test_http_response(method, response=None, msg=None, url=None): 227 | try: 228 | if url is not None: 229 | method(response=response, msg=msg, url=url) 230 | else: 231 | method(response=response, msg=msg) 232 | except AssertionError as e: 233 | msg = '{method_name}: {error}'.format(method_name=method.__name__, error=e) 234 | e.args = (msg,) 235 | raise 236 | 237 | def test_http_status_code_assertions(self): 238 | """ 239 | This test iterates through all the http_###_status_code methods in the StatusCodeAssertionMixin and tests that 240 | they return the correct status code. 241 | """ 242 | from test_plus.status_codes import StatusCodeAssertionMixin 243 | for attr in dir(StatusCodeAssertionMixin): 244 | method = getattr(self, attr, None) 245 | match = re.match(r'[a-z_]+(?P[\d]+)[a-z_]+', attr) 246 | if callable(method) is True and match is not None: 247 | status_code = int(match.groupdict()['status_code']) 248 | url = self.reverse('status-code-view', status_code) 249 | res_url = None 250 | res = self.get(url) 251 | 252 | if status_code in (301, 302): 253 | res_url = self.reverse('view-200') 254 | 255 | # with response 256 | self._test_http_response(method, res, url=res_url) 257 | 258 | # without response 259 | self._test_http_response(method, url=res_url) 260 | 261 | def test_get_check_200(self): 262 | res = self.get_check_200('view-200') 263 | self.assertTrue(res.status_code, 200) 264 | 265 | def test_response_200(self): 266 | res = self.get('view-200') 267 | self.response_200(res) 268 | 269 | # Test without response option 270 | self.response_200() 271 | 272 | def test_response_201(self): 273 | res = self.get('view-201') 274 | self.response_201(res) 275 | 276 | # Test without response option 277 | self.response_201() 278 | 279 | def test_response_204(self): 280 | res = self.get('view-204') 281 | self.response_204(res) 282 | 283 | # Test without response option 284 | self.response_204() 285 | 286 | def test_response_301(self): 287 | res = self.get('view-301') 288 | self.response_301(res) 289 | 290 | # Test without response option 291 | self.response_301() 292 | 293 | def test_response_302(self): 294 | res = self.get('view-302') 295 | self.response_302(res) 296 | 297 | # Test without response option 298 | self.response_302() 299 | 300 | def test_response_400(self): 301 | res = self.get('view-400') 302 | self.response_400(res) 303 | 304 | # Test without response option 305 | self.response_400() 306 | 307 | def test_response_401(self): 308 | res = self.get('view-401') 309 | self.response_401(res) 310 | 311 | # Test without response option 312 | self.response_401() 313 | 314 | def test_response_403(self): 315 | res = self.get('view-403') 316 | self.response_403(res) 317 | 318 | # Test without response option 319 | self.response_403() 320 | 321 | def test_response_404(self): 322 | res = self.get('view-404') 323 | self.response_404(res) 324 | 325 | # Test without response option 326 | self.response_404() 327 | 328 | def test_response_405(self): 329 | res = self.get('view-405') 330 | self.response_405(res) 331 | 332 | # Test without response option 333 | self.response_405() 334 | 335 | def test_response_409(self): 336 | res = self.get('view-409') 337 | self.response_409(res) 338 | 339 | # Test without response option 340 | self.response_409() 341 | 342 | def test_response_410(self): 343 | res = self.get('view-410') 344 | self.response_410(res) 345 | 346 | # Test without response option 347 | self.response_410() 348 | 349 | def test_make_user(self): 350 | """ Test make_user using django.contrib.auth defaults """ 351 | u1 = self.make_user('u1') 352 | self.assertEqual(u1.username, 'u1') 353 | 354 | def test_make_user_with_perms(self): 355 | u1 = self.make_user('u1', perms=['auth.*']) 356 | if django.VERSION < (2, 1): 357 | expected_perms = [u'add_group', u'change_group', u'delete_group', 358 | u'add_permission', u'change_permission', u'delete_permission', 359 | u'add_user', u'change_user', u'delete_user'] 360 | else: 361 | expected_perms = [u'add_group', u'change_group', u'delete_group', u'view_group', 362 | u'add_permission', u'change_permission', u'delete_permission', 363 | u'view_permission', u'add_user', u'change_user', u'delete_user', 364 | u'view_user'] 365 | 366 | self.assertEqual(list(u1.user_permissions.values_list('codename', flat=True)), expected_perms) 367 | 368 | u2 = self.make_user('u2', perms=['auth.add_group']) 369 | self.assertEqual(list(u2.user_permissions.values_list('codename', flat=True)), [u'add_group']) 370 | 371 | def test_login_required(self): 372 | self.assertLoginRequired('view-needs-login') 373 | 374 | # Make a user and login with our login context 375 | self.make_user('test') 376 | with self.login(username='test', password='password'): 377 | self.get_check_200('view-needs-login') 378 | 379 | def test_login_other_password(self): 380 | # Make a user with a different password 381 | user = self.make_user('test', password='revsys') 382 | with self.login(user, password='revsys'): 383 | self.get_check_200('view-needs-login') 384 | 385 | def test_login_no_password(self): 386 | 387 | user = self.make_user('test') 388 | with self.login(username=user.username): 389 | self.get_check_200('view-needs-login') 390 | 391 | def test_login_user_object(self): 392 | 393 | user = self.make_user('test') 394 | with self.login(user): 395 | self.get_check_200('view-needs-login') 396 | 397 | def test_reverse(self): 398 | self.assertEqual(self.reverse('view-200'), '/view/200/') 399 | 400 | def test_assertgoodview(self): 401 | self.assertGoodView('view-200') 402 | 403 | def test_assertnumqueries(self): 404 | with self.assertNumQueriesLessThan(1): 405 | self.get('view-needs-login') 406 | 407 | def test_assertnumqueries_data_1(self): 408 | with self.assertNumQueriesLessThan(2): 409 | self.get('view-data-1') 410 | 411 | def test_assertnumqueries_data_5(self): 412 | with self.assertNumQueriesLessThan(6): 413 | self.get('view-data-5') 414 | 415 | def test_invalid_request_method(self): 416 | with self.assertRaises(LookupError): 417 | self.request('foobar', 'some-url') 418 | 419 | @unittest.expectedFailure 420 | def test_assertnumqueries_failure(self): 421 | with self.assertNumQueriesLessThan(1): 422 | self.get('view-data-5') 423 | 424 | def test_assertincontext(self): 425 | response = self.get('view-context-with') 426 | self.assertTrue('testvalue' in response.context) 427 | 428 | self.assertInContext('testvalue') 429 | self.assertTrue(self.context['testvalue'], response.context['testvalue']) 430 | 431 | def test_get_context(self): 432 | response = self.get('view-context-with') 433 | self.assertTrue('testvalue' in response.context) 434 | value = self.get_context('testvalue') 435 | self.assertEqual(value, True) 436 | 437 | def test_assert_context(self): 438 | response = self.get('view-context-with') 439 | self.assertTrue('testvalue' in response.context) 440 | self.assertContext('testvalue', True) 441 | 442 | @unittest.expectedFailure 443 | def test_assertnotincontext(self): 444 | self.get('view-context-without') 445 | self.assertInContext('testvalue') 446 | 447 | def test_no_response(self): 448 | with self.assertRaises(NoPreviousResponse): 449 | self.assertInContext('testvalue') 450 | 451 | def test_no_response_context(self): 452 | with self.assertRaises(NoPreviousResponse): 453 | self.assertContext('testvalue', False) 454 | 455 | def test_get_context_raises(self): 456 | with self.assertRaises(NoPreviousResponse): 457 | self.get_context('testvalue') 458 | 459 | def test_get_is_ajax(self): 460 | response = self.get('view-is-ajax', 461 | extra={'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'}) 462 | self.response_200(response) 463 | 464 | def test_post_is_ajax(self): 465 | response = self.post('view-is-ajax', 466 | data={'item': 1}, 467 | extra={'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'}) 468 | self.response_200(response) 469 | 470 | def test_assertresponsecontains(self): 471 | self.get('view-contains') 472 | self.assertResponseContains('

Hello world

') 473 | self.assertResponseNotContains('

Hello Frank

') 474 | 475 | def test_assert_response_headers(self): 476 | self.get('view-headers') 477 | self.assertResponseHeaders({'Content-Type': 'text/plain'}) 478 | self.assertResponseHeaders({'X-Custom': '1'}) 479 | self.assertResponseHeaders({'X-Custom': '1', 'X-Non-Existent': None}) 480 | self.assertResponseHeaders({'X-Non-Existent': None}) 481 | 482 | 483 | class TestPlusCBViewTests(CBVTestCase): 484 | 485 | def test_get(self): 486 | self.get(CBView) 487 | self.response_200() 488 | 489 | def test_post(self): 490 | data = {'testing': True} 491 | self.post(CBView, data=data) 492 | self.response_200() 493 | 494 | # Test without data 495 | self.post(CBView) 496 | self.response_200() 497 | 498 | def test_get_check_200(self): 499 | self.get_check_200('cbview') 500 | 501 | def test_assert_good_view(self): 502 | self.assertGoodView('cbview') 503 | 504 | def test_login_required(self): 505 | self.assertLoginRequired('cbview-needs-login') 506 | 507 | # Make a user and login with our login context 508 | self.make_user('test') 509 | with self.login(username='test', password='password'): 510 | self.get_check_200('cbview-needs-login') 511 | 512 | 513 | class TestPlusCBDataViewTests(CBVTestCase): 514 | """ 515 | Provide usage examples for CBVTestCase 516 | """ 517 | 518 | def setUp(self): 519 | self.data = Data.objects.create(name='RevSys') 520 | 521 | def test_get_request_attributes(self): 522 | """ 523 | Ensure custom `request` attribute is seen at view 524 | """ 525 | request = django.test.RequestFactory().get('/') 526 | # add custom attribute 527 | request.some_data = 5 528 | 529 | data = {'some_data_key': 'some_data_value'} 530 | 531 | self.get( 532 | CBDataView, 533 | request=request, 534 | pk=self.data.pk, 535 | data=data, 536 | ) 537 | # view copies `request.some_data` into context if present 538 | self.assertContext('some_data', 5) 539 | 540 | def test_get_override_view_template(self): 541 | """ 542 | Ensure `initkwargs` overrides view attributes 543 | """ 544 | request = django.test.RequestFactory().get('/') 545 | 546 | self.get( 547 | CBDataView, 548 | request=request, 549 | pk=self.data.pk, 550 | ) 551 | self.assertTemplateUsed('test.html') # default template 552 | 553 | # Use `initkwargs` to override view class "template_name" attribute 554 | self.get( 555 | CBDataView, 556 | request=request, 557 | pk=self.data.pk, 558 | initkwargs={ 559 | 'template_name': 'other.html', 560 | } 561 | ) 562 | self.assertTemplateUsed('other.html') # overridden template 563 | 564 | def test_post_request_attributes(self): 565 | """ 566 | Ensure custom `request` attribute is seen at view 567 | """ 568 | request = django.test.RequestFactory().post('/') 569 | # add custom attribute 570 | request.some_data = 5 571 | 572 | self.post( 573 | CBDataView, 574 | request=request, 575 | pk=self.data.pk, 576 | data={} # no data, name is required so this will be invalid 577 | ) 578 | # view copies `request.some_data` into context if present 579 | self.assertContext('some_data', 5) 580 | 581 | def test_post_override_view_template(self): 582 | """ 583 | Ensure `initkwargs` overrides view attributes 584 | """ 585 | request = django.test.RequestFactory().post('/') 586 | 587 | self.post( 588 | CBDataView, 589 | request=request, 590 | pk=self.data.pk, 591 | data={} # no data, name is required so this will be invalid 592 | ) 593 | self.assertTemplateUsed('test.html') # default template 594 | 595 | # Use `initkwargs` to override view class "template_name" attribute 596 | self.post( 597 | CBDataView, 598 | request=request, 599 | pk=self.data.pk, 600 | initkwargs={ 601 | 'template_name': 'other.html', 602 | }, 603 | data={} # no data, name is required so this will be invalid 604 | ) 605 | self.assertTemplateUsed('other.html') # overridden template 606 | 607 | 608 | class TestPlusCBTemplateViewTests(CBVTestCase): 609 | 610 | def test_get(self): 611 | response = self.get(CBTemplateView) 612 | self.assertEqual(response.status_code, 200) 613 | self.assertInContext('revsys') 614 | self.assertContext('revsys', 42) 615 | self.assertTemplateUsed(response, template_name='test.html') 616 | 617 | def test_get_new_template(self): 618 | template_name = 'other.html' 619 | response = self.get(CBTemplateView, initkwargs={'template_name': template_name}) 620 | self.assertEqual(response.status_code, 200) 621 | self.assertTemplateUsed(response, template_name=template_name) 622 | 623 | 624 | class TestPlusCBCustomMethodTests(CBVTestCase): 625 | 626 | def test_custom_method_with_value(self): 627 | special_value = 42 628 | instance = self.get_instance(CBView, initkwargs={'special_value': special_value}) 629 | self.assertEqual(instance.special(), special_value) 630 | 631 | def test_custom_method_no_value(self): 632 | instance = self.get_instance(CBView) 633 | self.assertFalse(instance.special()) 634 | 635 | 636 | @unittest.skipUnless(DRF is True, 'DRF is not installed.') 637 | class TestAPITestCaseDRFInstalled(APITestCase): 638 | 639 | def test_post(self): 640 | data = {'testing': {'prop': 'value'}} 641 | response = self.post('view-json', data=data) 642 | assert response["content-type"] == "application/json" 643 | self.response_200() 644 | 645 | def test_post_with_format(self): 646 | data = {'testing': {'prop': 'value'}} 647 | response = self.post('view-json', data=data, extra={'format': 'json'}) 648 | assert response["content-type"] == "application/json" 649 | self.response_200() 650 | 651 | def test_post_with_content_type(self): 652 | data = {'testing': {'prop': 'value'}} 653 | response = self.post('view-json', data=json.dumps(data), extra={'content_type': 'application/json'}) 654 | assert response["content-type"] == "application/json" 655 | self.response_200() 656 | 657 | def test_post_with_non_primitive_data_type(self): 658 | data = {"uuid": uuid.uuid4()} 659 | response = self.post('view-json', data=data) 660 | assert response["content-type"] == "application/json" 661 | self.response_200() 662 | 663 | def test_get_with_content_type(self): 664 | data = {'testing': {'prop': 'value'}} 665 | response = self.get('view-json', data=data, extra={'content_type': 'application/json'}) 666 | assert response["content-type"] == "application/json" 667 | self.response_200() 668 | 669 | def test_get_with_non_primitive_data_type(self): 670 | data = {"uuid": uuid.uuid4()} 671 | response = self.get('view-json', data=data) 672 | assert response["content-type"] == "application/json" 673 | self.response_200() 674 | 675 | 676 | # pytest tests 677 | def test_tp_loads(tp): 678 | from django.test import Client 679 | assert isinstance(tp.client, Client) 680 | 681 | 682 | @pytest.mark.skipif(DRF is False, reason="DRF is not installed.") 683 | def test_tp_api_loads(tp_api): 684 | from rest_framework.test import APIClient 685 | assert isinstance(tp_api.client, APIClient) 686 | 687 | 688 | def test_simple_post(tp): 689 | url = tp.reverse('view-200') 690 | data = {'testing': True} 691 | res = tp.post(url, data=data) 692 | assert res.status_code == 200 693 | 694 | 695 | def test_tp_response_200(tp): 696 | url = tp.reverse('view-200') 697 | data = {'testing': True} 698 | res = tp.post(url, data=data) 699 | tp.response_200() 700 | tp.response_200(res) 701 | -------------------------------------------------------------------------------- /test_project/test_app/urls.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.urls import include, re_path as url 3 | except ImportError: 4 | try: 5 | from django.conf.urls import url, include 6 | except ImportError: 7 | from django.conf.urls.defaults import url, include 8 | 9 | from .views import ( 10 | FormErrors, data_1, data_5, needs_login, view_200, view_201, view_204, 11 | view_301, view_302, view_400, view_401, view_403, view_404, view_405, 12 | view_409, view_410, view_contains, view_context_with, view_context_without, 13 | view_headers, view_is_ajax, view_json, view_redirect, 14 | CBLoginRequiredView, CBView, 15 | status_code_view, 16 | ) 17 | 18 | urlpatterns = [ 19 | url(r'^accounts/', include('django.contrib.auth.urls')), 20 | url(r'^status-code-view/(?P[\d]+)/$', status_code_view, name='status-code-view'), 21 | url(r'^view/200/$', view_200, name='view-200'), 22 | url(r'^view/201/$', view_201, name='view-201'), 23 | url(r'^view/204/$', view_204, name='view-204'), 24 | url(r'^view/301/$', view_301, name='view-301'), 25 | url(r'^view/302/$', view_302, name='view-302'), 26 | url(r'^view/400/$', view_400, name='view-400'), 27 | url(r'^view/401/$', view_401, name='view-401'), 28 | url(r'^view/403/$', view_403, name='view-403'), 29 | url(r'^view/404/$', view_404, name='view-404'), 30 | url(r'^view/405/$', view_405, name='view-405'), 31 | url(r'^view/409/$', view_409, name='view-409'), 32 | url(r'^view/410/$', view_410, name='view-410'), 33 | url(r'^view/json/$', view_json, name='view-json'), 34 | url(r'^view/redirect/$', view_redirect, name='view-redirect'), 35 | url(r'^view/needs-login/$', needs_login, name='view-needs-login'), 36 | url(r'^view/data1/$', data_1, name='view-data-1'), 37 | url(r'^view/data5/$', data_5, name='view-data-5'), 38 | url(r'^view/context/with/$', view_context_with, name='view-context-with'), 39 | url(r'^view/context/without/$', view_context_without, name='view-context-without'), 40 | url(r'^view/isajax/$', view_is_ajax, name='view-is-ajax'), 41 | url(r'^view/contains/$', view_contains, name='view-contains'), 42 | url(r'^view/form-errors/$', FormErrors.as_view(), name='form-errors'), 43 | url(r'^view/headers/$', view_headers, name='view-headers'), 44 | url(r'^cbview/needs-login/$', CBLoginRequiredView.as_view(), name='cbview-needs-login'), 45 | url(r'^cbview/$', CBView.as_view(), name='cbview'), 46 | ] 47 | -------------------------------------------------------------------------------- /test_project/test_app/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.contrib.auth.decorators import login_required 4 | from django.http import HttpResponse, HttpResponseGone 5 | from django.shortcuts import redirect, render 6 | from django.utils.decorators import method_decorator 7 | from django.views import generic 8 | 9 | from .forms import DataForm, NameForm 10 | from .models import Data 11 | 12 | try: 13 | from django.urls import reverse 14 | except ImportError: 15 | from django.core.urlresolvers import reverse 16 | 17 | 18 | # Function-based test views 19 | 20 | def status_code_view(request, status=200): 21 | status = int(status) 22 | if status in (301, 302): 23 | is_perm = True if status == 301 else False 24 | return redirect('view-200', permanent=is_perm) 25 | 26 | return HttpResponse('', status=status) 27 | 28 | 29 | def view_200(request): 30 | return HttpResponse('', status=200) 31 | 32 | 33 | def view_201(request): 34 | return HttpResponse('', status=201) 35 | 36 | 37 | def view_204(request): 38 | return HttpResponse('', status=204) 39 | 40 | 41 | def view_301(request): 42 | return HttpResponse('', status=301) 43 | 44 | 45 | def view_302(request): 46 | return HttpResponse('', status=302) 47 | 48 | 49 | def view_400(request): 50 | return HttpResponse('', status=400) 51 | 52 | 53 | def view_401(request): 54 | return HttpResponse('', status=401) 55 | 56 | 57 | def view_403(request): 58 | return HttpResponse('', status=403) 59 | 60 | 61 | def view_404(request): 62 | return HttpResponse('', status=404) 63 | 64 | 65 | def view_405(request): 66 | return HttpResponse('', status=405) 67 | 68 | 69 | def view_409(request): 70 | return HttpResponse('', status=409) 71 | 72 | 73 | def view_410(request): 74 | return HttpResponseGone() 75 | 76 | 77 | def view_redirect(request): 78 | return redirect('view-200') 79 | 80 | 81 | def view_json(request): 82 | if request.method == 'POST': 83 | ctype = request.META['CONTENT_TYPE'] 84 | if not ctype.startswith('application/json'): 85 | raise ValueError("Request's content-type should be 'application/json'. Got '{}' instead.".format(ctype)) 86 | data = json.loads(request.body.decode('utf-8')) 87 | return HttpResponse(json.dumps(data), content_type='application/json') 88 | 89 | return HttpResponse('', content_type='application/json') 90 | 91 | 92 | @login_required 93 | def needs_login(request): 94 | return HttpResponse('', status=200) 95 | 96 | 97 | def data_1(request): 98 | list(Data.objects.all()) 99 | return HttpResponse('', status=200) 100 | 101 | 102 | def data_5(request): 103 | list(Data.objects.all()) 104 | list(Data.objects.all()) 105 | list(Data.objects.all()) 106 | list(Data.objects.all()) 107 | list(Data.objects.all()) 108 | return HttpResponse('', status=200) 109 | 110 | 111 | def view_context_with(request): 112 | return render(request, 'base.html', {'testvalue': True}) 113 | 114 | 115 | def view_context_without(request): 116 | return render(request, 'base.html', {}) 117 | 118 | 119 | def view_is_ajax(request): 120 | if hasattr(request, "is_ajax"): 121 | status = 200 if request.is_ajax() else 404 122 | else: # Django 4.0 compatible 123 | status = 200 if request.headers.get('x-requested-with') == 'XMLHttpRequest' else 404 124 | return HttpResponse('', status=status) 125 | 126 | 127 | def view_contains(request): 128 | return render(request, 'test.html', {}) 129 | 130 | 131 | def view_headers(request): 132 | response = HttpResponse('', content_type='text/plain', status=200) 133 | response['X-Custom'] = 1 134 | return response 135 | 136 | 137 | # Class-based test views 138 | 139 | class CBView(generic.View): 140 | 141 | def get(self, request): 142 | return HttpResponse('', status=200) 143 | 144 | def post(self, request): 145 | return HttpResponse('', status=200) 146 | 147 | def special(self): 148 | if hasattr(self, 'special_value'): 149 | return self.special_value 150 | else: 151 | return False 152 | 153 | 154 | class CBLoginRequiredView(generic.View): 155 | 156 | @method_decorator(login_required) 157 | def dispatch(self, *args, **kwargs): 158 | return super(CBLoginRequiredView, self).dispatch(*args, **kwargs) 159 | 160 | def get(self, request): 161 | return HttpResponse('', status=200) 162 | 163 | 164 | class CBDataView(generic.UpdateView): 165 | 166 | model = Data 167 | template_name = "test.html" 168 | form_class = DataForm 169 | 170 | def get_success_url(self): 171 | return reverse("view-200") 172 | 173 | def get_context_data(self, **kwargs): 174 | kwargs = super(CBDataView, self).get_context_data(**kwargs) 175 | if hasattr(self.request, "some_data"): 176 | kwargs.update({ 177 | "some_data": self.request.some_data 178 | }) 179 | return kwargs 180 | 181 | 182 | class CBTemplateView(generic.TemplateView): 183 | 184 | template_name = 'test.html' 185 | 186 | def get_context_data(self, **kwargs): 187 | kwargs['revsys'] = 42 188 | return kwargs 189 | 190 | 191 | class FormErrors(generic.FormView): 192 | form_class = NameForm 193 | template_name = 'form_errors.html' 194 | -------------------------------------------------------------------------------- /test_project/test_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revsys/django-test-plus/1292d172fc69c9a6e8fc0d5a93fa085820946fe5/test_project/test_project/__init__.py -------------------------------------------------------------------------------- /test_project/test_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for test_project project. 3 | 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/1.7/topics/settings/ 6 | 7 | For the full list of settings and their values, see 8 | https://docs.djangoproject.com/en/1.7/ref/settings/ 9 | """ 10 | 11 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 12 | import os 13 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 14 | 15 | 16 | # Quick-start development settings - unsuitable for production 17 | # See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/ 18 | 19 | # SECURITY WARNING: keep the secret key used in production secret! 20 | SECRET_KEY = 'mlqc(f8*woj%&b(gf=al7yc8$v3+(b8-=k&50%vyao8p5u8b6*' 21 | 22 | # SECURITY WARNING: don't run with debug turned on in production! 23 | DEBUG = True 24 | 25 | ALLOWED_HOSTS = [] 26 | 27 | 28 | # Application definition 29 | 30 | INSTALLED_APPS = ( 31 | 'django.contrib.admin', 32 | 'django.contrib.auth', 33 | 'django.contrib.contenttypes', 34 | 'django.contrib.sessions', 35 | 'django.contrib.messages', 36 | 'django.contrib.staticfiles', 37 | 'test_app', 38 | ) 39 | 40 | MIDDLEWARE = ( 41 | 'django.contrib.sessions.middleware.SessionMiddleware', 42 | 'django.middleware.common.CommonMiddleware', 43 | 'django.middleware.csrf.CsrfViewMiddleware', 44 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 45 | 'django.contrib.messages.middleware.MessageMiddleware', 46 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 47 | ) 48 | 49 | # Backwards compatibility for Django < 1.10 50 | MIDDLEWARE_CLASSES = MIDDLEWARE 51 | 52 | ROOT_URLCONF = 'test_app.urls' 53 | 54 | WSGI_APPLICATION = 'test_project.wsgi.application' 55 | 56 | 57 | # Database 58 | # https://docs.djangoproject.com/en/1.7/ref/settings/#databases 59 | 60 | DATABASES = { 61 | 'default': { 62 | 'ENGINE': 'django.db.backends.sqlite3', 63 | 'NAME': os.path.join(BASE_DIR, 'test_project'), 64 | } 65 | } 66 | 67 | # For Django 1.10+ 68 | TEMPLATES = [ 69 | { 70 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 71 | 'APP_DIRS': True, 72 | 'DIRS': [ 73 | os.path.join(BASE_DIR, 'test_project/templates'), 74 | ], 75 | 'OPTIONS': { 76 | 'context_processors': [ 77 | 'django.template.context_processors.debug', 78 | 'django.template.context_processors.request', 79 | 'django.contrib.auth.context_processors.auth', 80 | 'django.contrib.messages.context_processors.messages', 81 | ], 82 | 'debug': True 83 | } 84 | } 85 | ] 86 | 87 | # Internationalization 88 | # https://docs.djangoproject.com/en/1.7/topics/i18n/ 89 | 90 | LANGUAGE_CODE = 'en-us' 91 | 92 | TIME_ZONE = 'UTC' 93 | 94 | USE_I18N = True 95 | 96 | USE_L10N = True 97 | 98 | USE_TZ = True 99 | 100 | 101 | # Static files (CSS, JavaScript, Images) 102 | # https://docs.djangoproject.com/en/1.7/howto/static-files/ 103 | 104 | STATIC_URL = '/static/' 105 | 106 | TEST_RUNNER = 'test_plus.runner.NoLoggingRunner' 107 | 108 | REST_FRAMEWORK = { 109 | 'TEST_REQUEST_DEFAULT_FORMAT': 'json', 110 | } 111 | -------------------------------------------------------------------------------- /test_project/test_project/templates/base.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revsys/django-test-plus/1292d172fc69c9a6e8fc0d5a93fa085820946fe5/test_project/test_project/templates/base.html -------------------------------------------------------------------------------- /test_project/test_project/templates/registration/login.html: -------------------------------------------------------------------------------- 1 |

Login!

2 | -------------------------------------------------------------------------------- /test_project/test_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for test_project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.7/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 12 | 13 | from django.core.wsgi import get_wsgi_application 14 | application = get_wsgi_application() 15 | --------------------------------------------------------------------------------