diff --git a/django/contrib/admin/helpers.py b/django/contrib/admin/helpers.py index cc5317199d..9068ce57da 100644 --- a/django/contrib/admin/helpers.py +++ b/django/contrib/admin/helpers.py @@ -441,9 +441,8 @@ class InlineAdminFormSet: def forms(self): return self.formset.forms - @property def non_form_errors(self): - return self.formset.non_form_errors + return self.formset.non_form_errors() @property def is_bound(self): diff --git a/django/test/testcases.py b/django/test/testcases.py index bfcfef58cb..53d3838bb8 100644 --- a/django/test/testcases.py +++ b/django/test/testcases.py @@ -1,5 +1,6 @@ import asyncio import difflib +import inspect import json import logging import posixpath @@ -42,6 +43,7 @@ from django.db import DEFAULT_DB_ALIAS, connection, connections, transaction from django.forms.fields import CharField from django.http import QueryDict from django.http.request import split_domain_port, validate_host +from django.http.response import HttpResponseBase from django.test.client import AsyncClient, Client from django.test.html import HTMLParseError, parse_html from django.test.signals import template_rendered @@ -165,6 +167,127 @@ class _DatabaseFailure: raise DatabaseOperationForbidden(self.message) +# RemovedInDjango50Warning +class _AssertFormErrorDeprecationHelper: + @staticmethod + def assertFormError(self, response, form, field, errors, msg_prefix=""): + """ + Search through all the rendered contexts of the `response` for a form named + `form` then dispatch to the new assertFormError() using that instance. + If multiple contexts contain the form, they're all checked in order and any + failure will abort (this matches the old behavior). + """ + warning_msg = ( + f"Passing response to assertFormError() is deprecated. Use the form object " + f"directly: assertFormError(response.context[{form!r}], {field!r}, ...)" + ) + warnings.warn(warning_msg, RemovedInDjango50Warning, stacklevel=2) + + full_msg_prefix = f"{msg_prefix}: " if msg_prefix else "" + contexts = to_list(response.context) if response.context is not None else [] + if not contexts: + self.fail( + f"{full_msg_prefix}Response did not use any contexts to render the " + f"response" + ) + # Search all contexts for the error. + found_form = False + for i, context in enumerate(contexts): + if form not in context: + continue + found_form = True + self.assertFormError(context[form], field, errors, msg_prefix=msg_prefix) + if not found_form: + self.fail( + f"{full_msg_prefix}The form '{form}' was not used to render the " + f"response" + ) + + @staticmethod + def assertFormsetError( + self, response, formset, form_index, field, errors, msg_prefix="" + ): + """ + Search for a formset named "formset" in the "response" and dispatch to + the new assertFormsetError() using that instance. If the name is found + in multiple contexts they're all checked in order and any failure will + abort the test. + """ + warning_msg = ( + f"Passing response to assertFormsetError() is deprecated. Use the formset " + f"object directly: assertFormsetError(response.context[{formset!r}], " + f"{form_index!r}, ...)" + ) + warnings.warn(warning_msg, RemovedInDjango50Warning, stacklevel=2) + + full_msg_prefix = f"{msg_prefix}: " if msg_prefix else "" + contexts = to_list(response.context) if response.context is not None else [] + if not contexts: + self.fail( + f"{full_msg_prefix}Response did not use any contexts to render the " + f"response" + ) + found_formset = False + for i, context in enumerate(contexts): + if formset not in context or not hasattr(context[formset], "forms"): + continue + found_formset = True + self.assertFormsetError( + context[formset], form_index, field, errors, msg_prefix + ) + if not found_formset: + self.fail( + f"{full_msg_prefix}The formset '{formset}' was not used to render the " + f"response" + ) + + @classmethod + def patch_signature(cls, new_method): + """ + Replace the decorated method with a new one that inspects the passed + args/kwargs and dispatch to the old implementation (with deprecation + warning) when it detects the old signature. + """ + + @wraps(new_method) + def patched_method(self, *args, **kwargs): + old_method = getattr(cls, new_method.__name__) + old_signature = inspect.signature(old_method) + try: + old_bound_args = old_signature.bind(self, *args, **kwargs) + except TypeError: + # If old signature doesn't match then either: + # 1) new signature will match + # 2) or a TypeError will be raised showing the user information + # about the new signature. + return new_method(self, *args, **kwargs) + + new_signature = inspect.signature(new_method) + try: + new_bound_args = new_signature.bind(self, *args, **kwargs) + except TypeError: + # Old signature matches but not the new one (because of + # previous try/except). + return old_method(self, *args, **kwargs) + + # If both signatures match, decide on which method to call by + # inspecting the first arg (arg[0] = self). + assert old_bound_args.args[1] == new_bound_args.args[1] + if hasattr( + old_bound_args.args[1], "context" + ): # Looks like a response object => old method. + return old_method(self, *args, **kwargs) + elif isinstance(old_bound_args.args[1], HttpResponseBase): + raise ValueError( + f"{old_method.__name__}() is only usable on responses fetched " + f"using the Django test Client." + ) + else: + return new_method(self, *args, **kwargs) + + return patched_method + + class SimpleTestCase(unittest.TestCase): # The class we'll use for the test client self.client. @@ -585,22 +708,19 @@ class SimpleTestCase(unittest.TestCase): self.assertEqual(field_errors, errors, msg_prefix + failure_message) - def assertFormError(self, response, form, field, errors, msg_prefix=""): + # RemovedInDjango50Warning: When the deprecation ends, remove the + # decorator. + @_AssertFormErrorDeprecationHelper.patch_signature + def assertFormError(self, form, field, errors, msg_prefix=""): """ - Assert that a form used to render the response has a specific field - error. + Assert that a field named "field" on the given form object has specific + errors. + + errors can be either a single error message or a list of errors + messages. Using errors=[] test that the field has no errors. + + You can pass field=None to check the form's non-field errors. """ - self._check_test_client_response(response, "context", "assertFormError") - if msg_prefix: - msg_prefix += ": " - - # Put context(s) into a list to simplify processing. - contexts = [] if response.context is None else to_list(response.context) - if not contexts: - self.fail( - msg_prefix + "Response did not use any contexts to render the response" - ) - if errors is None: warnings.warn( "Passing errors=None to assertFormError() is deprecated, use " @@ -609,47 +729,25 @@ class SimpleTestCase(unittest.TestCase): stacklevel=2, ) errors = [] - # Put error(s) into a list to simplify processing. - errors = to_list(errors) - # Search all contexts for the error. - found_form = False - for i, context in enumerate(contexts): - if form in context: - found_form = True - self._assert_form_error( - context[form], field, errors, msg_prefix, "form %r" % context[form] - ) - if not found_form: - self.fail( - msg_prefix + "The form '%s' was not used to render the response" % form - ) - - def assertFormsetError( - self, response, formset, form_index, field, errors, msg_prefix="" - ): - """ - Assert that a formset used to render the response has a specific error. - - For field errors, specify the ``form_index`` and the ``field``. - For non-field errors, specify the ``form_index`` and the ``field`` as - None. - For non-form errors, specify ``form_index`` as None and the ``field`` - as None. - """ - self._check_test_client_response(response, "context", "assertFormsetError") - # Add punctuation to msg_prefix if msg_prefix: msg_prefix += ": " + errors = to_list(errors) + self._assert_form_error(form, field, errors, msg_prefix, f"form {form!r}") - # Put context(s) into a list to simplify processing. - contexts = [] if response.context is None else to_list(response.context) - if not contexts: - self.fail( - msg_prefix + "Response did not use any contexts to " - "render the response" - ) + # RemovedInDjango50Warning: When the deprecation ends, remove the + # decorator. + @_AssertFormErrorDeprecationHelper.patch_signature + def assertFormsetError(self, formset, form_index, field, errors, msg_prefix=""): + """ + Similar to assertFormError() but for formsets. + Use form_index=None to check the formset's non-form errors (in that + case, you must also use field=None). + Otherwise use an integer to check the formset's n-th form for errors. + + Other parameters are the same as assertFormError(). + """ if errors is None: warnings.warn( "Passing errors=None to assertFormsetError() is deprecated, " @@ -662,50 +760,31 @@ class SimpleTestCase(unittest.TestCase): if form_index is None and field is not None: raise ValueError("You must use field=None with form_index=None.") - # Put error(s) into a list to simplify processing. + if msg_prefix: + msg_prefix += ": " errors = to_list(errors) - # Search all contexts for the error. - found_formset = False - for i, context in enumerate(contexts): - if formset not in context or not hasattr(context[formset], "forms"): - continue - formset_repr = repr(context[formset]) - if not context[formset].is_bound: - self.fail( - f"{msg_prefix}The formset {formset_repr} is not bound, it will " - f"never have any errors." - ) - found_formset = True - if form_index is not None: - form_count = context[formset].total_form_count() - if form_index >= form_count: - form_or_forms = "forms" if form_count > 1 else "form" - self.fail( - f"{msg_prefix}The formset {formset_repr} only has " - f"{form_count} {form_or_forms}." - ) - if form_index is not None: - form_repr = f"form {form_index} of formset {formset_repr}" - self._assert_form_error( - context[formset].forms[form_index], - field, - errors, - msg_prefix, - form_repr, - ) - else: - failure_message = ( - f"{msg_prefix}The non-form errors of formset {formset_repr} don't " - f"match." - ) - self.assertEqual( - context[formset].non_form_errors(), errors, failure_message - ) - if not found_formset: + if not formset.is_bound: self.fail( - msg_prefix - + "The formset '%s' was not used to render the response" % formset + f"{msg_prefix}The formset {formset!r} is not bound, it will never have " + f"any errors." + ) + if form_index is not None and form_index >= formset.total_form_count(): + form_count = formset.total_form_count() + form_or_forms = "forms" if form_count > 1 else "form" + self.fail( + f"{msg_prefix}The formset {formset!r} only has {form_count} " + f"{form_or_forms}." + ) + if form_index is not None: + form_repr = f"form {form_index} of formset {formset!r}" + self._assert_form_error( + formset.forms[form_index], field, errors, msg_prefix, form_repr + ) + else: + failure_message = f"The non-form errors of formset {formset!r} don't match." + self.assertEqual( + formset.non_form_errors(), errors, msg_prefix + failure_message ) def _get_template_used(self, response, template_name, msg_prefix, method_name): diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index ab147725c1..eb6d7f7b2f 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -97,6 +97,10 @@ details on these changes. * The ``django.utils.timezone.utc`` alias to ``datetime.timezone.utc`` will be removed. +* Passing a response object and a form/formset name to + ``SimpleTestCase.assertFormError()`` and ``assertFormsetError()`` will no + longer be allowed. + .. _deprecation-removed-in-4.1: 4.1 diff --git a/docs/releases/4.1.txt b/docs/releases/4.1.txt index 7660780ae5..4413fdfc9a 100644 --- a/docs/releases/4.1.txt +++ b/docs/releases/4.1.txt @@ -327,6 +327,10 @@ Tests * A nested atomic block marked as durable in :class:`django.test.TestCase` now raises a ``RuntimeError``, the same as outside of tests. +* :meth:`.SimpleTestCase.assertFormError` and + :meth:`~.SimpleTestCase.assertFormsetError` now support passing a + form/formset object directly. + URLs ~~~~ @@ -449,6 +453,9 @@ Miscellaneous * The admin log out UI now uses ``POST`` requests. +* The undocumented ``InlineAdminFormSet.non_form_errors`` property is replaced + by the ``non_form_errors()`` method. This is consistent with ``BaseFormSet``. + .. _deprecated-features-4.1: Features deprecated in 4.1 @@ -552,6 +559,15 @@ Miscellaneous * The :data:`django.utils.timezone.utc` alias to :attr:`datetime.timezone.utc` is deprecated. Use :attr:`datetime.timezone.utc` directly. +* Passing a response object and a form/formset name to + ``SimpleTestCase.assertFormError()`` and ``assertFormsetError()`` is + deprecated. Use:: + + assertFormError(response.context['form_name'], …) + assertFormsetError(response.context['formset_name'], …) + + or pass the form/formset object directly instead. + Features removed in 4.1 ======================= diff --git a/docs/topics/testing/tools.txt b/docs/topics/testing/tools.txt index c6923c6c87..836dab54e4 100644 --- a/docs/topics/testing/tools.txt +++ b/docs/topics/testing/tools.txt @@ -1473,47 +1473,65 @@ your test suite. self.assertFieldOutput(EmailField, {'a@a.com': 'a@a.com'}, {'aaa': ['Enter a valid email address.']}) -.. method:: SimpleTestCase.assertFormError(response, form, field, errors, msg_prefix='') +.. method:: SimpleTestCase.assertFormError(form, field, errors, msg_prefix='') - Asserts that a field on a form raises the provided list of errors when - rendered on the form. + Asserts that a field on a form raises the provided list of errors. - ``response`` must be a response instance returned by the - :class:`test client `. + ``form`` is a ``Form`` instance. The form must be + :ref:`bound ` but not necessarily + validated (``assertFormError()`` will automatically call ``full_clean()`` + on the form). - ``form`` is the name the ``Form`` instance was given in the template - context of the response. + ``field`` is the name of the field on the form to check. To check the form's + :meth:`non-field errors `, use + ``field=None``. - ``field`` is the name of the field on the form to check. If ``field`` - has a value of ``None``, non-field errors (errors you can access via - :meth:`form.non_field_errors() `) will - be checked. + ``errors`` is a list of all the error strings that the field is expected to + have. You can also pass a single error string if you only expect one error + which means that ``errors='error message'`` is the same as + ``errors=['error message']``. - ``errors`` is an error string, or a list of error strings, that are - expected as a result of form validation. + .. versionchanged:: 4.1 -.. method:: SimpleTestCase.assertFormsetError(response, formset, form_index, field, errors, msg_prefix='') + In older versions, using an empty error list with ``assertFormError()`` + would always pass, regardless of whether the field had any errors or + not. Starting from Django 4.1, using ``errors=[]`` will only pass if + the field actually has no errors. + + Django 4.1 also changed the behavior of ``assertFormError()`` when a + field has multiple errors. In older versions, if a field had multiple + errors and you checked for only some of them, the test would pass. + Starting from Django 4.1, the error list must be an exact match to the + field's actual errors. + + .. deprecated:: 4.1 + + Support for passing a response object and a form name to + ``assertFormError()`` is deprecated and will be removed in Django 5.0. + Use the form instance directly instead. + +.. method:: SimpleTestCase.assertFormsetError(formset, form_index, field, errors, msg_prefix='') Asserts that the ``formset`` raises the provided list of errors when rendered. - ``response`` must be a response instance returned by the - :class:`test client `. + ``formset`` is a ``Formset`` instance. The formset must be bound but not + necessarily validated (``assertFormsetError()`` will automatically call the + ``full_clean()`` on the formset). - ``formset`` is the name the ``Formset`` instance was given in the template - context of the response. + ``form_index`` is the number of the form within the ``Formset`` (starting + from 0). Use ``form_index=None`` to check the formset's non-form errors, + i.e. the errors you get when calling ``formset.non_form_errors()``. In that + case you must also use ``field=None``. - ``form_index`` is the number of the form within the ``Formset``. If - ``form_index`` has a value of ``None``, non-form errors (errors you can - access via ``formset.non_form_errors()``) will be checked. + ``field`` and ``errors`` have the same meaning as the parameters to + ``assertFormError()``. - ``field`` is the name of the field on the form to check. If ``field`` - has a value of ``None``, non-field errors (errors you can access via - :meth:`form.non_field_errors() `) will - be checked. + .. deprecated:: 4.1 - ``errors`` is an error string, or a list of error strings, that are - expected as a result of form validation. + Support for passing a response object and a formset name to + ``assertFormsetError()`` is deprecated and will be removed in Django + 5.0. Use the formset instance directly instead. .. method:: SimpleTestCase.assertContains(response, text, count=None, status_code=200, msg_prefix='', html=False) diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index 185c1bff26..94d9d80289 100644 --- a/tests/admin_views/tests.py +++ b/tests/admin_views/tests.py @@ -2128,7 +2128,9 @@ class AdminViewPermissionsTest(TestCase): self.assertEqual(response.status_code, 302) login = self.client.post(login_url, self.no_username_login) self.assertEqual(login.status_code, 200) - self.assertFormError(login, "form", "username", ["This field is required."]) + self.assertFormError( + login.context["form"], "username", ["This field is required."] + ) def test_login_redirect_for_direct_get(self): """ @@ -6711,10 +6713,9 @@ class UserAdminTest(TestCase): }, ) self.assertEqual(response.status_code, 200) - self.assertFormError(response, "adminform", "password1", []) + self.assertFormError(response.context["adminform"], "password1", []) self.assertFormError( - response, - "adminform", + response.context["adminform"], "password2", ["The two password fields didn’t match."], ) @@ -7836,12 +7837,13 @@ class AdminViewOnSiteTests(TestCase): reverse("admin:admin_views_parentwithdependentchildren_add"), post_data ) self.assertFormError( - response, "adminform", "some_required_info", ["This field is required."] + response.context["adminform"], + "some_required_info", + ["This field is required."], ) - self.assertFormError(response, "adminform", None, []) + self.assertFormError(response.context["adminform"], None, []) self.assertFormsetError( - response, - "inline_admin_formset", + response.context["inline_admin_formset"], 0, None, [ @@ -7849,7 +7851,9 @@ class AdminViewOnSiteTests(TestCase): "contrived test case" ], ) - self.assertFormsetError(response, "inline_admin_formset", None, None, []) + self.assertFormsetError( + response.context["inline_admin_formset"], None, None, [] + ) def test_change_view_form_and_formsets_run_validation(self): """ @@ -7879,11 +7883,12 @@ class AdminViewOnSiteTests(TestCase): post_data, ) self.assertFormError( - response, "adminform", "some_required_info", ["This field is required."] + response.context["adminform"], + "some_required_info", + ["This field is required."], ) self.assertFormsetError( - response, - "inline_admin_formset", + response.context["inline_admin_formset"], 0, None, [ diff --git a/tests/test_client/tests.py b/tests/test_client/tests.py index 8fcf3e046d..f13b05f25b 100644 --- a/tests/test_client/tests.py +++ b/tests/test_client/tests.py @@ -437,10 +437,10 @@ class ClientTest(TestCase): response = self.client.post("/form_view/", post_data) self.assertContains(response, "This field is required.", 3) self.assertTemplateUsed(response, "Invalid POST Template") - - self.assertFormError(response, "form", "email", "This field is required.") - self.assertFormError(response, "form", "single", "This field is required.") - self.assertFormError(response, "form", "multi", "This field is required.") + form = response.context["form"] + self.assertFormError(form, "email", "This field is required.") + self.assertFormError(form, "single", "This field is required.") + self.assertFormError(form, "multi", "This field is required.") def test_form_error(self): "POST erroneous data to a form" @@ -455,7 +455,9 @@ class ClientTest(TestCase): self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, "Invalid POST Template") - self.assertFormError(response, "form", "email", "Enter a valid email address.") + self.assertFormError( + response.context["form"], "email", "Enter a valid email address." + ) def test_valid_form_with_template(self): "POST valid data to a form using multiple templates" @@ -480,10 +482,10 @@ class ClientTest(TestCase): self.assertTemplateUsed(response, "form_view.html") self.assertTemplateUsed(response, "base.html") self.assertTemplateNotUsed(response, "Invalid POST Template") - - self.assertFormError(response, "form", "email", "This field is required.") - self.assertFormError(response, "form", "single", "This field is required.") - self.assertFormError(response, "form", "multi", "This field is required.") + form = response.context["form"] + self.assertFormError(form, "email", "This field is required.") + self.assertFormError(form, "single", "This field is required.") + self.assertFormError(form, "multi", "This field is required.") def test_form_error_with_template(self): "POST erroneous data to a form using multiple templates" @@ -500,7 +502,9 @@ class ClientTest(TestCase): self.assertTemplateUsed(response, "base.html") self.assertTemplateNotUsed(response, "Invalid POST Template") - self.assertFormError(response, "form", "email", "Enter a valid email address.") + self.assertFormError( + response.context["form"], "email", "Enter a valid email address." + ) def test_unknown_page(self): "GET an invalid URL" diff --git a/tests/test_utils/tests.py b/tests/test_utils/tests.py index e0204e7083..e704ed5dc1 100644 --- a/tests/test_utils/tests.py +++ b/tests/test_utils/tests.py @@ -1373,6 +1373,7 @@ class TestFormset(formset_factory(TestForm)): class AssertFormErrorTests(SimpleTestCase): + @ignore_warnings(category=RemovedInDjango50Warning) def test_non_client_response(self): msg = ( "assertFormError() is only usable on responses fetched using the " @@ -1380,8 +1381,9 @@ class AssertFormErrorTests(SimpleTestCase): ) response = HttpResponse() with self.assertRaisesMessage(ValueError, msg): - self.assertFormError(response, "formset", 0, "field", "invalid value") + self.assertFormError(response, "form", "field", "invalid value") + @ignore_warnings(category=RemovedInDjango50Warning) def test_response_with_no_context(self): msg = "Response did not use any contexts to render the response" response = mock.Mock(context=[]) @@ -1397,6 +1399,7 @@ class AssertFormErrorTests(SimpleTestCase): msg_prefix=msg_prefix, ) + @ignore_warnings(category=RemovedInDjango50Warning) def test_form_not_in_context(self): msg = "The form 'form' was not used to render the response" response = mock.Mock(context=[{}]) @@ -1408,18 +1411,32 @@ class AssertFormErrorTests(SimpleTestCase): response, "form", "field", "invalid value", msg_prefix=msg_prefix ) + def test_single_error(self): + self.assertFormError(TestForm.invalid(), "field", "invalid value") + + def test_error_list(self): + self.assertFormError(TestForm.invalid(), "field", ["invalid value"]) + + def test_empty_errors_valid_form(self): + self.assertFormError(TestForm.valid(), "field", []) + + def test_empty_errors_valid_form_non_field_errors(self): + self.assertFormError(TestForm.valid(), None, []) + def test_field_not_in_form(self): msg = ( "The form does not " "contain the field 'other_field'." ) - response = mock.Mock(context=[{"form": TestForm.invalid()}]) with self.assertRaisesMessage(AssertionError, msg): - self.assertFormError(response, "form", "other_field", "invalid value") + self.assertFormError(TestForm.invalid(), "other_field", "invalid value") msg_prefix = "Custom prefix" with self.assertRaisesMessage(AssertionError, f"{msg_prefix}: {msg}"): self.assertFormError( - response, "form", "other_field", "invalid value", msg_prefix=msg_prefix + TestForm.invalid(), + "other_field", + "invalid value", + msg_prefix=msg_prefix, ) def test_field_with_no_errors(self): @@ -1427,14 +1444,13 @@ class AssertFormErrorTests(SimpleTestCase): "The errors of field 'field' on form don't match." ) - response = mock.Mock(context=[{"form": TestForm.valid()}]) with self.assertRaisesMessage(AssertionError, msg) as ctx: - self.assertFormError(response, "form", "field", "invalid value") + self.assertFormError(TestForm.valid(), "field", "invalid value") self.assertIn("[] != ['invalid value']", str(ctx.exception)) msg_prefix = "Custom prefix" with self.assertRaisesMessage(AssertionError, f"{msg_prefix}: {msg}"): self.assertFormError( - response, "form", "field", "invalid value", msg_prefix=msg_prefix + TestForm.valid(), "field", "invalid value", msg_prefix=msg_prefix ) def test_field_with_different_error(self): @@ -1442,99 +1458,62 @@ class AssertFormErrorTests(SimpleTestCase): "The errors of field 'field' on form don't match." ) - response = mock.Mock(context=[{"form": TestForm.invalid()}]) with self.assertRaisesMessage(AssertionError, msg) as ctx: - self.assertFormError(response, "form", "field", "other error") + self.assertFormError(TestForm.invalid(), "field", "other error") self.assertIn("['invalid value'] != ['other error']", str(ctx.exception)) msg_prefix = "Custom prefix" with self.assertRaisesMessage(AssertionError, f"{msg_prefix}: {msg}"): self.assertFormError( - response, "form", "field", "other error", msg_prefix=msg_prefix + TestForm.invalid(), "field", "other error", msg_prefix=msg_prefix ) - def test_basic_positive_assertion(self): - response = mock.Mock(context=[{"form": TestForm.invalid()}]) - self.assertFormError(response, "form", "field", "invalid value") - - def test_basic_positive_assertion_multicontext(self): - response = mock.Mock(context=[{}, {"form": TestForm.invalid()}]) - self.assertFormError(response, "form", "field", "invalid value") - - def test_empty_errors_unbound_form(self): + def test_unbound_form(self): msg = ( "The form is not " "bound, it will never have any errors." ) - response = mock.Mock(context=[{"form": TestForm()}]) with self.assertRaisesMessage(AssertionError, msg): - self.assertFormError(response, "form", "field", []) + self.assertFormError(TestForm(), "field", []) msg_prefix = "Custom prefix" with self.assertRaisesMessage(AssertionError, f"{msg_prefix}: {msg}"): - self.assertFormError(response, "form", "field", [], msg_prefix=msg_prefix) - - def test_empty_errors_valid_form(self): - response = mock.Mock(context=[{"form": TestForm.valid()}]) - self.assertFormError(response, "form", "field", []) + self.assertFormError(TestForm(), "field", [], msg_prefix=msg_prefix) def test_empty_errors_invalid_form(self): msg = ( "The errors of field 'field' on form don't match." ) - response = mock.Mock(context=[{"form": TestForm.invalid()}]) with self.assertRaisesMessage(AssertionError, msg) as ctx: - self.assertFormError(response, "form", "field", []) + self.assertFormError(TestForm.invalid(), "field", []) self.assertIn("['invalid value'] != []", str(ctx.exception)) def test_non_field_errors(self): - response = mock.Mock(context=[{"form": TestForm.invalid(nonfield=True)}]) - self.assertFormError(response, "form", None, "non-field error") + self.assertFormError(TestForm.invalid(nonfield=True), None, "non-field error") def test_different_non_field_errors(self): - response = mock.Mock(context=[{"form": TestForm.invalid(nonfield=True)}]) msg = ( "The non-field errors of form don't match." ) with self.assertRaisesMessage(AssertionError, msg) as ctx: - self.assertFormError(response, "form", None, "other non-field error") + self.assertFormError( + TestForm.invalid(nonfield=True), None, "other non-field error" + ) self.assertIn( "['non-field error'] != ['other non-field error']", str(ctx.exception) ) msg_prefix = "Custom prefix" with self.assertRaisesMessage(AssertionError, f"{msg_prefix}: {msg}"): self.assertFormError( - response, "form", None, "other non-field error", msg_prefix=msg_prefix + TestForm.invalid(nonfield=True), + None, + "other non-field error", + msg_prefix=msg_prefix, ) - @ignore_warnings(category=RemovedInDjango50Warning) - def test_errors_none(self): - msg = ( - "The errors of field 'field' on form don't match." - ) - response = mock.Mock(context=[{"form": TestForm.invalid()}]) - with self.assertRaisesMessage(AssertionError, msg): - self.assertFormError(response, "form", "field", None) - - def test_errors_none_warning(self): - response = mock.Mock(context=[{"form": TestForm.valid()}]) - msg = ( - "Passing errors=None to assertFormError() is deprecated, use " - "errors=[] instead." - ) - with self.assertWarnsMessage(RemovedInDjango50Warning, msg): - self.assertFormError(response, "form", "field", None) - class AssertFormsetErrorTests(SimpleTestCase): - def _get_formset_data(self, field_value): - return { - "form-TOTAL_FORMS": "1", - "form-INITIAL_FORMS": "0", - "form-0-field": field_value, - } - + @ignore_warnings(category=RemovedInDjango50Warning) def test_non_client_response(self): msg = ( "assertFormsetError() is only usable on responses fetched using " @@ -1544,12 +1523,14 @@ class AssertFormsetErrorTests(SimpleTestCase): with self.assertRaisesMessage(ValueError, msg): self.assertFormsetError(response, "formset", 0, "field", "invalid value") + @ignore_warnings(category=RemovedInDjango50Warning) def test_response_with_no_context(self): msg = "Response did not use any contexts to render the response" response = mock.Mock(context=[]) with self.assertRaisesMessage(AssertionError, msg): self.assertFormsetError(response, "formset", 0, "field", "invalid value") + @ignore_warnings(category=RemovedInDjango50Warning) def test_formset_not_in_context(self): msg = "The formset 'formset' was not used to render the response" response = mock.Mock(context=[{}]) @@ -1561,25 +1542,41 @@ class AssertFormsetErrorTests(SimpleTestCase): response, "formset", 0, "field", "invalid value", msg_prefix=msg_prefix ) + def test_single_error(self): + self.assertFormsetError(TestFormset.invalid(), 0, "field", "invalid value") + + def test_error_list(self): + self.assertFormsetError(TestFormset.invalid(), 0, "field", ["invalid value"]) + + def test_empty_errors_valid_formset(self): + self.assertFormsetError(TestFormset.valid(), 0, "field", []) + + def test_multiple_forms(self): + formset = TestFormset( + { + "form-TOTAL_FORMS": "2", + "form-INITIAL_FORMS": "0", + "form-0-field": "valid", + "form-1-field": "invalid", + } + ) + formset.full_clean() + self.assertFormsetError(formset, 0, "field", []) + self.assertFormsetError(formset, 1, "field", ["invalid value"]) + def test_field_not_in_form(self): msg = ( "The form 0 of formset " "does not contain the field 'other_field'." ) - response = mock.Mock(context=[{"formset": TestFormset.invalid()}]) with self.assertRaisesMessage(AssertionError, msg): self.assertFormsetError( - response, - "formset", - 0, - "other_field", - "invalid value", + TestFormset.invalid(), 0, "other_field", "invalid value" ) msg_prefix = "Custom prefix" with self.assertRaisesMessage(AssertionError, f"{msg_prefix}: {msg}"): self.assertFormsetError( - response, - "formset", + TestFormset.invalid(), 0, "other_field", "invalid value", @@ -1591,14 +1588,13 @@ class AssertFormsetErrorTests(SimpleTestCase): "The errors of field 'field' on form 0 of formset don't match." ) - response = mock.Mock(context=[{"formset": TestFormset.valid()}]) with self.assertRaisesMessage(AssertionError, msg) as ctx: - self.assertFormsetError(response, "formset", 0, "field", "invalid value") + self.assertFormsetError(TestFormset.valid(), 0, "field", "invalid value") self.assertIn("[] != ['invalid value']", str(ctx.exception)) msg_prefix = "Custom prefix" with self.assertRaisesMessage(AssertionError, f"{msg_prefix}: {msg}"): self.assertFormsetError( - response, "formset", 0, "field", "invalid value", msg_prefix=msg_prefix + TestFormset.valid(), 0, "field", "invalid value", msg_prefix=msg_prefix ) def test_field_with_different_error(self): @@ -1606,67 +1602,45 @@ class AssertFormsetErrorTests(SimpleTestCase): "The errors of field 'field' on form 0 of formset don't match." ) - response = mock.Mock(context=[{"formset": TestFormset.invalid()}]) with self.assertRaisesMessage(AssertionError, msg) as ctx: - self.assertFormsetError(response, "formset", 0, "field", "other error") + self.assertFormsetError(TestFormset.invalid(), 0, "field", "other error") self.assertIn("['invalid value'] != ['other error']", str(ctx.exception)) msg_prefix = "Custom prefix" with self.assertRaisesMessage(AssertionError, f"{msg_prefix}: {msg}"): self.assertFormsetError( - response, "formset", 0, "field", "other error", msg_prefix=msg_prefix + TestFormset.invalid(), 0, "field", "other error", msg_prefix=msg_prefix ) - def test_basic_positive_assertion(self): - response = mock.Mock(context=[{"formset": TestFormset.invalid()}]) - self.assertFormsetError(response, "formset", 0, "field", "invalid value") - - def test_basic_positive_assertion_multicontext(self): - response = mock.Mock(context=[{}, {"formset": TestFormset.invalid()}]) - self.assertFormsetError(response, "formset", 0, "field", "invalid value") - - def test_empty_errors_unbound_formset(self): + def test_unbound_formset(self): msg = ( "The formset is not " "bound, it will never have any errors." ) - response = mock.Mock(context=[{"formset": TestFormset()}]) with self.assertRaisesMessage(AssertionError, msg): - self.assertFormsetError(response, "formset", 0, "field", []) - - def test_empty_errors_valid_formset(self): - response = mock.Mock(context=[{}, {"formset": TestFormset.valid()}]) - self.assertFormsetError(response, "formset", 0, "field", []) + self.assertFormsetError(TestFormset(), 0, "field", []) def test_empty_errors_invalid_formset(self): msg = ( "The errors of field 'field' on form 0 of formset don't match." ) - response = mock.Mock(context=[{}, {"formset": TestFormset.invalid()}]) with self.assertRaisesMessage(AssertionError, msg) as ctx: - self.assertFormsetError(response, "formset", 0, "field", []) + self.assertFormsetError(TestFormset.invalid(), 0, "field", []) self.assertIn("['invalid value'] != []", str(ctx.exception)) def test_non_field_errors(self): - response = mock.Mock( - context=[ - {}, - {"formset": TestFormset.invalid(nonfield=True)}, - ] + self.assertFormsetError( + TestFormset.invalid(nonfield=True), 0, None, "non-field error" ) - self.assertFormsetError(response, "formset", 0, None, "non-field error") def test_different_non_field_errors(self): - response = mock.Mock( - context=[{}, {"formset": TestFormset.invalid(nonfield=True)}], - ) msg = ( "The non-field errors of form 0 of formset don't match." ) with self.assertRaisesMessage(AssertionError, msg) as ctx: self.assertFormsetError( - response, "formset", 0, None, "other non-field error" + TestFormset.invalid(nonfield=True), 0, None, "other non-field error" ) self.assertIn( "['non-field error'] != ['other non-field error']", str(ctx.exception) @@ -1674,8 +1648,7 @@ class AssertFormsetErrorTests(SimpleTestCase): msg_prefix = "Custom prefix" with self.assertRaisesMessage(AssertionError, f"{msg_prefix}: {msg}"): self.assertFormsetError( - response, - "formset", + TestFormset.invalid(nonfield=True), 0, None, "other non-field error", @@ -1683,80 +1656,74 @@ class AssertFormsetErrorTests(SimpleTestCase): ) def test_no_non_field_errors(self): - response = mock.Mock(context=[{}, {"formset": TestFormset.invalid()}]) msg = ( "The non-field errors of form 0 of formset don't match." ) with self.assertRaisesMessage(AssertionError, msg) as ctx: - self.assertFormsetError(response, "formset", 0, None, "non-field error") + self.assertFormsetError(TestFormset.invalid(), 0, None, "non-field error") self.assertIn("[] != ['non-field error']", str(ctx.exception)) msg_prefix = "Custom prefix" with self.assertRaisesMessage(AssertionError, f"{msg_prefix}: {msg}"): self.assertFormsetError( - response, "formset", 0, None, "non-field error", msg_prefix=msg_prefix + TestFormset.invalid(), 0, None, "non-field error", msg_prefix=msg_prefix ) def test_non_form_errors(self): - response = mock.Mock( - context=[ - {}, - {"formset": TestFormset.invalid(nonform=True)}, - ] - ) - self.assertFormsetError(response, "formset", None, None, "error") + self.assertFormsetError(TestFormset.invalid(nonform=True), None, None, "error") def test_different_non_form_errors(self): - response = mock.Mock( - context=[{}, {"formset": TestFormset.invalid(nonform=True)}], - ) msg = ( "The non-form errors of formset don't match." ) with self.assertRaisesMessage(AssertionError, msg) as ctx: - self.assertFormsetError(response, "formset", None, None, "other error") + self.assertFormsetError( + TestFormset.invalid(nonform=True), None, None, "other error" + ) self.assertIn("['error'] != ['other error']", str(ctx.exception)) msg_prefix = "Custom prefix" with self.assertRaisesMessage(AssertionError, f"{msg_prefix}: {msg}"): self.assertFormsetError( - response, "formset", None, None, "other error", msg_prefix=msg_prefix + TestFormset.invalid(nonform=True), + None, + None, + "other error", + msg_prefix=msg_prefix, ) def test_no_non_form_errors(self): - response = mock.Mock(context=[{}, {"formset": TestFormset.invalid()}]) msg = ( "The non-form errors of formset don't match." ) with self.assertRaisesMessage(AssertionError, msg) as ctx: - self.assertFormsetError(response, "formset", None, None, "error") + self.assertFormsetError(TestFormset.invalid(), None, None, "error") self.assertIn("[] != ['error']", str(ctx.exception)) msg_prefix = "Custom prefix" with self.assertRaisesMessage(AssertionError, f"{msg_prefix}: {msg}"): self.assertFormsetError( - response, "formset", None, None, "error", msg_prefix=msg_prefix + TestFormset.invalid(), + None, + None, + "error", + msg_prefix=msg_prefix, ) def test_non_form_errors_with_field(self): - response = mock.Mock( - context=[ - {}, - {"formset": TestFormset.invalid(nonform=True)}, - ] - ) msg = "You must use field=None with form_index=None." with self.assertRaisesMessage(ValueError, msg): - self.assertFormsetError(response, "formset", None, "field", "error") + self.assertFormsetError( + TestFormset.invalid(nonform=True), None, "field", "error" + ) def test_form_index_too_big(self): msg = ( "The formset only has " "1 form." ) - response = mock.Mock(context=[{}, {"formset": TestFormset.invalid()}]) with self.assertRaisesMessage(AssertionError, msg): - self.assertFormsetError(response, "formset", 2, "field", "error") + self.assertFormsetError(TestFormset.invalid(), 2, "field", "error") def test_form_index_too_big_plural(self): formset = TestFormset( @@ -1772,40 +1739,221 @@ class AssertFormsetErrorTests(SimpleTestCase): "The formset only has 2 " "forms." ) - response = mock.Mock(context=[{}, {"formset": formset}]) with self.assertRaisesMessage(AssertionError, msg): - self.assertFormsetError(response, "formset", 2, "field", "error") + self.assertFormsetError(formset, 2, "field", "error") - def test_formset_named_form(self): - formset = TestFormset.invalid() - # The mocked context emulates the template-based rendering of the - # formset. - response = mock.Mock( - context=[ - {"form": formset}, - {"form": formset.management_form}, - ] - ) - self.assertFormsetError(response, "form", 0, "field", "invalid value") + +# RemovedInDjango50Warning +class AssertFormErrorDeprecationTests(SimpleTestCase): + """ + Exhaustively test all possible combinations of args/kwargs for the old + signature. + """ @ignore_warnings(category=RemovedInDjango50Warning) - def test_errors_none(self): + def test_assert_form_error_errors_none(self): + msg = ( + "The errors of field 'field' on form don't match." + ) + with self.assertRaisesMessage(AssertionError, msg): + self.assertFormError(TestForm.invalid(), "field", None) + + def test_assert_form_error_errors_none_warning(self): + msg = ( + "Passing errors=None to assertFormError() is deprecated, use " + "errors=[] instead." + ) + with self.assertWarnsMessage(RemovedInDjango50Warning, msg): + self.assertFormError(TestForm.valid(), "field", None) + + def _assert_form_error_old_api_cases(self, form, field, errors, msg_prefix): + response = mock.Mock(context=[{"form": TestForm.invalid()}]) + return ( + ((response, form, field, errors), {}), + ((response, form, field, errors, msg_prefix), {}), + ((response, form, field, errors), {"msg_prefix": msg_prefix}), + ((response, form, field), {"errors": errors}), + ((response, form, field), {"errors": errors, "msg_prefix": msg_prefix}), + ((response, form), {"field": field, "errors": errors}), + ( + (response, form), + {"field": field, "errors": errors, "msg_prefix": msg_prefix}, + ), + ((response,), {"form": form, "field": field, "errors": errors}), + ( + (response,), + { + "form": form, + "field": field, + "errors": errors, + "msg_prefix": msg_prefix, + }, + ), + ( + (), + {"response": response, "form": form, "field": field, "errors": errors}, + ), + ( + (), + { + "response": response, + "form": form, + "field": field, + "errors": errors, + "msg_prefix": msg_prefix, + }, + ), + ) + + def test_assert_form_error_old_api(self): + deprecation_msg = ( + "Passing response to assertFormError() is deprecated. Use the form object " + "directly: assertFormError(response.context['form'], 'field', ...)" + ) + for args, kwargs in self._assert_form_error_old_api_cases( + form="form", + field="field", + errors=["invalid value"], + msg_prefix="Custom prefix", + ): + with self.subTest(args=args, kwargs=kwargs): + with self.assertWarnsMessage(RemovedInDjango50Warning, deprecation_msg): + self.assertFormError(*args, **kwargs) + + @ignore_warnings(category=RemovedInDjango50Warning) + def test_assert_form_error_old_api_assertion_error(self): + for args, kwargs in self._assert_form_error_old_api_cases( + form="form", + field="field", + errors=["other error"], + msg_prefix="Custom prefix", + ): + with self.subTest(args=args, kwargs=kwargs): + with self.assertRaises(AssertionError): + self.assertFormError(*args, **kwargs) + + @ignore_warnings(category=RemovedInDjango50Warning) + def test_assert_formset_error_errors_none(self): msg = ( "The errors of field 'field' on form 0 of formset don't match." ) - response = mock.Mock(context=[{"formset": TestFormset.invalid()}]) with self.assertRaisesMessage(AssertionError, msg): - self.assertFormsetError(response, "formset", 0, "field", None) + self.assertFormsetError(TestFormset.invalid(), 0, "field", None) - def test_errors_none_warning(self): - response = mock.Mock(context=[{"formset": TestFormset.valid()}]) + def test_assert_formset_error_errors_none_warning(self): msg = ( "Passing errors=None to assertFormsetError() is deprecated, use " "errors=[] instead." ) with self.assertWarnsMessage(RemovedInDjango50Warning, msg): - self.assertFormsetError(response, "formset", 0, "field", None) + self.assertFormsetError(TestFormset.valid(), 0, "field", None) + + def _assert_formset_error_old_api_cases( + self, formset, form_index, field, errors, msg_prefix + ): + response = mock.Mock(context=[{"formset": TestFormset.invalid()}]) + return ( + ((response, formset, form_index, field, errors), {}), + ((response, formset, form_index, field, errors, msg_prefix), {}), + ( + (response, formset, form_index, field, errors), + {"msg_prefix": msg_prefix}, + ), + ((response, formset, form_index, field), {"errors": errors}), + ( + (response, formset, form_index, field), + {"errors": errors, "msg_prefix": msg_prefix}, + ), + ((response, formset, form_index), {"field": field, "errors": errors}), + ( + (response, formset, form_index), + {"field": field, "errors": errors, "msg_prefix": msg_prefix}, + ), + ( + (response, formset), + {"form_index": form_index, "field": field, "errors": errors}, + ), + ( + (response, formset), + { + "form_index": form_index, + "field": field, + "errors": errors, + "msg_prefix": msg_prefix, + }, + ), + ( + (response,), + { + "formset": formset, + "form_index": form_index, + "field": field, + "errors": errors, + }, + ), + ( + (response,), + { + "formset": formset, + "form_index": form_index, + "field": field, + "errors": errors, + "msg_prefix": msg_prefix, + }, + ), + ( + (), + { + "response": response, + "formset": formset, + "form_index": form_index, + "field": field, + "errors": errors, + }, + ), + ( + (), + { + "response": response, + "formset": formset, + "form_index": form_index, + "field": field, + "errors": errors, + "msg_prefix": msg_prefix, + }, + ), + ) + + def test_assert_formset_error_old_api(self): + deprecation_msg = ( + "Passing response to assertFormsetError() is deprecated. Use the formset " + "object directly: assertFormsetError(response.context['formset'], 0, ...)" + ) + for args, kwargs in self._assert_formset_error_old_api_cases( + formset="formset", + form_index=0, + field="field", + errors=["invalid value"], + msg_prefix="Custom prefix", + ): + with self.subTest(args=args, kwargs=kwargs): + with self.assertWarnsMessage(RemovedInDjango50Warning, deprecation_msg): + self.assertFormsetError(*args, **kwargs) + + @ignore_warnings(category=RemovedInDjango50Warning) + def test_assert_formset_error_old_api_assertion_error(self): + for args, kwargs in self._assert_formset_error_old_api_cases( + formset="formset", + form_index=0, + field="field", + errors=["other error"], + msg_prefix="Custom prefix", + ): + with self.subTest(args=args, kwargs=kwargs): + with self.assertRaises(AssertionError): + self.assertFormsetError(*args, **kwargs) class FirstUrls: