Skip to content

Fixed #35728 -- Computed error messages in assertions only on test failures. #19402

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

cliff688
Copy link
Member

@cliff688 cliff688 commented Apr 20, 2025

Trac ticket number

ticket-35728

Branch description

There are two main approaches used.

  1. Return early if the assertion will pass; if not compute error messages and pass them on to unittest's assertions. Passing on to unittest's assertions has the benefit of sometimes providing more helpful error messages, e.g. when asserting sequence equality, the difference is shown in the report. But sometimes this is just prepends False is not true to an error message.
    2. Compute the error message in the failing conditional branch, for self.fail(msg).

A additional (now removed) commit db0cfda makes sure that we use __contains__ for checking existence of an element in HTML assertions. This has the benefit of using Element._count(elem, count=False) under the hood, and short-circuiting when a single match is found.

from django.test.html import parse_html

parsed_needle = parse_html("<span>Hello</span>")
parsed_haystack = parse_html(
    "<div>" + ("<span>Other</span>" * 100) +
    "<span>Hello</span>" * 2 +
    ("<span>Other</span>" * 100) + "</div>"
)

%timeit parsed_needle in parsed_haystack 
%timeit parsed_haystack.count(parsed_needle)  # Double the time.

Checklist

  • This PR targets the main branch.
  • The commit message is written in past tense, mentions the ticket number, and ends with a period.
  • I have checked the "Has patch" ticket flag in the Trac system.
  • I have added or updated relevant tests.
  • I have added or updated relevant docs, including release notes if applicable.
  • I have attached screenshots in both light and dark modes for any UI changes.

Comment on lines 998 to 994
if count is None or count == 0:
# Does needle exist regardless of count?
real_count = int(parsed_needle in parsed_haystack)
else:
real_count = parsed_haystack.count(parsed_needle)
if (count is None and real_count > 0) or count == real_count:
# Shortcut.
return

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The performance gains of this are difficult to test using assertInHTML because parse_html() incurs somewhere from 50–100x the cost of that assertion (compared to _count()) when I run it using the HTML in the PR description, checking for <span>Hello</span> for example.

@cliff688
Copy link
Member Author

cliff688 commented Apr 20, 2025

Hello @adamchainz! Should I extend the patch for the other assertions as well, e.g. assertTemplateUsed() / assertRedirect() where computing the messages does not seem particularly expensive?

@adamchainz
Copy link
Member

Hello @adamchainz! Should I extend the patch for the other assertions as well, e.g. assertTemplateUsed() / assertRedirect() where computing the messages does not seem particularly expensive?

No, I think those are safe.

@cliff688 cliff688 marked this pull request as ready for review June 3, 2025 07:33
Copy link
Contributor

@sarahboyce sarahboyce left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @cliff688. I've added a thought that I had looking through this

It would be great if we add benchmarks to django-asv for our custom test assertions as that might have been able to catch that this is a performance regression from 1dae65d (Django 5.1) (refs ticket-34657)
You might want to ref this ticket/commit in some commit messages

Comment on lines +601 to +604
if (count is None and real_count > 0) or (
(count is not None and real_count == count)
):
return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By doing these early returns, we have already done the work of whether the assertion should fail and could just fail rather than doing the comparisons. This could look something like:

--- a/django/test/testcases.py
+++ b/django/test/testcases.py
@@ -606,23 +606,16 @@ class SimpleTestCase(unittest.TestCase):
         text_repr = self._text_repr(text, force_string=html)
 
         if count is not None:
