5

I'm writing tests for my Django Rest Framework API.

I'm stuck on testing 'delete'.

My test for 'create' works fine.

Here's my test code:

import json

from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from users.models import CustomUser
from lists.models import List, Item

class ListAPITest(APITestCase):
    @classmethod

    def setUp(self):
        self.data = {'name': 'Test list', 'description':'A description', 'item': [
        {'name': 'Item 1 Name', 'description': 'Item 1 description', 'order': 1},
        {'name': 'Item 2 Name', 'description': 'Item 2 description', 'order': 2},
        {'name': 'Item 3 Name', 'description': 'Item 3 description', 'order': 3},
        {'name': 'Item 4 Name', 'description': 'Item 4 description', 'order': 4},
        {'name': 'Item 5 Name', 'description': 'Item 5 description', 'order': 5},
        {'name': 'Item 6 Name', 'description': 'Item 6 description', 'order': 6},
        {'name': 'Item 7 Name', 'description': 'Item 7 description', 'order': 7},
        {'name': 'Item 8 Name', 'description': 'Item 8 description', 'order': 8},
        {'name': 'Item 9 Name', 'description': 'Item 9 description', 'order': 9},
        {'name': 'Item 10 Name', 'description': 'Item 10 description', 'order': 10}
        ]}
        # 'lists' is the app_name set in endpoints.py
        # 'Lists' is the base_name set for the list route in endpoints.py
        # '-list' seems to be something baked into the api
        self.url = reverse('lists:Lists-list')

    def test_create_list_authenticated(self):
        """
        Ensure we can create a new list object.
        """

        user = CustomUser.objects.create(email='person@example.com', username='Test user', email_verified=True)

        self.client.force_authenticate(user=user)
        response = self.client.post(self.url, self.data, format='json')
        list_id = json.loads(response.content)['id']

        # the request should succeed
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)

        # there should now be 1 List in the database
        self.assertEqual(List.objects.count(), 1)

    def test_delete_list_by_owner(self):
        """
        delete list should succeed if user created list
        """
        user = CustomUser.objects.create(email='person@example.com', username='Test user', email_verified=True)
        new_list = List.objects.create(name='Test list', description='A description', created_by=user, created_by_username=user.username)
        self.client.force_authenticate(user=user)
        response = self.client.delete(self.url + '/' + str(new_list.id))
        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)

Instead of the expected status 204, I'm seeing:

AssertionError: 405 != 204

405 is method not allowed.

Here's my model definition:

class List(models.Model):
    """Models for lists
    """
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    created_by = models.ForeignKey(USER, on_delete=models.CASCADE, related_name='list_created_by_id')
    created_by_username = models.CharField(max_length=255) # this shold be OK given that the list will be deleted if the created_by_id user is deleted
    created_at = models.DateTimeField(auto_now_add=True)
    parent_item = models.ForeignKey('Item', on_delete=models.SET_NULL, null=True, related_name='parent_item')
    modified_by = models.ForeignKey(USER, on_delete=models.SET_NULL, null=True,
        related_name='list_modified_by')
    modified_at = models.DateTimeField(auto_now_add=True)
    name = models.CharField(max_length=255)
    description = models.CharField(max_length=5000, blank=True, default='')
    is_public = models.BooleanField(default=False)

    def __str__(self):
        return self.name

Here's my viewset:

class ListViewSet(FlexFieldsModelViewSet):
    """
    ViewSet for lists.
    """
    permission_classes = [IsOwnerOrReadOnly, HasVerifiedEmail]
    model = List
    serializer_class = ListSerializer
    permit_list_expands = ['item']
    pagination_class = LimitOffsetPagination

    def get_queryset(self):
        # unauthenticated user can only view public lists
        queryset = List.objects.filter(is_public=True)

        # authenticated user can view public lists and lists the user created
        # listset in query parameters can be additional filter
        if self.request.user.is_authenticated:
            listset = self.request.query_params.get('listset', None)

            if listset == 'my-lists':
                queryset = List.objects.filter(created_by=self.request.user)

            elif listset == 'public-lists':
                queryset = List.objects.filter(is_public=True)

            else:
                queryset = List.objects.filter(
                    Q(created_by=self.request.user) | 
                    Q(is_public=True)
                )

        # allow filter by URL parameter created_by
        created_by = self.request.query_params.get('created_by', None)

        if created_by is not None:
            queryset = queryset.filter(created_by=created_by)

        # return only lists that have no parent item
        toplevel = self.request.query_params.get('toplevel')
        if toplevel is not None:
            queryset = queryset.filter(parent_item=None)

        return queryset.order_by('name')

