Package: pytest-drf
pytest-drfpytest-drf is apytestplugin for testing yourDjango REST FrameworkAPIs.Installationpipinstallpytest-drfThe Spielpytest-drf aims to shoo away clunky setup code and boilerplate in DRF tests, in favor of declarative scaffolds and configurable fixtures encouraging small, easy-to-follow tests with single responsibilities.This is accomplished by performing one request per test, and providing the response as a fixture. All configuration of the request — the URL, the query params, the HTTP method, the POST data, etc — is also done through fixtures. This frees the test methods to contain only assertions about the response or the state of the app after the request completes.For example, consider a public API endpoint that responds to a GET request with the JSON string "Hello, World!" and a 200 status code. Such an endpoint might be written like so# example/views.pyfromrest_frameworkimportpermissionsfromrest_framework.decoratorsimportapi_view,permission_classesfromrest_framework.responseimportResponse@api_view()@permission_classes([permissions.AllowAny])defhello_world(request):returnResponse('Hello, World!')Let's route it to/hello, and give it a name, so we can easily generate URLs for it.# example/urls.pyfromdjango.urlsimportpathfromexampleimportviewsurlpatterns=[path('hello',views.hello_world,name='hello-world'),]With pytest-drf, we'd verify the behavior of our endpoint with something like this# tests/test_hello.pyimportpytestfromdjango.urlsimportreversefrompytest_drfimportAPIViewTest,Returns200,UsesGetMethodclassTestHelloWorld(APIViewTest,UsesGetMethod,Returns200,):@pytest.fixturedefurl(self):returnreverse('hello-world')deftest_it_returns_hello_world(self,json):expected='Hello, World!'actual=jsonassertexpected==actualWhen we run pytest, we see two tests run$ py.test

