Search code examples
pythondjangounit-testingdjango-rest-framework

How to avoid combinatorial explosion in unit testing Django views


I have a rather complicated Django (Rest Framework) view which updates an object in the database.

In order to update the object, some conditions need to be met (these aren't the real conditions, but they're similar):

  1. The user has to be logged in
  2. The username needs to begin with admin
  3. The update data needs to be valid according to some rules
  4. A separate expensive function is_moon_phase_ok needs to return True

I'm trying to write a solid set of unit tests for this view and the scenarios that I've come up with are the following:

when not logged in, return 401
when not logged in, return {"fail": "login"}
when not logged in, don't touch database
when not logged in, don't check moon phase
when username is not admin_*, return 401
when username is not admin_*, return {"fail": "username"}
when username is not admin_*, don't touch database
when username is not admin_*, don't check moon phase
when invalid data, return 400
when invalid data, return {"fail": "data"}
when invalid data, don't touch database
when invalid data, don't check moon phase
when logged in, return 200
when logged in, return updated data
when logged in, check moon phase
when logged in, update database

As you can tell, each of these unit tests will take quite a bit of code to set up and then execute. In my case, it's between 7 and 20 lines of code per unit test.

Imagine if the requirements change, and they do change, how much of a pain it would be to overlook the test cases, make sure they still apply, update them according to the new requirements, etc.

Is there a better way of accomplishing the same thing, with the same test coverage, but with less labor?


Solution

  • For me this sounds a lot like parametrized tests, pytest have support for this, basically you can write a test and provide input parameters, as well as expected. So you write just one test but generic enough to support different parameters, thus less code to maintain. Behind the scene pytest runs same test one by one with your defined parameters. Writing generic tests may introduce some logic (as you can see in my example) but you can live with that in my opinion

    As an generic example:

    @pytest.mark.parametrize('is_admin,expected_status_code,expected_error', [
        (True, 200, {}),
        (False, 401, {"fail": "login"})
    ])
    def test_sample(is_admin, expected_status_code, expected_data):
        # do your setup
        if is_admin:
            user =  create_super_user()
        else:
            user =  normal_user()
    
        # do your request
        response = client.get('something')
    
        # make assertion on response
        assert response.status_code == expected_status_code
        assert response.data == expected_data
    

    You can also have multiple layers of parameters, eg.:

    @pytest.mark.parametrize('is_admin', [
        True,
        False
    ])
    @pytest.mark.parametrize('some_condition,expected_status_code,expected_error', [
        (True, 200, {}),
        (False, 401, {"fail": "login"})
    ])
    

    This will execute the tests for each combination of is_admin (True/False) and other parameters, nice huh?

    Check documentation here pytest parametrize tests

    If you don't use pytest, check this library that does something similar Parameterized testing with any Python test framework