APITestCase with APIRequestFactory

I have a test class in Django Rest Framework which contains a function which passes the test if it is the only function in the class, or fails if it is one of two or more functions.

If I run the below code as is, i.e. def test_audio_list(self) function remains commented out, the test passes as per the two assert statements.

If I uncomment def test_audio_list(self) function and run the tests, the def test_audio_retrieve(self) function will fail with a 404 whilst def test_audio_list(self) will pass.

Here is my full test case (with def test_audio_list(self): commented out).

class AudioTests(APITestCase):

    def setUp(self):
        importer()  # imports data into the database
        self.test_user = User(username='jim', password='monkey123', email='jim@jim.com')
        self.test_user.save()
        self.factory = APIRequestFactory()
        self.list_view = AudioViewSet.as_view(actions={'get': 'list'})
        self.detail_view = AudioViewSet.as_view(actions={'get': 'retrieve'})

    # def test_audio_list(self):
    #     """
    #         Check that audo returns a 200 OK
    #     """
    #     # Make an authenticated request to the view...
    #     request = self.factory.get('/api/v1/audio/')
    # 
    #     self.test_user.refresh_from_db()
    #     force_authenticate(request, user=self.test_user)
    #     response = self.list_view(request, pk="1")
    # 
    #     self.assertContains(response, 'audio/c1ha')
    #     self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_audio_retrieve(self):
        """
            Check that audio returns a 200 OK
        """

        # Make an authenticated request to the view...
        request = self.factory.get('/api/v1/audio/')

        # force refresh of user
        self.test_user.refresh_from_db()

        force_authenticate(request, user=self.test_user)
        response = self.detail_view(request, pk="1")

        self.assertContains(response, 'audio')
        self.assertEqual(response.status_code, status.HTTP_200_OK)

Running python manage.py test on the above will produce no errors. But if I uncomment def test_audio_list(self) function, def test_audio_retrieve(self) will fail with the following:

======================================================================
FAIL: test_audio_retrieve (api.tests.AudioTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/code/api/tests.py", line 96, in test_audio_retrieve
    self.assertContains(response, 'audio/c1ha')
  File "/usr/local/lib/python3.7/site-packages/django/test/testcases.py", line 446, in assertContains
    response, text, status_code, msg_prefix, html)
  File "/usr/local/lib/python3.7/site-packages/django/test/testcases.py", line 418, in _assert_contains
    " (expected %d)" % (response.status_code, status_code)
AssertionError: 404 != 200 : Couldn't retrieve content: Response code was 404 (expected 200)

A user on Stackoverflow suggested I try running both tests within the same function as below. I can confirm that works, but from all the examples of Django tests I have seen, it appears best practice is to write individual tests in their own method. This also seems to make more sense to me as each method essentially represents a test.

def test_audio(self):
    """
        All tests pass in this function
    """
    factory = APIRequestFactory()
    view_retrieve = AudioViewSet.as_view(actions={'get': 'retrieve'})
    view_list = AudioViewSet.as_view(actions={'get': ‘list’})


    # Make an authenticated request to the view...
    request = factory.get('/api/v1/audio/')
    force_authenticate(request, user=self.test_user)

    # Retrieve
    response = view_retrieve(request, pk="1")
    self.assertContains(response, 'audio/c1ha')
    self.assertEqual(response.status_code, status.HTTP_200_OK)

    # List
    response = view_list(request, pk="1")
    self.assertContains(response, 'audio/c1ha')
    self.assertEqual(response.status_code, status.HTTP_200_OK)

I’m assuming I must be doing something wrong here, but I just cannot put my finger on it, but I’m guessing it has something to do with the way Django treats the test database before and after objects have been accessed.

Any help is much appreciated.

Cheers,

C

It all looks reasonable to me except you haven’t shown what the import() function called in setUp does - or which kind of class you’re wrapping the tests in. Showing those details might help people track down the issue.

EDIT: Does your importer by any chance delete all the existing data, before adding new data back in? If so, the auto-incrementing ID of your model rows won’t restart from 1 every test but will increment. Which would cause your hardcoded reference to pk=1 to fail every time after the first.

If this is what’s happening, now would be a good time for me to advocate setting up the minimal possible data in your database per test rather than importing a load of shared data for all of them :slight_smile: I find using Factory Boy really useful for this!

1 Like