tests/test_hello.py::TestHelloWorld::test_it_returns_200 <- pytest_drf/status.py PASSED [ 50%]
tests/test_hello.py::TestHelloWorld::test_it_returns_hello_world PASSED                 [100%]What happened here?Well,APIViewTestdefines aresponsefixture that takes the value of theurlfixture and makes an HTTP request to it. TheUsesGetMethodmixin defines thehttp_methodas'get', making it a GET request. Theresponsefixture is defined asautouse=True, so it's automatically evaluated before the test method is executed.TheReturns200mixin simply adds a test withassert response.status_code == 200. This test has its own request/response cycle, independent oftest_it_returns_hello_world— this way, if the endpoint responds with the wrong status code but the correct response data, one can see what needs fixing after runningpy.testonly once!What's with thejsonfixture?Why doestest_it_returns_hello_worlduse thejsonfixture, instead ofresponse?jsonis another fixture that merely returnsresponse.json(). Because the API response is provided as a fixture, we can perform any post-processing transformations on it in another fixture — this lets us keep all those irrelevant implementation details out of the test methods, so they can focus on one thing and one thing only: verifying the data is correct.Do I have to use thisexpected == actualbusiness?No, but it's Encouraged™! By explicitly labeling the known value and the value being tested, it'll be easier for another reader (including future you) to understand what's actually going on.The specific order ofexpectedandactualin the assertion (i.e. whether it'sexpected == actualoractual == expected) isn't as important as maintaining the same order everywhere. This will ease the interpretation of test failures, as, e.g., you'll always know which side of a diff contains the incorrect values.The orderexpected == actualis used in pytest-drf's mixins to serve a left-to-right bias, whereexpected == actualalways puts the known value first.Reducing the bulkiness of fixturesThough a fixture-based approach like this can be very convenient in terms of configuration and hiding implementation details, it can also consume a lot of valuable screen real estate — especially if the fixtures consist entirely of a return statement.For simple, one-line fixtures,pytest-lambda(included as a dependency to pytest-drf) enables expressing them as lambda functions. Here's what our test file looks like with a lambda fixturefromdjango.urlsimportreversefrompytest_drfimportAPIViewTest,Returns200,UsesGetMethodfrompytest_lambdaimportlambda_fixtureclassTestHelloWorld(APIViewTest,UsesGetMethod,Returns200,):url=lambda_fixture(lambda:reverse('hello-world'))deftest_it_returns_hello_world(self,json):expected='Hello, World!'actual=jsonassertexpected==actualAdditionally, for constant value fixtures, pytest-lambda providesstatic_fixtureWhat about authentication?To make requests as someone other thanAnonymousUser, pytest-drf provides theAsUser()mixin. Simply create / expose a user in a fixture, and include the mixin with your view testfromdjango.contrib.auth.modelsimportUserfromdjango.urlsimportreversefrompytest_drfimportAPIViewTest,AsUser,Returns200,UsesGetMethodfrompytest_lambdaimportlambda_fixturealice=lambda_fixture(lambda:User.objects.create(username='alice',first_name='Alice',last_name='Innchains',email='alice@ali.ce',))classTestAboutMe(APIViewTest,UsesGetMethod,Returns200,AsUser('alice'),):url=lambda_fixture(lambda:reverse('about-me'))deftest_it_returns_profile(self,json):expected={'username':'alice','first_name':'Alice','last_name':'Innchains','email':'alice@ali.ce',}actual=jsonassertexpected==actualBut I mainly use ViewSets, not APIViews!pytest-drf offersViewSetTest, along with some mixins and conventions, to aid in testing ViewSets.Consider thisKeyValuemodel. It's got an ID, a string key, and a string value# kv/models.pyfromdjango.dbimportmodelsclassKeyValue(models.Model):key=models.CharField(max_length=32,unique=True)value=models.CharField(max_length=32)We implement the endpoints with aModelViewSetandModelSerializer# kv/views.pyfromrest_frameworkimportpermissions,serializers,viewsetsfromrest_framework.paginationimportPageNumberPaginationfromkv.modelsimportKeyValueclassKeyValueSerializer(serializers.ModelSerializer):classMeta:model=KeyValuefields=('id','key','value',)classKeyValueViewSet(viewsets.ModelViewSet):queryset=KeyValue.objects.order_by('id')serializer_class=KeyValueSerializerpagination_class=PageNumberPaginationpermission_classes=[permissions.AllowAny]We route it with a base path of/kv, leading to the registration of two routes (list and detail) servicing list, create, retrieve, update, and destroy actionsGET /kv— list KeyValuesPOST /kv— create a KeyValueGET /kv/<pk>— retrieve a KeyValuePATCH /kv/<pk>— update a KeyValueDELETE /kv/<pk>— delete a KeyValue# kv/urls.pyfromdjango.urlsimportinclude,pathfromrest_frameworkimportroutersfromkvimportviewsrouter=routers.DefaultRouter()router.register('kv',views.KeyValueViewSet,basename='key-values')urlpatterns=[path('',include(router.urls)),]Now, usingViewSetTest, we scaffold our tests# tests/test_kv.pyfrompytest_drfimport(ViewSetTest,Returns200,Returns201,Returns204,UsesGetMethod,UsesDeleteMethod,UsesDetailEndpoint,UsesListEndpoint,UsesPatchMethod,UsesPostMethod,)frompytest_drf.utilimporturl_forfrompytest_lambdaimportlambda_fixtureclassTestKeyValueViewSet(ViewSetTest):list_url=lambda_fixture(lambda:url_for('key-values-list'))detail_url=lambda_fixture(lambdakey_value:url_for('key-values-detail',key_value.pk))classTestList(UsesGetMethod,UsesListEndpoint,Returns200,):passclassTestCreate(UsesPostMethod,UsesListEndpoint,Returns201,):passclassTestRetrieve(UsesGetMethod,UsesDetailEndpoint,Returns200,):passclassTestUpdate(UsesPatchMethod,UsesDetailEndpoint,Returns200,):passclassTestDestroy(UsesDeleteMethod,UsesDetailEndpoint,Returns204,):passAt the moment, most of these tests will fail, because we haven't yet provided an implementation for thekey_valuefixture (though,TestCreatefails on serializer validation, as we haven't passed any required fields, yet).$py.test--tb=no

tests/test_kv.py::TestKeyValueViewSet::TestList::test_it_returns_200<-pytest_drf/status.pyPASSED[20%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_returns_201<-pytest_drf/status.pyFAILED[40%]tests/test_kv.py::TestKeyValueViewSet::TestRetrieve::test_it_returns_200<-pytest_drf/status.pyERROR[60%]tests/test_kv.py::TestKeyValueViewSet::TestUpdate::test_it_returns_200<-pytest_drf/status.pyERROR[80%]tests/test_kv.py::TestKeyValueViewSet::TestDestroy::test_it_returns_204<-pytest_drf/status.pyERROR[100%]The skeleton above, which we'll fill out soon, is just to show the key components of aViewSetTest, which are:Implementinglist_urlanddetail_urlUnlike APIViews, ViewSets often have two separate routes: one for the general set of resources (the list endpoint), and one for a specific resource (the detail endpoint). Since these are the same URLs regardless of the particular ViewSet action, we implement them once at the beginning of the ViewSetTest, and switch between them in nested classes usingUsesListEndpointandUsesDetailEndpoint.Since the detail endpoint acts upon a specific instance,detail_urlacts upon thekey_valuefixture, which we will implement in the Retrieve/Update/Destroy tests by creating aKeyValuerow.ViewSet action child test contexts (nested classes)For each ViewSet action (i.e. endpoint/http method combination), we'll create a separate nested class (AKA test context) with appropriate mixins to denote which endpoint to hit (implemented bylist_urlordetail_url), and with which HTTP method. We also add aReturns2XXmixin for each endpoint, which quickly lets us run tests that hit the actual ViewSet.Now, before we get to writing the crux of our CRUD tests, we're gonna implement anexpression method, which will aid us in verifying API responses.Expression methodsAn expression method is a simple function that takes a model instance and returns what the API would respond for it. Our serializer has three fields:id,key, andvalue. So our expression method looks something like this:defexpress_key_value(kv:KeyValue)->Dict[str,Any]:return{'id':kv.id,'key':kv.key,'value':kv.value,}Now, when we make assertions on our API endpoint's response, we can easily verify the integrity of the whole data. For list responses, though, it would be nice to pass a list ofKeyValueinstances and get a list of expressions back. For that, we havepytest_drf.util.pluralized:frompytest_drf.utilimportpluralizedexpress_key_values=pluralized(express_key_value)In[1]:a=KeyValue.objects.create(key='apples',value='oranges')In[2]:b=KeyValue.objects.create(key='bananas',value='B A NA NA S')In[3]:express_key_value(a)Out[3]:{'id':1,'key':'apples','value':'oranges'}In[4]:express_key_value(b)Out[4]:{'id':2,'key':'bananas','value':'B A NA NA S'}In[5]:express_key_values([a,b])Out[5]:[{'id':1,'key':'apples','value':'oranges'},{'id':2,'key':'bananas','value':'B A NA NA S'}]Now that we're equipped, let's get to writing tests for each ViewSet actionTestListTo verify the behavior of the list action, we want to check that it returns the proper structure for eachKeyValuein the right order. So, let's actually create someKeyValuesfor our endpoint to respond withclassTestList(UsesGetMethod,UsesListEndpoint,Returns200,):key_values=lambda_fixture(lambda:[KeyValue.objects.create(key=key,value=value)forkey,valuein{'quay':'worth','chi':'revenue','umma':'gumma',}.items()],autouse=True,)We use a dictionary to supply the information, and a list comprehension to create the actual rows. Note that we setautouse=True, so ourReturns200test has a substantial response. If we didn't set it, theReturns200test wouldn't have any real rows — which might mask an exception in serialization.And now let's verify the endpoint's responsedeftest_it_returns_key_values(self,key_values,results):expected=express_key_values(sorted(key_values,key=lambdakv:kv.id))actual=resultsassertexpected==actualHere we use theresultsfixture, which is equivalent toresponse.json()['results']— it's where the actual API response is when using a pagination class. Our endpoint is usingPageNumberPagination.For ourexpectedvalue, we lean on the pluralized version of our expression method. We pass it our sourceKeyValueinstances, sorted by ID, because ourViewSetorders them the same.Let's run the tests, and see how we're coming along$py.test--tb=no

tests/test_kv.py::TestKeyValueViewSet::TestList::test_it_returns_200<-pytest_drf/status.pyPASSED[16%]tests/test_kv.py::TestKeyValueViewSet::TestList::test_it_returns_key_valuesPASSED[33%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_returns_201<-pytest_drf/status.pyFAILED[50%]tests/test_kv.py::TestKeyValueViewSet::TestRetrieve::test_it_returns_200<-pytest_drf/status.pyERROR[66%]tests/test_kv.py::TestKeyValueViewSet::TestUpdate::test_it_returns_200<-pytest_drf/status.pyERROR[83%]tests/test_kv.py::TestKeyValueViewSet::TestDestroy::test_it_returns_204<-pytest_drf/status.pyERROR[100%]Ayyy, it passed! Making progress!TestCreateWhen it comes to the "C" in "CRUD", there are a few behaviors the endpoint should and shouldn't have, if we're aiming to be thorough. Namely, the endpoint:Should create a newKeyValue, andonly oneKeyValue(and also shouldn't delete any others)Should create a newKeyValuewith precisely the data we POSTShould return the newly-createdKeyValuein the proper structureBefore we get to those tests, let's setup our POST data, which is super simple:classTestCreate(UsesPostMethod,UsesListEndpoint,Returns201,):data=static_fixture({'key':'snakes','value':'hissssssss',})We lean onpytest-lambda'sstatic_fixture, which creates a fixture that returns a constant value. Thedatafixture will be POSTed asclient.post(data=data).Now, to test whether the endpoint creates one and only oneKeyValue, we must record whichKeyValuerows existbeforethe request, and compare them with the rows that existafterthe request. For this, we'll useprecondition_fixture, a feature ofpytest-common-subject, which pytest-drf is built upon. Though the fixture performing each test's HTTP request is evaluated later than all others (using the@pytest.mark.latemark frompytest-fixture-order),precondition_fixtureuses pytest's dependency graph to 100% ensure it's evaluated before the request.frompytest_common_subjectimportprecondition_fixtureinitial_key_value_ids=precondition_fixture(lambda:set(KeyValue.objects.values_list('id',flat=True)))deftest_it_creates_new_key_value(self,initial_key_value_ids,json):expected=initial_key_value_ids|{json['id']}actual=set(KeyValue.objects.values_list('id',flat=True))assertexpected==actualSo, we record a set of all the existingKeyValueIDs with ourprecondition_fixture. What we expect to see after the request is that only one new ID has been added: the ID of our newly-createdKeyValue, which is returned in the API response. Let's check our test results now$py.test--tb=no

tests/test_kv.py::TestKeyValueViewSet::TestList::test_it_returns_200<-pytest_drf/status.pyPASSED[14%]tests/test_kv.py::TestKeyValueViewSet::TestList::test_it_returns_key_valuesPASSED[28%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_returns_201<-pytest_drf/status.pyPASSED[42%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_creates_new_key_valuePASSED[57%]tests/test_kv.py::TestKeyValueViewSet::TestRetrieve::test_it_returns_200<-pytest_drf/status.pyERROR[71%]tests/test_kv.py::TestKeyValueViewSet::TestUpdate::test_it_returns_200<-pytest_drf/status.pyERROR[85%]tests/test_kv.py::TestKeyValueViewSet::TestDestroy::test_it_returns_204<-pytest_drf/status.pyERROR[100%]Success!Next up is verifying that the newly-createdKeyValuehas exactly the field values we POSTed.frompytest_assert_utilsimportassert_model_attrsdeftest_it_sets_expected_attrs(self,data,json):key_value=KeyValue.objects.get(pk=json['id'])expected=dataassert_model_attrs(key_value,expected)Here we lean onassert_model_attrsfrompytest-assert-utils, which performs a dict-to-dict comparison under the hood, so if the items aren't equal, pytest will display a diff. Let's run it$py.test--tb=no

tests/test_kv.py::TestKeyValueViewSet::TestList::test_it_returns_200<-pytest_drf/status.pyPASSED[12%]tests/test_kv.py::TestKeyValueViewSet::TestList::test_it_returns_key_valuesPASSED[25%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_returns_201<-pytest_drf/status.pyPASSED[37%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_creates_new_key_valuePASSED[50%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_sets_expected_attrsPASSED[62%]tests/test_kv.py::TestKeyValueViewSet::TestRetrieve::test_it_returns_200<-pytest_drf/status.pyERROR[75%]tests/test_kv.py::TestKeyValueViewSet::TestUpdate::test_it_returns_200<-pytest_drf/status.pyERROR[87%]tests/test_kv.py::TestKeyValueViewSet::TestDestroy::test_it_returns_204<-pytest_drf/status.pyERROR[100%]Nice!Last but not least, we verify the structure of the API response.deftest_it_returns_key_value(self,json):key_value=KeyValue.objects.get(pk=json['id'])expected=express_key_value(key_value)actual=jsonassertexpected==actual$py.test--tb=no

tests/test_kv.py::TestKeyValueViewSet::TestList::test_it_returns_200<-pytest_drf/status.pyPASSED[11%]tests/test_kv.py::TestKeyValueViewSet::TestList::test_it_returns_key_valuesPASSED[22%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_returns_201<-pytest_drf/status.pyPASSED[33%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_creates_new_key_valuePASSED[44%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_sets_expected_attrsPASSED[55%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_returns_key_valuePASSED[66%]tests/test_kv.py::TestKeyValueViewSet::TestRetrieve::test_it_returns_200<-pytest_drf/status.pyERROR[77%]tests/test_kv.py::TestKeyValueViewSet::TestUpdate::test_it_returns_200<-pytest_drf/status.pyERROR[88%]tests/test_kv.py::TestKeyValueViewSet::TestDestroy::test_it_returns_204<-pytest_drf/status.pyERROR[100%]Yay! Our create action is covered!TestRetrieveRetrieval is simple to test: just create aKeyValuebeforehand, and verify the endpoint returns it in the proper structure. It's also the first of the ViewSet actions to use thedetail_url. We're gonna define thekey_valuefixture thatdetail_urlrequestsclassTestRetrieve(UsesGetMethod,UsesDetailEndpoint,Returns200,):key_value=lambda_fixture(lambda:KeyValue.objects.create(key='monty',value='jython',))And with our expression method, verifying the API response is a cinchdeftest_it_returns_key_value(self,key_value,json):expected=express_key_value(key_value)actual=jsonassertexpected==actual$py.test--tb=no

tests/test_kv.py::TestKeyValueViewSet::TestList::test_it_returns_200<-pytest_drf/status.pyPASSED[10%]tests/test_kv.py::TestKeyValueViewSet::TestList::test_it_returns_key_valuesPASSED[20%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_returns_201<-pytest_drf/status.pyPASSED[30%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_creates_new_key_valuePASSED[40%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_sets_expected_attrsPASSED[50%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_returns_key_valuePASSED[60%]tests/test_kv.py::TestKeyValueViewSet::TestRetrieve::test_it_returns_200<-pytest_drf/status.pyPASSED[70%]tests/test_kv.py::TestKeyValueViewSet::TestRetrieve::test_it_returns_key_valuePASSED[80%]tests/test_kv.py::TestKeyValueViewSet::TestUpdate::test_it_returns_200<-pytest_drf/status.pyERROR[90%]tests/test_kv.py::TestKeyValueViewSet::TestDestroy::test_it_returns_204<-pytest_drf/status.pyERROR[100%]Almost done!TestUpdateThe update endpoint should do two things:Change theKeyValuerow in the database to matchpreciselywhat we've POSTedReturn the updatedKeyValuein the proper structureLike our retrieve tests, we'll start by defining thekey_valuefixture, which we'll be updating through our request; but we'll also declare the data we'll be POSTingclassTestUpdate(UsesPatchMethod,UsesDetailEndpoint,Returns200,):key_value=lambda_fixture(lambda:KeyValue.objects.create(key='pipenv',value='was a huge leap forward',))data=static_fixture({'key':'buuut poetry','value':'locks quicker and i like that',})So let's test that our POSTed data makes it to the databasedeftest_it_sets_expected_attrs(self,data,key_value):# We must tell Django to grab fresh data from the database, or we'll# see our stale initial data and think our endpoint is broken!key_value.refresh_from_db()expected=dataassert_model_attrs(key_value,expected)$py.test--tb=no

tests/test_kv.py::TestKeyValueViewSet::TestList::test_it_returns_200<-pytest_drf/status.pyPASSED[9%]tests/test_kv.py::TestKeyValueViewSet::TestList::test_it_returns_key_valuesPASSED[18%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_returns_201<-pytest_drf/status.pyPASSED[27%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_creates_new_key_valuePASSED[36%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_sets_expected_attrsPASSED[45%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_returns_key_valuePASSED[54%]tests/test_kv.py::TestKeyValueViewSet::TestRetrieve::test_it_returns_200<-pytest_drf/status.pyPASSED[63%]tests/test_kv.py::TestKeyValueViewSet::TestRetrieve::test_it_returns_key_valuePASSED[72%]tests/test_kv.py::TestKeyValueViewSet::TestUpdate::test_it_returns_200<-pytest_drf/status.pyPASSED[81%]tests/test_kv.py::TestKeyValueViewSet::TestUpdate::test_it_sets_expected_attrsPASSED[90%]tests/test_kv.py::TestKeyValueViewSet::TestDestroy::test_it_returns_204<-pytest_drf/status.pyERROR[100%]Excellent.Now just the standard response structure verification, though this time we'll have to do the samerefresh_from_db()dance as the last test, for the same reasondeftest_it_returns_key_value(self,key_value,json):key_value.refresh_from_db()expected=express_key_value(key_value)actual=jsonassertexpected==actual$py.test--tb=no

tests/test_kv.py::TestKeyValueViewSet::TestList::test_it_returns_200<-pytest_drf/status.pyPASSED[8%]tests/test_kv.py::TestKeyValueViewSet::TestList::test_it_returns_key_valuesPASSED[16%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_returns_201<-pytest_drf/status.pyPASSED[25%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_creates_new_key_valuePASSED[33%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_sets_expected_attrsPASSED[41%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_returns_key_valuePASSED[50%]tests/test_kv.py::TestKeyValueViewSet::TestRetrieve::test_it_returns_200<-pytest_drf/status.pyPASSED[58%]tests/test_kv.py::TestKeyValueViewSet::TestRetrieve::test_it_returns_key_valuePASSED[66%]tests/test_kv.py::TestKeyValueViewSet::TestUpdate::test_it_returns_200<-pytest_drf/status.pyPASSED[75%]tests/test_kv.py::TestKeyValueViewSet::TestUpdate::test_it_sets_expected_attrsPASSED[83%]tests/test_kv.py::TestKeyValueViewSet::TestUpdate::test_it_returns_key_valuePASSED[91%]tests/test_kv.py::TestKeyValueViewSet::TestDestroy::test_it_returns_204<-pytest_drf/status.pyERROR[100%]Boom! Just the destroy action left!TestDestroyLast but not least (well, maybe in terms ofKeyValue.objects.count()), we have our deletion endpoint. Its only behavior is to delete the specifiedKeyValue, andonlythe specifiedKeyValue. Similar toTestCreate, we'll use aprecondition_fixtureto record the initial set ofKeyValueIDs — though, this time we'll include theKeyValuewe'll be deleting in the listclassTestDestroy(UsesDeleteMethod,UsesDetailEndpoint,Returns204,):key_value=lambda_fixture(lambda:KeyValue.objects.create(key='i love',value='YOU',))initial_key_value_ids=precondition_fixture(lambdakey_value:# ensure our to-be-deleted KeyValue exists in our setset(KeyValue.objects.values_list('id',flat=True)))deftest_it_deletes_key_value(self,initial_key_value_ids,key_value):expected=initial_key_value_ids-{key_value.id}actual=set(KeyValue.objects.values_list('id',flat=True))assertexpected==actual$py.test--tb=no

tests/test_kv.py::TestKeyValueViewSet::TestList::test_it_returns_200<-pytest_drf/status.pyPASSED[7%]tests/test_kv.py::TestKeyValueViewSet::TestList::test_it_returns_key_valuesPASSED[15%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_returns_201<-pytest_drf/status.pyPASSED[23%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_creates_new_key_valuePASSED[30%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_sets_expected_attrsPASSED[38%]tests/test_kv.py::TestKeyValueViewSet::TestCreate::test_it_returns_key_valuePASSED[46%]tests/test_kv.py::TestKeyValueViewSet::TestRetrieve::test_it_returns_200<-pytest_drf/status.pyPASSED[53%]tests/test_kv.py::TestKeyValueViewSet::TestRetrieve::test_it_returns_key_valuePASSED[61%]tests/test_kv.py::TestKeyValueViewSet::TestUpdate::test_it_returns_200<-pytest_drf/status.pyPASSED[69%]tests/test_kv.py::TestKeyValueViewSet::TestUpdate::test_it_sets_expected_attrsPASSED[76%]tests/test_kv.py::TestKeyValueViewSet::TestUpdate::test_it_returns_key_valuePASSED[84%]tests/test_kv.py::TestKeyValueViewSet::TestDestroy::test_it_returns_204<-pytest_drf/status.pyPASSED[92%]tests/test_kv.py::TestKeyValueViewSet::TestDestroy::test_it_deletes_key_valuePASSED[100%]=============================================13passedin0.42s==============================================Yaaaaay!Putting it all together# tests/test_kv.pyfromtypingimportAny,Dictfrompytest_common_subjectimportprecondition_fixturefrompytest_drfimport(ViewSetTest,Returns200,Returns201,Returns204,UsesGetMethod,UsesDeleteMethod,UsesDetailEndpoint,UsesListEndpoint,UsesPatchMethod,UsesPostMethod,)frompytest_drf.utilimportpluralized,url_forfrompytest_lambdaimportlambda_fixture,static_fixturefrompytest_assert_utilsimportassert_model_attrsdefexpress_key_value(kv:KeyValue)->Dict[str,Any]:return{'id':kv.id,'key':kv.key,'value':kv.value,}express_key_values=pluralized(express_key_value)classTestKeyValueViewSet(ViewSetTest):list_url=lambda_fixture(lambda:url_for('key-values-list'))detail_url=lambda_fixture(lambdakey_value:url_for('key-values-detail',key_value.pk))classTestList(UsesGetMethod,UsesListEndpoint,Returns200,):key_values=lambda_fixture(lambda:[KeyValue.objects.create(key=key,value=value)forkey,valuein{'quay':'worth','chi':'revenue','umma':'gumma',}.items()],autouse=True,)deftest_it_returns_key_values(self,key_values,results):expected=express_key_values(sorted(key_values,key=lambdakv:kv.id))actual=resultsassertexpected==actualclassTestCreate(UsesPostMethod,UsesListEndpoint,Returns201,):data=static_fixture({'key':'snakes','value':'hissssssss',})initial_key_value_ids=precondition_fixture(lambda:set(KeyValue.objects.values_list('id',flat=True)))deftest_it_creates_new_key_value(self,initial_key_value_ids,json):expected=initial_key_value_ids|{json['id']}actual=set(KeyValue.objects.values_list('id',flat=True))assertexpected==actualdeftest_it_sets_expected_attrs(self,data,json):key_value=KeyValue.objects.get(pk=json['id'])expected=dataassert_model_attrs(key_value,expected)deftest_it_returns_key_value(self,json):key_value=KeyValue.objects.get(pk=json['id'])expected=express_key_value(key_value)actual=jsonassertexpected==actualclassTestRetrieve(UsesGetMethod,UsesDetailEndpoint,Returns200,):key_value=lambda_fixture(lambda:KeyValue.objects.create(key='monty',value='jython',))deftest_it_returns_key_value(self,key_value,json):expected=express_key_value(key_value)actual=jsonassertexpected==actualclassTestUpdate(UsesPatchMethod,UsesDetailEndpoint,Returns200,):key_value=lambda_fixture(lambda:KeyValue.objects.create(key='pipenv',value='was a huge leap forward',))data=static_fixture({'key':'buuut poetry','value':'locks quicker and i like that',})deftest_it_sets_expected_attrs(self,data,key_value):# We must tell Django to grab fresh data from the database, or we'll# see our stale initial data and think our endpoint is broken!key_value.refresh_from_db()expected=dataassert_model_attrs(key_value,expected)deftest_it_returns_key_value(self,key_value,json):key_value.refresh_from_db()expected=express_key_value(key_value)actual=jsonassertexpected==actualclassTestDestroy(UsesDeleteMethod,UsesDetailEndpoint,Returns204,):key_value=lambda_fixture(lambda:KeyValue.objects.create(key='i love',value='YOU',))initial_key_value_ids=precondition_fixture(lambdakey_value:# ensure our to-be-deleted KeyValue exists in our setset(KeyValue.objects.values_list('id',flat=True)))deftest_it_deletes_key_value(self,initial_key_value_ids,key_value):expected=initial_key_value_ids-{key_value.id}actual=set(KeyValue.objects.values_list('id',flat=True))assertexpected==actualIt's quite a feat!Now, we tested an already-existing endpoint here, just for demonstration purposes. But there's a bigger advantage to performing one request per test, and having a single responsibility for each test: we can write the tests first and incrementally build the ViewSet. We run the tests on changes, and when they're all green, we know the endpoint is done.The beauty of the tests-first methodology is that it frees us up to be creative. Because we have a definite end condition, we can experiment with better implementations — more maintainable, easier to read, using best practices, perhaps leaning on a third-party package for heavy lifting.Well, congratulations if you've made it this far. I hope you may find some value in this library, or even from some conventions in these example tests. Good luck out there, and remember: readability counts — in tests, doubly so.Bonus: BDD syntaxPersonally, I like to useDescribeKeyValueViewSet, andDescribeList,DescribeCreate, etc for my test classes. If I'm testingDescribeCreateas a particular user, I like to use, e.g.,ContextAsAdmin. SometimesCaseUnauthenticatedhits the spot.And for test methods, I love to omit thetestintest_it_does_xyz, and simply putit_does_xyz.To appease my leanings toward BDD namings, I use apytest.iniwith these options:[pytest]# Only search for tests within files matching these patternspython_files=tests.py test_*.py# Discover tests within classes matching these patterns# NOTE: the dash represents a word boundary (functionality provided by pytest-camel-collect)python_classes=Test-* Describe-* Context-* With-* Without-* For-* When-* If-* Case-*# Only methods matching these patterns are considered testspython_functions=test_* it_* its_*About the dashes inpython_classes: sometimes I'll name a test classForAdminUsers. If I had the patternFor*, it would also match a pytest-drf mixin namedForbidsAnonymousUsers.pytest-camel-collectis a little plugin that interprets dashes inpython_classesas CamelCase word boundaries. However, similar behavior can be had on stock pytest using a pattern likeFor[A-Z0-9]*.Here's what our exampleKeyValueViewSettest would look like with this BDD naming schemeBDD-esque KeyValueViewSet testfromtypingimportAny,Dictfrompytest_common_subjectimportprecondition_fixturefrompytest_drfimport(ViewSetTest,Returns200,Returns201,Returns204,UsesGetMethod,UsesDeleteMethod,UsesDetailEndpoint,UsesListEndpoint,UsesPatchMethod,UsesPostMethod,)frompytest_drf.utilimportpluralized,url_forfrompytest_lambdaimportlambda_fixture,static_fixturefrompytest_assert_utilsimportassert_model_attrsdefexpress_key_value(kv:KeyValue)->Dict[str,Any]:return{'id':kv.id,'key':kv.key,'value':kv.value,}express_key_values=pluralized(express_key_value)classDescribeKeyValueViewSet(ViewSetTest):list_url=lambda_fixture(lambda:url_for('key-values-list'))detail_url=lambda_fixture(lambdakey_value:url_for('key-values-detail',key_value.pk))classDescribeList(UsesGetMethod,UsesListEndpoint,Returns200,):key_values=lambda_fixture(lambda:[KeyValue.objects.create(key=key,value=value)forkey,valuein{'quay':'worth','chi':'revenue','umma':'gumma',}.items()],autouse=True,)defit_returns_key_values(self,key_values,results):expected=express_key_values(sorted(key_values,key=lambdakv:kv.id))actual=resultsassertexpected==actualclassDescribeCreate(UsesPostMethod,UsesListEndpoint,Returns201,):data=static_fixture({'key':'snakes','value':'hissssssss',})initial_key_value_ids=precondition_fixture(lambda:set(KeyValue.objects.values_list('id',flat=True)))defit_creates_new_key_value(self,initial_key_value_ids,json):expected=initial_key_value_ids|{json['id']}actual=set(KeyValue.objects.values_list('id',flat=True))assertexpected==actualdefit_sets_expected_attrs(self,data,json):key_value=KeyValue.objects.get(pk=json['id'])expected=dataassert_model_attrs(key_value,expected)defit_returns_key_value(self,json):key_value=KeyValue.objects.get(pk=json['id'])expected=express_key_value(key_value)actual=jsonassertexpected==actualclassDescribeRetrieve(UsesGetMethod,UsesDetailEndpoint,Returns200,):key_value=lambda_fixture(lambda:KeyValue.objects.create(key='monty',value='jython',))defit_returns_key_value(self,key_value,json):expected=express_key_value(key_value)actual=jsonassertexpected==actualclassDescribeUpdate(UsesPatchMethod,UsesDetailEndpoint,Returns200,):key_value=lambda_fixture(lambda:KeyValue.objects.create(key='pipenv',value='was a huge leap forward',))data=static_fixture({'key':'buuut poetry','value':'locks quicker and i like that',})defit_sets_expected_attrs(self,data,key_value):# We must tell Django to grab fresh data from the database, or we'll# see our stale initial data and think our endpoint is broken!key_value.refresh_from_db()expected=dataassert_model_attrs(key_value,expected)defit_returns_key_value(self,key_value,json):key_value.refresh_from_db()expected=express_key_value(key_value)actual=jsonassertexpected==actualclassDescribeDestroy(UsesDeleteMethod,UsesDetailEndpoint,Returns204,):key_value=lambda_fixture(lambda:KeyValue.objects.create(key='i love',value='YOU',))initial_key_value_ids=precondition_fixture(lambdakey_value:# ensure our to-be-deleted KeyValue exists in our setset(KeyValue.objects.values_list('id',flat=True)))defit_deletes_key_value(self,initial_key_value_ids,key_value):expected=initial_key_value_ids-{key_value.id}actual=set(KeyValue.objects.values_list('id',flat=True))assertexpected==actual