Handling Errors in Django Test Cases

Hello,

I am currently writing test cases using Django and would like to improve the way we handle errors, particularly distinguishing between errors that arise from the test cases themselves and those caused by the user’s code.

Context:

Let’s say a user writes a function to fetch and increment the likes count of a post:

def fetch_post(request, post_id):
    try:
        post = Post.objects.get(id=post_id)
    except Post.DoesNotExist:
        raise Http404("Post not found")

    post.likes += 1
    post.save()

    return HttpResponse("Post liked")

Here’s the test case for that function:

from django.test import TestCase
from project_app.models import Post
from django.urls import reverse
from django.http import Http404

class FetchPostViewTests(TestCase):
    def setUp(self):
        self.post = Post.objects.create(title="Sample Post")

    def assertLikesIncrementedByOne(self, initial_likes, updated_post):
        if updated_post.likes != initial_likes + 1:
            raise AssertionError(f'Error: "Likes cannot be incremented by {updated_post.likes - initial_likes}"')

    def test_fetch_post_increments_likes(self):
        initial_likes = self.post.likes
        response = self.client.get(reverse('fetch_post', args=[self.post.id]))
        updated_post = Post.objects.get(id=self.post.id)

        self.assertLikesIncrementedByOne(initial_likes, updated_post)
        self.assertEqual(response.content.decode(), "Post liked")

    def test_fetch_post_not_found(self):
        response = self.client.get(reverse('fetch_post', args=[9999]))
        self.assertEqual(response.status_code, 404)  

Scenario:

Now, if a user accidentally modifies the code to increment the likes by 2 instead of 1 and didn’t save the post object.

# Wrong code that will fail the test case
def fetch_post(request, post_id):
    try:
        # used filter() method instead of get() method
        post = Post.objects.filter(id=post_id)
    except Post.DoesNotExist:
        raise Http404("Post not found") 

    post.likes += 2 # incremented by two instead of 1
    post.save()

    return HttpResponse("Post liked")

This leads to the following test failure:

test_cases/test_case.py:53:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
/usr/local/lib/python3.9/site-packages/django/test/client.py:742: in get
    response = super().get(path, data=data, secure=secure, **extra)
/usr/local/lib/python3.9/site-packages/django/test/client.py:396: in get
    return self.generic('GET', path, secure=secure, **{
/usr/local/lib/python3.9/site-packages/django/test/client.py:473: in generic
    return self.request(**r)
/usr/local/lib/python3.9/site-packages/django/test/client.py:719: in request
    self.check_exception(response)
/usr/local/lib/python3.9/site-packages/django/test/client.py:580: in check_exception
    raise exc_value
/usr/local/lib/python3.9/site-packages/django/core/handlers/exception.py:47: in inner
    response = get_response(request)
/usr/local/lib/python3.9/site-packages/django/core/handlers/base.py:181: in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

request = <WSGIRequest: GET '/fetch_post/9999/'>, post_id = 9999

    def fetch_post(request, post_id):
        try:
            post = Post.objects.filter(id=post_id)
        except Post.DoesNotExist:
            raise Http404("Post not found")  # Raise 404 if post does not exist

>       post.likes += 2
E       AttributeError: 'QuerySet' object has no attribute 'likes'

project_app/views.py:11: AttributeError
------------------------------ Captured log call -------------------------------
ERROR    django.request:log.py:224 Internal Server Error: /fetch_post/9999/
Traceback (most recent call last):
  File "/usr/local/lib/python3.9/site-packages/django/core/handlers/exception.py", line 47, in inner
    response = get_response(request)
  File "/usr/local/lib/python3.9/site-packages/django/core/handlers/base.py", line 181, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/app/project_app/views.py", line 11, in fetch_post
    post.likes += 2
AttributeError: 'QuerySet' object has no attribute 'likes'
=========================== short test summary info ============================
FAILED test_cases/test_case.py::FetchPostViewTests::test_fetch_post_increments_likes
FAILED test_cases/test_case.py::FetchPostViewTests::test_fetch_post_not_found
============================== 2 failed in 0.74s ===============================

Request for Assistance:

Instead of displaying the above traceback, which clearly indicates a test case error, I would prefer to return a more user-friendly error message like:

Error 1: "Likes cannot be incremented by 2"
Error 2: "You used filter method instead of the get method in your function"

Is there a way to catch such errors within the test case and return a more human-readable error message? Any guidance on how to implement this would be greatly appreciated.

Thank you!


Edit 1

Note: I am using a celery worker to run the dockerfile that will in turn run the tests. Here is the relevant part of the tasks.py responsible for printing error:

print("Running tests in Docker container...")
test_result = subprocess.run(
    ["docker", "run", "--rm", "-v", f"{volume_name}:/app", "test-runner"],
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    text=True
)

if test_result.returncode != 0:
    # Check for AssertionError messages in stdout
    error_message = test_result.stdout
    if "AssertionError" in error_message:
        # Extract just the assertion error message
        lines = error_message.splitlines()
        for line in lines:
            if "Error:" in line:
                submission.error_log = line.strip()  # Save specific error message to the database
                raise Exception(line.strip())

In my view you’re essentially trying to capture every possible coding error in order to present a nice message about it instead of a traceback.

I also wouldn’t create my own assertions for something like “assert likes incremented by one”. You can add a message to a standard assertion (which I’ve never bothered with myself) if you like:

self.assertEqual(a, b, "Your error message here")

Happy to have others with more testing experience contradict me! But it feels to me like you’re making the tests themselves more complicated than they need to be, in order to present slightly nicer error output.