Ah, my apolgoies, I’ve edited to my post to show that it is an APITestCase class. I must have missed the top line in my cut and paste.

As for the importer() call, it is a small script which imports data from an excel spreadsheet using Pandas. It doesn’t delete anything from the database. It is rather long, so I’ve included an example of what is repeated many times in the the script below.

I suppose the key concept that I am most likely missing is how Django handles data in the database during the test. My understanding is that Django will run destroy the data in the database and run setUp() before every test. Hence, I’m running importer() in setUp().

N.b The first python snippet shows some code adding an object to the database if it doesn’t already exist. Eventually it calls add_media(physical, data) where the Audio file is added

       # parse and create physical and instrument objects from
       # pandas object named data
        if (
            data["component"].lower() == "physical exam"
            and data["sub_component"].lower() == "image"
        ):
            if not Physical.objects.all().filter(case=case, name=data["name"]).exists():
                logging.info(f"PHYSICAL IMAGE: Creating an Image: {data['name']}")
                physical = Physical(case=case, name=data["name"])
                physical.save()
                add_media(physical, data)

Now, where the audio is added.

def add_media(obj, data):
    """
    Method to parse audio, video and image filenames from excel and add them
    to the model image, video and audio fields
    :param obj: The model for which we will add media files
    :param data: The current row in the excel sheet
    :return: Nothing
    """

    if data["text"] is not None and data["text"] != "case":
        if obj.text != data["answer"]:
            obj.text = data["answer"]
            obj.save()

    if data["audio"] is not None:
        audio_list = data["audio"].lower().replace(" ", "")

        for file in audio_list.split(","):
            if not Audio.objects.all().filter(file=f"audio/{file}").exists():
                audio = Audio(
                    file=f"audio/{file}",
                    description=f"Audio for {data['component']} {data['sub_component']} {data['name']}",
                )
                audio.save()
                obj.audio.add(audio)
            else:
                audio = Audio.objects.get(file=f"audio/{file}")
                obj.audio.add(audio)

Ah right, sorry, I forgot that your importer() doesn’t have to wipe the database because Django does it :slight_smile: So what I said above about the auto-incrementing IDs of your tables not being reset between tests still stands.

Here’s a reduced example of what happens.

Let’s say we have a really simple model:

class Contact(models.Model):
    display_name = models.CharField(max_length=128)

And a test to show the behaviour:

from hub.crm import models
from django.test import TestCase

class DemonstrateIDResetBehaviour(TestCase):
    def test_fred(self):
        fred = models.Contact.objects.create(display_name="Fred")
        print(f"Fred has ID {fred.id}")

    def test_george(self):
        george = models.Contact.objects.create(display_name="George")
        print(f"George has ID {george.id}")

    def test_minerva(self):
        minerva = models.Contact.objects.create(display_name="Minerva")
        print(f"Minerva has ID {minerva.id}")

The output from this would be:

Fred has ID 1
George has ID 2
Minerva has ID 3

Does that make sense? You can’t rely on the data you load into the database having any particular ID and that’s why your retrieve test fails. If you don’t run importer() but set up the test data for each test case you can keep a copy of the object around to get the right ID:

    def test_audio_retrieve(self):
        audio = Audio.objects.create(
            file=f"audio/test_filaname",
            description="Test audio",
        )
        request = self.factory.get('/api/v1/audio/')
        force_authenticate(request, user=self.test_user)

        response = self.detail_view(request, pk=audio.id)

        self.assertContains(response, 'audio')
        self.assertEqual(response.status_code, status.HTTP_200_OK)

It also reduces the scope of each individual test - from “all of my importing functionality plus an API endpoint” to “an API endpoint”. If you want to test that the API endpoint works, it’s good to just set up the minimal data needed for that bit of functionality.

However, you don’t have to do that - you could get the first Audio record by using pk=Audio.objects.first().id. In my experience though, it’s better to make test cases as simple and self-contained as possible, or you’re storing up pain for yourself later.

Hope that helps :slight_smile:

2 Likes

Holy moly! Of course, auto-increment. How could I have been such a eejit (not the first time!).

Thank you, Takkaria, you’ve been most helpful and your example makes complete sense. I’ve been thinking that I would just use the data as imported by the importer(), but I definitely see your point about keep the tests simple. I will keep this in mind as I continue my testing.

Again, thank you very much!

Cheers,

C