I have read the docs but I haven't been able to find how to set up the delete request.

I have also tried this:

kwargs = {'pk': new_list.id}
response = self.client.delete(self.url, **kwargs)

This gives me an error:

AssertionError: Expected view ListViewSet to be called with a URL keyword argument named "pk". Fix your URL conf, or set the `.lookup_field` attribute on the view correctly.

Delete in my app works fine via the API in my React front end.

I know it's confusing that my object is called List...but it's hard to think of another name because that's what it is!

Thank you for for any ideas what I'm missing here!

Little Brain
  • 2,647
  • 1
  • 30
  • 54

2 Answers2

13

The issue may be on how you're formulating the URL. You can reverse the URL for delete directly by doing this:

 url = reverse('lists:Lists-detail', kwargs={'pk': new_list.pk})
 self.client.delete(url). 

With this approach, you won't have issues like forgetting a trailing slash or adding it when it's not needed. The issue could also be in your viewset since you're using a custom ModelViewset but you said it works with the JS client so it may not be the problem.

Ken4scholars
  • 6,076
  • 2
  • 21
  • 38
  • Thank you! I had not realised that I needed to use 'delete' in the reverse, or that the reverse was the place to put in the pk. That works :) – Little Brain Mar 17 '19 at 08:49
  • 2
    @LittleBrain you're welcome. The url is not for delete per say but it is the detail route on which PUT, PATCH and DELETE methods work while POST and GET work on the list route – Ken4scholars Mar 17 '19 at 11:31
  • Thanks yes, I should have written 'detail' in my comment above, not 'delete'. – Little Brain Mar 17 '19 at 12:01
1

I recommend you have a look at the Django-restframework testing documentation.

https://www.django-rest-framework.org/api-guide/testing/

This is an example of how i would write a test for your current situation.

from rest_framework.test import APIRequestFactory, force_authenticate
from django.test import TestCase

class TestsAPIListDetailView(TestCase):

    def setUp(self):
        self.factory = APIRequestFactory()
        # This only matters if you are passing url query params e.g. ?foo=bar
        self.baseUrl = "/list/"

    def test_delete_with_standard_permission(self):

        # Creates mock objects
        user = CustomUser.objects.create(email='person@example.com', username='Test user', email_verified=True)
        new_list = List.objects.create(name='Test list', description='A description', created_by=user,
                                       created_by_username=user.username)

        # Creates a mock delete request.
        # The url isn't strictly needed here. Unless you are using query params e.g. ?q=bar
        req = self.factory.delete("{}{}/?q=bar".format(self.baseUrl, new_list.pk))

        current_list_amount = List.object.count()

        # Authenticates the user with the request object.
        force_authenticate(req, user=user)

        # Returns the response data if you ran the view with request(e.g if you called a delete request).
        # Also you can put your url kwargs(For example for /lists/<pk>/) like pk or slug in here. Theses kwargs will be automatically passed to view. 

        resp = APIListDetailView.as_view()(req, pk=new_list.pk)

        # Asserts.
        self.assertEqual(204, resp.status_code, "Should delete the list from database.")
        self.assertEqual(current_list_amount, List.objects.count() - 1, "Should have delete a list from the database.")

If you are new to testing it might be worth having a look at factory boy for mocking your Django models. https://factoryboy.readthedocs.io/en/latest/

By the way you should really avoid using generic words like "List" for your model names.

  • Hi James, thank you, but don't you need to pass in the id of new_list to the request? Am I missing something obvious? – Little Brain Mar 11 '19 at 19:13
  • @LittleBrain Sorry I was copying a list view test and I forgot to add the URL kwargs to test. However, this has been amended now. – James Brewer Mar 12 '19 at 09:36
  • I'd like to make my code based on the examples work instead of rewriting everything, and I want to understand what I'm doing wrong with self.client.delete. Would it be possible for you to explain how I would supply the id in my code: response = self.client.delete(self.url)? It must be simple but I cannot find documentation. – Little Brain Mar 15 '19 at 19:56
  • I am not convinced that factory is the right thing to use here, it seems like self.client is a more complete test: https://www.reddit.com/r/django/comments/56ux4c/testcase_vs_requestfactory/. I must be nearly there because the create test works, I only need to know how to construct a delete request. – Little Brain Mar 16 '19 at 01:56