2 |
--------------------------------------------------------------------------------
/test_project/test_app/templates/form_errors.html:
--------------------------------------------------------------------------------
1 | {# stub file used for a test #}
2 |
--------------------------------------------------------------------------------
/docs/modules/modules.rst:
--------------------------------------------------------------------------------
1 | test_plus
2 | =========
3 |
4 | .. toctree::
5 | :maxdepth: 4
6 |
7 | test_plus
8 |
--------------------------------------------------------------------------------
/test_plus/__init__.py:
--------------------------------------------------------------------------------
1 | from .test import APITestCase, TestCase
2 |
3 | __all__ = [
4 | 'APITestCase',
5 | 'TestCase',
6 | ]
7 |
--------------------------------------------------------------------------------
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | omit =
3 | test_project/*
4 | */.virtualenvs/*
5 | */site-packages/*
6 | test_plus/compat.py
7 | .eggs/*
8 | .tox/*
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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_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 |
--------------------------------------------------------------------------------
/.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-latest
12 | env:
13 | PYTHONDONTWRITEBYTECODE: true
14 | PYTHONPATH: test_project
15 | steps:
16 | - uses: actions/checkout@v4
17 |
18 | - name: Set up Python 3.13
19 | uses: actions/setup-python@v5
20 | with:
21 | python-version: "3.13"
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 |
--------------------------------------------------------------------------------
/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 | Velda Kiara - https://github.com/VeldaKiara
23 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/noxfile.py:
--------------------------------------------------------------------------------
1 | import nox
2 |
3 | DJANGO_VERSIONS = ["4.2", "5.1", "5.2", "6.0"]
4 | DRF_VERSIONS = ["3.15", "3.16"]
5 | PYTHON_VERSIONS = ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"]
6 |
7 | INVALID_PYTHON_DJANGO_SESSIONS = [
8 | ("3.10", "6.0"),
9 | ("3.11", "6.0"),
10 | ("3.14", "4.2"),
11 | ("3.14", "5.1"),
12 | ("3.14t", "4.2"),
13 | ("3.14t", "5.1"),
14 | ]
15 | INVALID_DRF_DJANGO_SESSIONS = []
16 | INVALID_DRF_PYTHON_SESSIONS = []
17 |
18 | nox.options.default_venv_backend = "uv|venv"
19 | nox.options.reuse_existing_virtualenvs = True
20 |
21 |
22 | @nox.session(python=PYTHON_VERSIONS, tags=["django"], venv_backend="uv")
23 | @nox.parametrize("django", DJANGO_VERSIONS)
24 | def tests(session: nox.Session, django: str) -> None:
25 | if (session.python, django) in INVALID_PYTHON_DJANGO_SESSIONS:
26 | session.skip()
27 | session.install(".[test]")
28 | session.install(f"django~={django}")
29 | session.run("pytest", *session.posargs)
30 |
31 |
32 | @nox.session(python=PYTHON_VERSIONS, tags=["drf"], venv_backend="uv")
33 | @nox.parametrize("django", DJANGO_VERSIONS)
34 | @nox.parametrize("drf", DRF_VERSIONS)
35 | def tests_drf(session: nox.Session, django: str, drf: str) -> None:
36 | if (session.python, django) in INVALID_PYTHON_DJANGO_SESSIONS:
37 | session.skip()
38 | if (drf, django) in INVALID_DRF_DJANGO_SESSIONS:
39 | session.skip()
40 | if (drf, session.python) in INVALID_DRF_PYTHON_SESSIONS:
41 | session.skip()
42 | session.install(".[test]")
43 | session.install(f"django~={django}")
44 | session.install(f"djangorestframework~={drf}")
45 | session.run("pytest", *session.posargs)
46 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/justfile:
--------------------------------------------------------------------------------
1 | @_default:
2 | just --list
3 |
4 | # Install development dependencies
5 | @bootstrap:
6 | python -m pip install --upgrade pip uv
7 | python -m uv pip install --upgrade nox
8 |
9 | # Run bumpver with optional arguments
10 | @bump *ARGS="--help":
11 | uv tool run bumpver {{ ARGS }}
12 |
13 | # Bump patch version (dry run by default, use ARGS="" to apply)
14 | @bump-patch *ARGS="--dry":
15 | uv tool run bumpver update --patch {{ ARGS }}
16 | uv lock
17 |
18 | # Bump minor version (dry run by default, use ARGS="" to apply)
19 | @bump-minor *ARGS="--dry":
20 | uv tool run bumpver update --minor {{ ARGS }}
21 | uv lock
22 |
23 | # Run test coverage report
24 | @coverage *ARGS="--no-install --reuse-existing-virtualenvs":
25 | python -m nox {{ ARGS }} --session "coverage"
26 |
27 | # Build documentation
28 | @docs *ARGS="--no-install --reuse-existing-virtualenvs":
29 | python -m nox {{ ARGS }} --session "docs"
30 |
31 | # Format justfile
32 | @fmt:
33 | just --fmt --unstable
34 |
35 | # Run linting checks
36 | @lint *ARGS="--no-install --reuse-existing-virtualenvs":
37 | python -m nox {{ ARGS }} --session "lint"
38 |
39 | # Compile requirements lock file
40 | @lock:
41 | python -m piptools compile --resolver=backtracking
42 |
43 | # Run all nox sessions
44 | @nox *ARGS="--no-install --reuse-existing-virtualenvs":
45 | python -m nox {{ ARGS }}
46 |
47 | # Build and publish a release to PyPI
48 | @release:
49 | rm -rf build dist
50 | uv build
51 | git push --tags
52 | uv publish
53 |
54 | # Run all tests
55 | @test *ARGS="--no-install --reuse-existing-virtualenvs":
56 | python -m nox {{ ARGS }}
57 |
58 | # Run tests with Django REST Framework
59 | @test-drf *ARGS="--no-install --reuse-existing-virtualenvs":
60 | python -m nox {{ ARGS }} --session "tests_drf"
61 |
62 | # Run tests in current environment
63 | @test-env *ARGS="--no-install --reuse-existing-virtualenvs":
64 | python -m nox {{ ARGS }} --session "tests_env"
65 |
66 | # Run tests with latest Python and Django versions
67 | @test-latest *ARGS="--no-install --reuse-existing-virtualenvs":
68 | python -m nox {{ ARGS }} --session "tests-3.12(django='5.1')"
69 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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-latest
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.10"
23 | - "3.11"
24 | - "3.12"
25 | - "3.13"
26 | - "3.14"
27 | - "3.14t"
28 | django-version:
29 | - "4.2" # LTS
30 | - "5.1"
31 | - "5.2" # LTS
32 | - "6.0"
33 | drf-version:
34 | - ""
35 | # - "3.12"
36 | # - "3.13"
37 | - "3.15"
38 | - "3.16"
39 | exclude:
40 |
41 | # Django 6.0 is compatible with Python 3.12+
42 | - python-version: "3.10"
43 | django-version: "6.0"
44 | - python-version: "3.11"
45 | django-version: "6.0"
46 | # Django 4.2 is not compatible with Python 3.14+
47 | - python-version: "3.14"
48 | django-version: "4.2"
49 | - python-version: "3.14t"
50 | django-version: "4.2"
51 | # Django 5.1 is not compatible with Python 3.14+
52 | - python-version: "3.14"
53 | django-version: "5.1"
54 | - python-version: "3.14t"
55 | django-version: "5.1"
56 |
57 | steps:
58 | - uses: actions/checkout@v4
59 |
60 | - name: Set up Python ${{ matrix.python-version }}
61 | uses: actions/setup-python@v5
62 | with:
63 | python-version: ${{ matrix.python-version }}
64 | cache: "pip"
65 | cache-dependency-path: '**/setup.cfg'
66 |
67 | - name: Install dependencies
68 | run: |
69 | python -m pip install uv
70 |
71 | - name: Install Django ${{ matrix.django-version }}
72 | if: ${{ matrix.drf-version == '' }}
73 | run: |
74 | python -m uv pip install --system "Django~=${{ matrix.django-version }}.0"
75 |
76 | - name: Install DRF ${{ matrix.drf-version }} and Django ${{ matrix.django-version }}
77 | if: ${{ matrix.drf-version }}
78 | run: |
79 | python -m uv pip install --system pytz "djangorestframework~=${{ matrix.drf-version }}.0" "Django~=${{ matrix.django-version }}.0"
80 |
81 | - name: Install dependencies
82 | run: |
83 | python -m uv pip install --system -e ".[test]"
84 |
85 | - run: |
86 | pytest .
87 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["hatchling"]
3 | build-backend = "hatchling.build"
4 |
5 | [project]
6 | name = "django-test-plus"
7 | version = "2.4.1"
8 | description = "django-test-plus provides useful additions to Django's default TestCase"
9 | authors = [
10 | { name = "Frank Wiles", email = "frank@revsys.com" },
11 | { name = "Jeff Triplett", email = "jeff@revsys.com" },
12 | ]
13 | classifiers = [
14 | "Development Status :: 5 - Production/Stable",
15 | "Environment :: Web Environment",
16 | "Framework :: Django",
17 | "Framework :: Django :: 4.2",
18 | "Framework :: Django :: 5.0",
19 | "Framework :: Django :: 5.1",
20 | "Framework :: Django :: 5.2",
21 | "Framework :: Django :: 6.0",
22 | "Framework :: Pytest",
23 | "Intended Audience :: Developers",
24 | "License :: OSI Approved :: BSD License",
25 | "Operating System :: OS Independent",
26 | "Programming Language :: Python :: 3",
27 | "Programming Language :: Python :: 3.10",
28 | "Programming Language :: Python :: 3.11",
29 | "Programming Language :: Python :: 3.12",
30 | "Programming Language :: Python :: 3.13",
31 | "Programming Language :: Python :: 3.14",
32 | ]
33 | dependencies = ["packaging"]
34 | requires-python = ">=3.10"
35 |
36 | [project.readme]
37 | file = "README.md"
38 | content-type = "text/markdown"
39 |
40 | [project.urls]
41 | Changelog = "https://github.com/revsys/django-test-plus/blob/main/CHANGELOG.md"
42 | Homepage = "https://github.com/revsys/django-test-plus/"
43 |
44 | [project.entry-points]
45 | pytest11 = { test_plus = "test_plus.plugin" }
46 |
47 | [project.optional-dependencies]
48 | docs = ["sphinx", "furo", "sphinx-copybutton", "sphinx-prompt"]
49 | lint = ["pre-commit"]
50 | test = ["factory-boy", "pytest", "pytest-cov", "pytest-django"]
51 | testing = ["django-test-plus[test]"]
52 |
53 | [tool.bumpver]
54 | current_version = "2.4.1"
55 | version_pattern = "MAJOR.MINOR.PATCH"
56 | commit_message = "Bump version {old_version} -> {new_version}"
57 | commit = true
58 | tag = true
59 | push = false
60 |
61 | [tool.bumpver.file_patterns]
62 | "pyproject.toml" = ['current_version = "{version}"', 'version = "{version}"']
63 | "docs/conf.py" = ["version = '{version}'"]
64 |
65 | [tool.coverage.paths]
66 | source = ["test_plus"]
67 |
68 | [tool.coverage.report]
69 | exclude_lines = [
70 | "pragma: no cover",
71 | "if DEBUG:",
72 | "if not DEBUG:",
73 | "if settings.DEBUG:",
74 | "if TYPE_CHECKING:",
75 | 'def __str__\(self\)\s?\-?\>?\s?\w*\:',
76 | ]
77 | fail_under = 75
78 |
79 | [tool.coverage.run]
80 | omit = ["docs/*"]
81 | source = ["test_plus"]
82 |
83 | [tool.hatch.build.targets.wheel]
84 | packages = ["test_plus"]
85 |
86 | [tool.hatch.build.targets.sdist]
87 | include = [
88 | "/test_plus",
89 | "/CHANGELOG.md",
90 | "/README.md",
91 | ]
92 |
93 | [tool.isort]
94 | profile = "black"
95 | honor_noqa = true
96 |
97 | [tool.ruff]
98 | line-length = 120
99 |
100 | [tool.pytest.ini_options]
101 | DJANGO_SETTINGS_MODULE = "test_project.settings"
102 | # addopts = "--reuse-db --cov"
103 | addopts = "--reuse-db"
104 | norecursedirs = ".* build dist docs .eggs/* *.egg-info htmlcov test_plus .git .tox"
105 | python_files = "tests.py test_*.py *_tests.py"
106 | pythonpath = ["test_project"]
107 | testpaths = ["test_project"]
108 |
--------------------------------------------------------------------------------
/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 | is_ajax = request.headers.get('x-requested-with') == 'XMLHttpRequest'
121 | status = 200 if is_ajax else 404
122 | return HttpResponse('', status=status)
123 |
124 |
125 | def view_contains(request):
126 | return render(request, 'test.html', {})
127 |
128 |
129 | def view_headers(request):
130 | response = HttpResponse('', content_type='text/plain', status=200)
131 | response['X-Custom'] = 1
132 | return response
133 |
134 |
135 | # Class-based test views
136 |
137 | class CBView(generic.View):
138 |
139 | def get(self, request):
140 | return HttpResponse('', status=200)
141 |
142 | def post(self, request):
143 | return HttpResponse('', status=200)
144 |
145 | def special(self):
146 | if hasattr(self, 'special_value'):
147 | return self.special_value
148 | else:
149 | return False
150 |
151 |
152 | class CBLoginRequiredView(generic.View):
153 |
154 | @method_decorator(login_required)
155 | def dispatch(self, *args, **kwargs):
156 | return super(CBLoginRequiredView, self).dispatch(*args, **kwargs)
157 |
158 | def get(self, request):
159 | return HttpResponse('', status=200)
160 |
161 |
162 | class CBDataView(generic.UpdateView):
163 |
164 | model = Data
165 | template_name = "test.html"
166 | form_class = DataForm
167 |
168 | def get_success_url(self):
169 | return reverse("view-200")
170 |
171 | def get_context_data(self, **kwargs):
172 | kwargs = super(CBDataView, self).get_context_data(**kwargs)
173 | if hasattr(self.request, "some_data"):
174 | kwargs.update({
175 | "some_data": self.request.some_data
176 | })
177 | return kwargs
178 |
179 |
180 | class CBTemplateView(generic.TemplateView):
181 |
182 | template_name = 'test.html'
183 |
184 | def get_context_data(self, **kwargs):
185 | kwargs['revsys'] = 42
186 | return kwargs
187 |
188 |
189 | class FormErrors(generic.FormView):
190 | form_class = NameForm
191 | template_name = 'form_errors.html'
192 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changes
2 |
3 | ## Version 2.4.1 - December 19th, 2025
4 |
5 | - Migrate from setup.py/setup.cfg to pyproject.toml with hatchling
6 | - Use uv for building and publishing
7 |
8 | ## Version 2.4.0 - December 19th, 2025
9 |
10 | - Add Django 6.0 support
11 | - Add Python 3.14 support
12 | - Drop Python 3.9 support
13 |
14 | ## Version 2.3.0 - July 2nd, 2025
15 |
16 | - Add Django 5.2 support
17 | - Drop Django 3.2 tests
18 | - Add Python 3.13 tests
19 | - Cleanup Github Actions and README a bit
20 |
21 | ## Version 2.2.4 - June 24th, 2024
22 |
23 | - Fix bug with APITest case
24 |
25 | ## Version 2.2.3 - July 11th, 2023
26 |
27 | - Fix bug where email addresses were not created by make_user()
28 |
29 | ## Version 2.2.2 - June 27, 2023
30 |
31 | - Fix issue with User creation helper when User model doesn't have a username field
32 | - Improve assertNumQueriesLessThan
33 | - Add assertInContext
34 |
35 | ## version 2.2.1 - October 12, 2022
36 |
37 | - Add Django 4.2 support
38 |
39 | ## version 2.2.0 - May 19th, 2021
40 |
41 | - Add support for Django 3.2.
42 |
43 | ## version 2.1.1 - May 19th, 2021
44 |
45 | - Add official support for Python 3.9.
46 |
47 | ## version 2.0.1 - May 19th, 2021
48 |
49 | - Make assertLoginRequired work for pytest tp fixture.
50 |
51 | ## version 2.0.0 - May 18th, 2021
52 |
53 | - Drops Python 2.7, 3.4, and pypy and Django 1.11 support.
54 | - Add Django 3.1 support.
55 |
56 | ## version 1.4.0 - December 3rd, 2019
57 |
58 | - Added Django 3.0 support
59 | - Misc dependency updates
60 |
61 | ## version 1.3.1 - July 31st, 2019
62 |
63 | - Made `make_user` and `get_instance` class based methods, so they can be used
64 | in `setupUpTestData`. Thanks @avelis for the report.
65 |
66 | ## version 1.3.0 - July 31st, 2019
67 |
68 | - Add `tp_api` pytest fixture.
69 |
70 | ## version 1.2.0 - May 5h, 2019
71 |
72 | - Add optional `msg` argument to assertEqual method. Thanks @davitovmasyan.
73 |
74 | ## version 1.1.1 - July 2nd, 2018
75 |
76 | - Fix premature loading of Django settings under pytest
77 |
78 | ## version 1.1.0 - May 20th, 2018
79 |
80 | - Added real pytest fixture support!
81 | - 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.
82 | - Moved README and docs to Markdown
83 |
84 | ## version 1.0.22 - January 9th, 2018
85 |
86 | - Fix bug where we did not pass data dictionary to RequestFactory.get() properly
87 |
88 | ## version 1.0.21 - December 15th, 2017
89 |
90 | - Add response_204 method
91 |
92 | ## version 1.0.20 - October 31st, 2017
93 |
94 | - The Halloween Release!
95 | - Fixes to CI to ensure we really test Django 2.0
96 |
97 | ## version 1.0.19 - October 24th, 2017
98 |
99 | - Django 2.0 support
100 | - Dropped support for Python 3.3
101 | - Dropped support for Django < 1.8
102 | - Added APITestCase for better DRF testing
103 |
104 | ## version 1.0.18 - June 26th, 2017
105 |
106 | - Allow custom Request objects in get() and post()
107 | - Begin testing against Python 3.6 and Django 1.11
108 |
109 | ## version 1.0.17 - January 31st, 2017
110 |
111 | - Added assertResponseHeaders
112 |
113 | ## version 1.0.16 - October 19th, 2016
114 |
115 | - Added print_form_errors utility
116 |
117 | ## version 1.0.15 - August 18th, 2016
118 |
119 | - Added helper methods for more HTTP methods like put, patch, and trace
120 | - Added assertResponseContains and assertResponseNotContains
121 |
122 | ## version 1.0.14 - June 25th, 2016
123 |
124 | - Fixed documentation typo
125 | - Added response_400() test
126 | - Added Guinslym and David Arcos to AUTHORS.txt
127 |
128 | ## version 1.0.13 - May 23rd, 2016
129 |
130 | - Added response_401() test
131 | - Fixed situation where User models without a 'username' field could not be
132 | used as easily. Now credential field is automatically determined.
133 | - Fixed assertLoginRequired when settings.LOGIN_URL is a named URL pattern
134 | - 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
135 |
136 | ## version 1.0.12 - March 4th, 2016
137 |
138 | - Fixed incorrect documentation
139 | - Added response_405 and response_410 test methods
140 |
141 | ## version 1.0.11 - November 11, 2015
142 |
143 | - Fixed bad README typos and merge artifacts
144 |
145 | ## version 1.0.10 - November 11, 2015
146 |
147 | - Added response_405() test
148 | - requirements.txt typo
149 |
150 | ## version 1.0.9 - August 28, 2015
151 |
152 | - README typo
153 | - Fix more bad argument handling in CBVTest methods
154 | - Fix alias issue with PyCharm
155 |
156 | ## version 1.0.8 - August 12, 2015
157 |
158 | - Bug fix with argument order
159 |
160 | ## version 1.0.7 - July 31st, 2015
161 |
162 | - get/post test methods now accept the `follow` boolean.
163 |
164 | ## version 1.0.6 - July 12th, 2015
165 |
166 | - Allow overriding password to be not just 'password'
167 | - Added CBVTestCase to be able to test generic CBVs without hitting routing or middleware
168 |
169 | ## version 1.0.5 - June 16th, 2015
170 |
171 | - Allow 'from test_plus import TestCase'
172 | - Make response_XXX() be able to use last_response
173 | - Add extra lazy option of passing full URL to get() and post()
174 | - Pass along QUERY_STRING information via data kwargs on gets()
175 |
176 | ## version 1.0.4 - May 29th, 2015
177 |
178 | - README formatting fixes
179 | - Added get_context() method
180 | - Added assertContext() method
181 |
182 | ## version 1.0.3 - May 28th, 2015
183 |
184 | - Added extras kwargs to be able to pass to url resolution
185 | - Added response_403
186 | - README typo
187 |
188 | ## version 1.0.2 - May 23rd, 2015
189 |
190 | - Actually fixing README by moving README.md to README.rst
191 | - Added docs for assertNuMQueriesLessThan()
192 |
193 | ## version 1.0.1 - May 23rd, 2015
194 |
195 | - Fixing README markdown on PyPI issue
196 |
197 | ## version 1.0.0 - May 23rd, 2015
198 |
199 | - Initial release
200 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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.4.1'
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # django-test-plus
2 |
3 | Useful additions to Django's default TestCase from [REVSYS](https://www.revsys.com/)
4 |
5 | [](https://pypi.org/project/django-test-plus/)
6 | [](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 | - Python 3.10, 3.11, 3.12, 3.13, and 3.14.
23 |
24 | - Django 4.2 LTS, 5.1, 5.2 LTS, and 6.0.
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 |
--------------------------------------------------------------------------------
/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('