-            self.assertEqual(
-                real_count,
-                count,
-                (
-                    f"{msg_prefix}Found {real_count} instances of {text_repr} "
-                    f"(expected {count}) in the following response\n"
-                    f"{response_content!r}"
-                ),
+            msg = (
+                f"Found {real_count} instances of {text_repr} (expected {count}) in "
+                f"the following response\n{response_content!r}"
             )
         else:
-            self.assertTrue(
-                real_count != 0,
-                (
-                    f"{msg_prefix}Couldn't find {text_repr} in the following response\n"
-                    f"{response_content!r}"
-                ),
-            ),
+            msg = (
+                f"Couldn't find {text_repr} in the following response\n"
+                f"{response_content!r}"
+            )
+        self.fail(f"{msg_prefix}{msg}")
 
     def assertNotContains(
         self, response, text, status_code=200, msg_prefix="", html=False
@@ -1012,15 +1005,9 @@ class SimpleTestCase(unittest.TestCase):
                     f"Found {real_count} instances of {needle!r} (expected {count}) in "
                     f"the following response\n{haystack_repr}"
                 )
-            self.assertEqual(real_count, count, f"{msg_prefix}{msg}")
         else:
-            self.assertTrue(
-                real_count != 0,
-                (
-                    f"{msg_prefix}Couldn't find {needle!r} in the following response\n"
-                    f"{haystack_repr}"
-                ),
-            )
+            msg = f"Couldn't find {needle!r} in the following response\n{haystack_repr}"
+        self.fail(f"{msg_prefix}{msg}")
 
     def assertNotInHTML(self, needle, haystack, msg_prefix=""):
         self.assertInHTML(needle, haystack, count=0, msg_prefix=msg_prefix)
diff --git a/tests/test_utils/tests.py b/tests/test_utils/tests.py
index 16692500e3..99154d16ce 100644
--- a/tests/test_utils/tests.py
+++ b/tests/test_utils/tests.py
@@ -990,15 +990,14 @@ class HTMLEqualTests(SimpleTestCase):
 class InHTMLTests(SimpleTestCase):
     def test_needle_msg(self):
         msg = (
-            "False is not true : Couldn't find '<b>Hello</b>' in the following "
-            "response\n'<p>Test</p>'"
+            "Couldn't find '<b>Hello</b>' in the following response\n'<p>Test</p>'"
         )
         with self.assertRaisesMessage(AssertionError, msg):
             self.assertInHTML("<b>Hello</b>", "<p>Test</p>")
 
     def test_msg_prefix(self):
         msg = (
-            "False is not true : Prefix: Couldn't find '<b>Hello</b>' in the following "
+            "Prefix: Couldn't find '<b>Hello</b>' in the following "
             'response\n\'<input type="text" name="Hello" />\''
         )
         with self.assertRaisesMessage(AssertionError, msg):
@@ -1010,9 +1009,8 @@ class InHTMLTests(SimpleTestCase):
 
     def test_count_msg_prefix(self):
         msg = (
-            "2 != 1 : Prefix: Found 2 instances of '<b>Hello</b>' (expected 1) in the "
+            "Prefix: Found 2 instances of '<b>Hello</b>' (expected 1) in the "
             "following response\n'<b>Hello</b><b>Hello</b>'"
-            ""
         )
         with self.assertRaisesMessage(AssertionError, msg):
             self.assertInHTML(

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @sarahboyce for the review!

The only motivation for using unittest's assertions, knowing the assertion has already failed, was to preserve the message format. But I can drop them if that's okay.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can keep the assertion messages the same.

So something like:
--- a/django/test/testcases.py
+++ b/django/test/testcases.py
@@ -606,23 +606,17 @@ class SimpleTestCase(unittest.TestCase):
         text_repr = self._text_repr(text, force_string=html)
 
         if count is not None:
 
         if count is not None:
-            self.assertEqual(
-                real_count,
-                count,
-                (
-                    f"{msg_prefix}Found {real_count} instances of {text_repr} "
-                    f"(expected {count}) in the following response\n"
-                    f"{response_content!r}"
-                ),
+            msg = (
+                f"{real_count} != {count} : {msg_prefix}Found {real_count} instances "
+                f"of {text_repr} (expected {count}) in the following response\n"
+                f"{response_content!r}"
             )
         else:
-            self.assertTrue(
-                real_count != 0,
-                (
-                    f"{msg_prefix}Couldn't find {text_repr} in the following response\n"
-                    f"{response_content!r}"
-                ),
-            ),
+            msg = (
+                f"False is not true : {msg_prefix}Couldn't find {text_repr} in the "
+                f"following response\n{response_content!r}"
+            )
+        self.fail(msg)
 
     def assertNotContains(
         self, response, text, status_code=200, msg_prefix="", html=False
@@ -639,9 +633,9 @@ class SimpleTestCase(unittest.TestCase):
         if real_count != 0:
             text_repr = self._text_repr(text, force_string=html)
             self.fail(
-                f"{msg_prefix}{text_repr} unexpectedly found in the following response"
-                f"\n{response_content!r}"
-            ),
+                f"{real_count} != {0} : {msg_prefix}{text_repr} unexpectedly found in "
+                f"the following response\n{response_content!r}"
+            )
 
     def _check_test_client_response(self, response, attribute, method_name):
         """
@@ -1012,14 +1006,11 @@ class SimpleTestCase(unittest.TestCase):
                     f"Found {real_count} instances of {needle!r} (expected {count}) in "
                     f"the following response\n{haystack_repr}"
                 )
-            self.assertEqual(real_count, count, f"{msg_prefix}{msg}")
+            self.fail(f"{real_count} != {count} : {msg_prefix}{msg}")
         else:
-            self.assertTrue(
-                real_count != 0,
-                (
-                    f"{msg_prefix}Couldn't find {needle!r} in the following response\n"
-                    f"{haystack_repr}"
-                ),
+            self.fail(
+                f"False is not true : {msg_prefix}Couldn't find {needle!r} in the "
+                f"following response\n{haystack_repr}"
             )

I noticed that you had updated assertNotContains from an assertEqual to a fail, which has updated the message format but no test has failed so perhaps we want to make sure all of the failure messages are covered by tests

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can keep the assertion messages the same.

Yes, that would work... How do you feel about preserving f"{real_count} != {count}, etc., which sort of summarize the failure, and OTH dropping False is not true which doesn't add any value to the message?

perhaps we want to make sure all of the failure messages are covered by tests

I did an audit and added tests in #19588 👍🏾. Let me know if you think we should also add for cases like [] is not true and False is not true (which I am inclined to replace with self.fail() in this PR).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do you feel about preserving f"{real_count} != {count}, etc., which sort of summarize the failure, and OTH dropping False is not true which doesn't add any value to the message?

I don't think either prefix adds too much value given the messages that follow. For someone to be relying on our failure message would be strange.
So I'm open to these changing, but would appreciate more opinions (maybe @adamchainz @nessita )

cliff688 and others added 2 commits June 25, 2025 14:30
…sertNotContains only on test failure.

Thanks to Adam Johnson for the report.

Performance regression in 1dae65d.

Co-authored-by: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com>
Thanks to Adam Johnson for the report and to Sarah Boyce for the review.
@cliff688
Copy link
Member Author

I took a stab at adding benchmarks in django/django-asv#91.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants