Testing

microapi includes testing support as part of the package.

Historically, this is one of the pain points of plain old API views in Django. The built-in test client (rightly) assumes you’ll be making form posts and rendering HTML, so supporting JSON & making/checking payloads is tedious & repetitive.

The testing support in django-microapi tries to address this. Let’s start by looking at an example endpoint, and how we’d write tests for it.

Note

If you’d prefer to look at full working code, microapi dogfoods its own testing tools. You can find them within the repository on GitHub.

Example Endpoint

We’ll start with the similar code from the Tutorial:

# blog/api.py
from django.contrib.auth.decorators import login_required

from microapi import (
    ApiView,
    http,
)

from .models import BlogPost


def serialize_author(serializer, author):
    return {
        "id": author.id,
        "username": author.username,
        "email": author.email,
        "first_name": author.first_name,
        "last_name": author.last_name,
    }


def serialize_post(serializer, post):
    data = serializer.to_dict(post)
    data["author"] = serialize_author(serializer, post.author)
    return data


class BlogPostListView(ApiView):
    def serialize(self, obj):
        return serialize_post(obj)

    def get(self, request):
        posts = BlogPost.objects.all().order_by("-created")
        return self.render({
            "success": True,
            "posts": self.serialize_many(posts),
        })

    @login_required
    def post(self, request):
        data = self.read_json(request)

        # TODO: Validate the data here.

        post = self.serializer.from_dict(BlogPost(), data)
        post.author = request.user
        post.save()

        return self.render({
            "success": True,
            "post": self.serialize(post),
        }, status_code=http.CREATED)


class BlogPostDetailView(ApiView):
    def serialize(self, obj):
        return serialize_post(obj)

    def get(self, request, pk):
        try:
            post = BlogPost.objects.get(pk=pk)
        except BlogPost.DoesNotExist:
            return self.render_error("Blog post not found")

        return self.render({
            "success": True,
            "post": self.serialize(post),
        })

    @login_required
    def put(self, request, pk):
        data = self.read_json(request)

        try:
            post = BlogPost.objects.get(pk=pk)
        except BlogPost.DoesNotExist:
            return self.render_error("Blog post not found")

        post = self.serializer.from_dict(post, data)
        post.save()

        return self.render({
            "success": True,
            "post": self.serialize(post),
        }, status_code=http.UPDATED)

    @login_required
    def delete(self, request, pk):
        try:
            post = BlogPost.objects.get(pk=pk)
        except BlogPost.DoesNotExist:
            return self.render_error("Blog post not found")

        post.delete()

        return self.render({
            "success": True,
        }, status_code=http.NO_CONTENT)

Adding Tests

As with most things, microapi doesn’t dictate where you place your tests, but following Django’s conventions is a good place to start. So we’ll assume that you’ve made a blog/tests directory, so that you can have multiple test files within for various purposes.

We’ll create a new file within that directory, blog/tests/test_api.py, to match our blog/api.py layout. Within that file, we’ll start with the following code:

# blog/tests/test_api.py
from microapi.tests import ApiTestCase

from ..models import BlogPost


class BlogPostListViewTestCase(ApiTestCase):
    def test_should_fail(self):
        self.fail("Ensure our tests are being run.")

We’re starting with a simple test class, with a single test that should fail regardless. This will help us ensure our tests are being picked up by the test runner & failing correctly.

microapi.tests.ApiTestCase is a thin wrapper over the top of Django’s own TestCase, with additional methods to support making/receiving API requests and custom methods to assert things about the payloads or the response codes.

Go run your tests as usual:

$ ./manage.py test

You should get a failure (as expected):

AssertionError: Ensure our tests are being run.

----------------------------------------------------------------------
Ran 1 tests in 0.033s

FAILED (failures=1)

Warning

If you didn’t get the expected failure here, something is wrong with your setup. Before writing any further tests or putting any further time into this guide, you should take the time to fix things so that your tests get picked up.

Common problems/reasons include:

  • mis-named files/directories

  • an app not being included in INSTALLED_APPS

  • mis-naming the TestCase class within the tests

Now that we’re sure our tests are running, let’s fix that test case & make sure the list endpoint is responding to a GET correctly:

# blog/tests/test_api.py
from microapi.tests import ApiTestCase

from ..models import BlogPost
# We're importing our view here!
from ..api import BlogPostListView


class BlogPostListViewTestCase(ApiTestCase):
    # We're renaming this method!
    def test_get_success(self):
        # Make a test request.
        req = self.create_request(
            "/api/v1/posts/",
        )
        # Make an API request against our view (newly-imported above).
        resp = self.make_request(BlogPostListView, req)
        # Ensure that we got an HTTP 200 OK from the endpoint.
        self.assertOK(resp)

Nothing here is too crazy, though you’ll note that we’re not directly using either of Django’s included django.test.Client, nor the django.test.RequestFactory. Client, while a great tool normally, unfortunately makes a bunch of assumptions that are invalid for microapi.

Using RequestFactory directly is possible, but making API-related requests with it is kinda painful/repetitive, so we can do better. Enter ApiTestCase.create_request, which uses RequestFactory under-the-hood.

And in the same vein of trying to eliminate painful/repetitive code, ApiTestCase.make_request automates the request/response process against a given APIView. It handles all the instantiation of the view, as well as performing the request against it, returning a HttpResponse in the process as normal.

Finally, because HTTP status codes are more diverse & more important in an API use case, ApiTestCase ships with a host of assertion methods that check for common RESTful status codes. In this case, we’re just looking for a HTTP 200 OK from the endpoint, so self.assertOK(resp) handles that check for us.

Run your tests:

$ ./manage.py test

And you should get:

----------------------------------------------------------------------
Ran 1 tests in 0.047s

OK

🎉 Huzzah! Our code is running, our API is being hit, and our test is passing!

…But before we get too far ahead of ourselves, we should note that what’s coming back from that endpoint right now is just an empty response: there’s no data in our test database!

Inspecting Responses

So that we can do more interesting things in this guide, we’ll add in the creation of some basic test data in the database:

# ...

class BlogPostListViewTestCase(ApiTestCase):
    # Adding this above the test methods.
    def setUp(self):
        super().setUp()
        self.user = User.objects.create_user(
            "testmctest",
            "teest@mctest.com",
            "testpass",
        )
        self.post_1 = BlogPost.objects.create(
            title="Hello, World!",
            content="My first post! *SURELY*, it won't be the last...",
            published_by=self.user,
            published_on=make_aware(
                datetime.datetime(2023, 11, 28, 9, 26, 54, 123456),
                timezone=datetime.timezone.utc,
            ),
        )
        self.post_2 = BlogPost.objects.create(
            title="Life Update",
            content="So, it's been awhile...",
            published_by=self.user,
            published_on=make_aware(
                datetime.datetime(2023, 12, 5, 10, 3, 22, 123456),
                timezone=datetime.timezone.utc,
            ),
        )

Running the tests should get us the same result, since we don’t have any methods asserting anything about the API response(s):

----------------------------------------------------------------------
Ran 1 tests in 0.047s

OK

However, we should now have actual data coming back as part of the list endpoint. So let’s inspect that data & make some assertions about it:

# blog/tests/test_api.py
# We're changing up the import here & adding in `check_response`!
from microapi.tests import (
    ApiTestCase,
    check_response,
)

# ...

class BlogPostListViewTestCase(ApiTestCase):
    # ...

    def test_get_success(self):
        req = self.create_request(
            "/api/v1/posts/",
        )
        resp = self.make_request(BlogPostListView, req)
        self.assertOK(resp)

        # New code here!
        data = check_response(resp)
        # Here, we're just using the built-in `assert*` methods to inspect
        # the response data, just like asserting about any other `dict`.
        self.assertTrue(data["success"])
        self.assertEqual(len(data["posts"]), 2)
        # Note that, because we're creating a stable ordering via
        # `.order_by("-created")`, we can count on these being in this
        # order.
        # If you have an unstable sort order, you'll need to do extra work
        # to make sure tests like these will consistently pass.
        self.assertEqual(data["posts"][0]["title"], "Life Update")
        self.assertEqual(data["posts"][1]["title"], "Hello, World!")

The (unassuming) star of the show here is the newly-added check_response. It’s a utility method that takes a given HttpResponse, checks for appropriate JSON headers, and will automatically decode & return the response body for you.

After processing the response with check_response, the data you get back is a Python representation of the JSON payload (or an empty dict if there was no payload).

Testing Data-Creating Endpoints

Another pain-point of testing APIs is testing endpoints/methods that should create data. Forming a proper request, with the right method/headers/encoded-payload/etc., is tedious.

But, using the tools we’ve already introduced, this gets much easier. So now we’ll add on another test method to exercise the POST & create a blog post with it.

We’ll start by adding the new method to the same test case:

class BlogPostListViewTestCase(ApiTestCase):
    # ...

    def test_post_success(self):
        # While not required, I like to include a sanity-check at the
        # beginning of a test method, to ensure the DB is in the expected
        # state.
        # We should only have the two blog posts that are created in the
        # `setUp` method present.
        self.assertEqual(BlogPost.objects.all().count(), 2)

        # We'll take advantage of some of the optional arguments to
        # `create_request`...
        req = self.create_request(
            "/api/v1/posts/",
            method="post",
            data={
                "title": "Cat Pictures",
                "content": "All the internet is good for.",
                "published_on": "2023-12-05T11:45:45.000000-0600",
            },
            user=self.user,
        )
        # Then make the request & check the response in a similar fashion
        # to the last test method.
        resp = self.make_request(BlogPostListView, req)
        # Since we expect a different status code, we use `assertCreated`
        # here in place of `assertOK`.
        self.assertCreated(resp)

        # Finally, a simple assertion about the state of the DB.
        # We should ensure the new post is present.
        self.assertEqual(BlogPost.objects.all().count(), 3)

The only substantially different code here is how we create the request via ApiTestCase.create_request. We can provide the HTTP method to use, and the data to be automatically JSON-encoded for us. Since that method is protected by the login_required decorator, we can even supply the logged-in user making the request!

Now when we run our tests, we should get back something like:

----------------------------------------------------------------------
Ran 2 tests in 0.053s

OK

And we know our API is behaving properly.

“Final” API Test Code

Putting everything together, our completed test code should look like:

# blog/tests/test_api.py
from microapi.tests import (
    ApiTestCase,
    check_response,
)

from ..models import BlogPost
from ..api import BlogPostListView

class BlogPostListViewTestCase(ApiTestCase):
    def setUp(self):
        super().setUp()
        self.user = User.objects.create_user(
            "testmctest",
            "teest@mctest.com",
            "testpass",
        )
        self.post_1 = BlogPost.objects.create(
            title="Hello, World!",
            content="My first post! *SURELY*, it won't be the last...",
            published_by=self.user,
            published_on=make_aware(
                datetime.datetime(2023, 11, 28, 9, 26, 54, 123456),
                timezone=datetime.timezone.utc,
            ),
        )
        self.post_2 = BlogPost.objects.create(
            title="Life Update",
            content="So, it's been awhile...",
            published_by=self.user,
            published_on=make_aware(
                datetime.datetime(2023, 12, 5, 10, 3, 22, 123456),
                timezone=datetime.timezone.utc,
            ),
        )

    def test_get_success(self):
        req = self.create_request(
            "/api/v1/posts/",
        )
        resp = self.make_request(BlogPostListView, req)
        self.assertOK(resp)

        data = check_response(resp)
        self.assertTrue(data["success"])
        self.assertEqual(len(data["posts"]), 2)
        self.assertEqual(data["posts"][0]["title"], "Life Update")
        self.assertEqual(data["posts"][1]["title"], "Hello, World!")

    def test_post_success(self):
        # Sanity-check.
        self.assertEqual(BlogPost.objects.all().count(), 2)

        req = self.create_request(
            "/api/v1/posts/",
            method="post",
            data={
                "title": "Cat Pictures",
                "content": "All the internet is good for.",
                "published_on": "2023-12-05T11:45:45.000000-0600",
            },
            user=self.user,
        )
        resp = self.make_request(BlogPostListView, req)
        self.assertCreated(resp)

        self.assertEqual(BlogPost.objects.all().count(), 3)

Pytest Support

pytest is a fairly common/popular testing package within the Python community, and microapi ships with first-class support for it.

microapi.test includes a host of utility functions that can be directly used within your pytest test methods to exercise API endpoints. The full list is available in the microapi.tests reference.

In fact, everything that we covered above as part of ApiTestCase actually uses the function-based utilities/assertions built for pytest, neatly wrapped in a more familiar class-based approach.

So we could re-write our blog/tests/test_api.py like so for pytest:

# blog/tests/test_api.py
# Note that our imports here are quite different!
from microapi.tests import (
    assert_created,
    assert_ok,
    create_request,
    check_response,
)

from ..models import BlogPost
from ..api import BlogPostListView

def setup_posts():
    # There are better ways to do fixtures, but for the sake of keeping
    # things familiar to the above code...
    user = User.objects.create_user(
        "testmctest",
        "teest@mctest.com",
        "testpass",
    )
    post_1 = BlogPost.objects.create(
        title="Hello, World!",
        content="My first post! *SURELY*, it won't be the last...",
        published_by=user,
        published_on=make_aware(
            datetime.datetime(2023, 11, 28, 9, 26, 54, 123456),
            timezone=datetime.timezone.utc,
        ),
    )
    post_2 = BlogPost.objects.create(
        title="Life Update",
        content="So, it's been awhile...",
        published_by=user,
        published_on=make_aware(
            datetime.datetime(2023, 12, 5, 10, 3, 22, 123456),
            timezone=datetime.timezone.utc,
        ),
    )

def test_posts_get_success(self):
    setup_posts()

    req = create_request(
        "/api/v1/posts/",
    )
    view_func = BlogPostListView.as_view()
    # Don't forget to supply args/kwargs as they'd be received from the
    # URLconf here!
    resp = view_func(req)
    assert_ok(resp)

    data = check_response(resp)
    assert data["success"] == True
    assert len(data["posts"] == 2
    assert data["posts"][0]["title"] == "Life Update"
    assert data["posts"][1]["title"] == "Hello, World!"

def test_post_success(self):
    setup_posts()

    # Sanity-check.
    assert BlogPost.objects.all().count() == 2

    req = create_request(
        "/api/v1/posts/",
        method="post",
        data={
            "title": "Cat Pictures",
            "content": "All the internet is good for.",
            "published_on": "2023-12-05T11:45:45.000000-0600",
        },
        user=user,
    )
    view_func = BlogPostListView.as_view()
    # Don't forget to supply args/kwargs as they'd be received from the
    # URLconf here!
    resp = view_func(req)
    assert_created(resp)

    assert BlogPost.objects.all().count() == 3

And running them with pytest should yield something like:

collected 2 items

blog/tests/test_api.py ..                  [100%]

=============== 2 passed in 0.21s ===============