Weird test behavior in my Django project test suite after an IntegrityError

Posted on 2024-12-01 in Programmation

Recently I encountered a very weird behavior in my Django project test suite. I created a view that caught an IntegrityError from the database (caused by duplicates in a unique index). When this error occurs, I want to respond with a 409 - CONFLICT status code and an error message.

TL;DR: it’s caused by how transactions works in the test suite. Using a transaction.atomic() on the view solved it. Read on to learn more.

Everything worked fine in the browser. But in the test suite, I always got a 400 - BAD REQUEST error with the default HTML error page as content. Not my custom status nor custom JSON. Worst, if I tried to log the queries, django_assert_num_queries listed none. Yet, I was sure they were executed. And if I launched the debugger, my view correctly caught the IntegrityError and responded with the proper response and the proper status.

Note

I’m using pytest, but if I’m right, it doesn’t have an impact on the problem nor on how to solve it.

I had no clue what was going on. Luckily, I had to do the same modification (catch some IntegrityError) in two other views as well. I wandered if I’d always get the same behavior. So I updated the second view and got the same issue. I moved on the the third view and it worked as expected!

At least I now had a comparison point to start investigating. The main difference was that my third view was decorated with a transaction.atomic(). The other weren’t.

So it hit me: each test functions is run in a transaction. This way, we can easily and quickly rollback all the changes made by test test when it ends (successfully or not). So, when my IntegrityError hit, it caused a rollback to happen at test level before the test completed. That’s why django_assert_num_queries couldn’t list the queries and what caused me not to have access to the proper response. I don’t know why in this case the response is always a generic 400 and not a more specific error message though.

After testing with my existing views by adding/removing transaction.atomic(), I was able to confirm that I only got the correct behavior when transaction.atomic() was present. So I updated the view, made my test suite pass and went on to write this blog post!

In the end, I think I was lucky to have the need to update a view wrapped in transaction.atomic(). Without this basis to compare, I think I’d have search way longer what was going on and why the problem was even occurring!