I’m going through the official Django Tutorial and right now I’m at Part 5, doing Tests. I tried to implement all the suggestions further down:

So let’s get going!

Update the ResultsView to only show past Questions and exlude unpublished/future ones

The first suggestion is merely a repetion of what was already covered in the tutorial for the DetailView adapted to the ResultView.

polls/tests.py:

 1(...)
 2class ResultDetailViewTests(TestCase):
 3    def test_future_question(self):
 4        """
 5        the result page of a question with a future pub_date returns a 404 page
 6        """
 7        future_question = create_question(question_text="future q", days=5)
 8        url = reverse("polls:results", args=(future_question.id,))
 9        response = self.client.get(url)
10
11        self.assertEqual(response.status_code, 404)
12
13    def test_past_question(self):
14        """
15        the result page for a question with pub_date in the past shows the corresponding page
16        """
17        past_question = create_question(question_text="past q", days=-5)
18        url = reverse("polls:results", args=(past_question.id,))
19        response = self.client.get(url)
20
21        self.assertContains(response, past_question.question_text)

in polls/views.py:

1(...)
2class ResultsView(generic.DetailView):
3    model = Question
4    template_name = "polls/results.html"
5
6    def get_queryset(self):
7        return Question.objects.filter(
8            pub_date__lte=timezone.now()
9        )  # excludes future questions

Done!

Check for Questions without Choices and exclude them

This one is a bit trickier, since we have to modify the create_question function, which was added in the tutorial:

1def create_question(question_text, days):
2    """
3    Create a question with the given `question_text` and published the
4    given number of `days` offset to now (negative for questions published
5    in the past, positive for questions that have yet to be published).
6    """
7    time = timezone.now() + datetime.timedelta(days=days)
8    return Question.objects.create(question_text=question_text, pub_date=time)

this function will now include an optional Choice. Therefore we have to import this model and modify the function accordingly. In my first attempt I added the argument to the function with a default of None:

 1#First attempt
 2(...) #existing imports
 3
 4from .models import Question, Choice #added Choice model
 5
 6
 7def create_question(question_text, days, choice=None):
 8    """
 9    Create a question with the given `question_text` and published the
10    given number of `days` offset to now (negative for questions published
11    in the past, positive for questions that have yet to be published).
12    """
13    time = timezone.now() + datetime.timedelta(days=days)
14    question = Question.objects.create(question_text=question_text, pub_date=time)
15
16    if choice:
17        Choice.objects.create(question=question, choice_text="this is a choice", votes=0)
18
19    return question
20
21(...)

Running the tests with this function, breaks various testcases:

 1(...)
 2FAIL: test_future_question_and_past_question (polls.tests.QuestionIndexViewTests)
 3(...)
 4FAIL: test_past_questions (polls.tests.QuestionIndexViewTests)
 5(...)
 6FAIL: test_two_past_questions (polls.tests.QuestionIndexViewTests)
 7(...)
 8AssertionError: Lists differ: [] != [<Question: Past question 2.>, <Question: Past question 1.>]
 9
10Second list contains 2 additional elements.
11First extra element 0:
12<Question: Past question 2.>
13
14- []
15+ [<Question: Past question 2.>, <Question: Past question 1.>]
16(...)

whelp! Many minutes of reading my code passed until I found out why! I had already updated my get_queryset in polls/views.py to exclude all questions without assigned Choice:

 1class IndexView(generic.ListView):
 2    template_name = "polls/index.html"
 3    context_object_name = "latest_question_list"
 4    
 5
 6    def get_queryset(self):
 7        """
 8        Return the last five published questions (not including those set to be
 9        published in the future).
10        """
11        return (
12            Question.objects.exclude(choice__isnull=True)
13            .filter(pub_date__lte=timezone.now())
14            .order_by("-pub_date")[:5]
15        )

So since my new create_question function defaulted to not add a Choice it broke my tests! Now to update and fix my create_question:

 1def create_question(
 2    question_text, days, choice=True):  # choice must default to True, so that tests that don't focus on Choice existence succeed
 3    """
 4    Create a question with the given `question_text` and published the
 5    given number of `days` offset to now (negative for questions published
 6    in the past, positive for questions that have yet to be published).
 7    """
 8    time = timezone.now() + datetime.timedelta(days=days)
 9    question = Question.objects.create(question_text=question_text, pub_date=time)
10
11    if choice == True:
12        Choice.objects.create(
13            question=question, choice_text="this is a choice", votes=0
14        )
15
16    return question

So to accomplish the test testing that Questions whithout Choices are not visible on the IndexView i came up with the following tests:

 1(...)
 2class QuestionIndexViewTests(TestCase):
 3    (...)
 4    def test_question_with_no_choice(self):
 5        """Questions without Choices are not published"""
 6        question_without_choices = create_question(
 7            question_text="Q w/o Choice", days=-1, choice=False
 8        )
 9        response = self.client.get(reverse("polls:index"))
10        self.assertQuerysetEqual(
11            response.context["latest_question_list"],
12            [],
13        )
14
15    def test_question_with_choice(self):
16        """Q with Choices are published"""
17        question_with_choices = create_question(
18            question_text="Q w Choice", days=-1, choice=True
19        )
20        response = self.client.get(reverse("polls:index"))
21        self.assertQuerysetEqual(
22            response.context["latest_question_list"],
23            [question_with_choices],
24        )
25
26    def test_question_with_and_without_choice(self):
27        """only questions with choice are published. those without not."""
28        question_without_choices = create_question(
29            question_text="Q w/o Choice", days=-1, choice=False
30        )
31        question_with_choices = create_question(
32            question_text="Q w/ Choice", days=-1, choice=True
33        )
34
35        response = self.client.get(reverse("polls:index"))
36        self.assertQuerysetEqual(
37            response.context["latest_question_list"],
38            [question_with_choices],
39        )
40(...)

Done!

Logged-in admin users should see unpublished Questions

the instructions are not completly clear about what “unpublished” questions are. I assumed it meant questions with a pub_date in the future (another variation would be that “unpublished” additionally includes Questions without Choices, as in the former suggested tests).

To make this happen, we first have to adapt our IndexView in views.py. I included a simple check for superuser or not. and based on this check create the corresponding return object.

 1(...)
 2
 3class IndexView(generic.ListView):
 4    template_name = "polls/index.html"
 5    context_object_name = "latest_question_list"
 6
 7    def get_queryset(self):
 8
 9        if (
10            self.request.user.is_superuser # this checks if the currently logged in user is a superuser. Important to do it via `**self.**request` as only `request` can not be accessed in class based views.
11        ):  # request in class based views is accessible via `self.request`` instead of `request`
12            return Question.objects.exclude(choice__isnull=True).order_by("-pub_date")[
13                :5
14            ]  # returns published and unpublished questions for superusers
15
16        else:  # returns only published Questions, as user is normal user or not logged in.
17            return (
18                Question.objects.exclude(choice__isnull=True)
19                .filter(pub_date__lte=timezone.now())
20                .order_by("-pub_date")[:5]
21            )
22
23(...)

Since this is mainly about writing tests, we also have to add some tests to our tests.py. In the style of create_question() i defined a create_user() function and used that function to create two separate tests for the QuestionIndexViewTests TestCases.

Right after the existing create_question() function I define the following function create_user():

 1(...)
 2
 3def create_user(username, is_superuser=False, password=None):
 4    """create a user with given username and set superuser to True or False"""
 5    if is_superuser == False:
 6        user = User.objects.create_user(username)
 7    elif is_superuser == True:
 8        user = User.objects.create_superuser(username, password=password)
 9    user.save()
10
11    return user
12
13(...)

Take note of password=None: I initially created the function without this argument, which didn’t work out, but also didn’t give me an error message, which explained why the test failed. The reason is that a superuser absolutely must have a password set. So if we can’t pass the password argument the user is not created and no error thrown at us! This obviously is in the Django docs: create_superuser() must receive a password to work correctly. Another thing that cost me too much time debugging: I forgot to return the user object created. Didn’t work, just return it, please.

Summing up: We can now call this function with just a username and it will return us a normal user. If we set is_superuser to True and pass some password (e.g. “supersecurepassword”) to it, it will return us dutifully a superuser!

Now, let’s put it to work and test our IndexView with the following addiotional tests:

 1(...)
 2class QuestionIndexViewTests(TestCase):
 3    (...)
 4
 5    def test_question_visibility_for_superuser(self):
 6        """unpublished questions are only visible to logged in superusers. For normal users unpublished questions will not be shown"""
 7        self.client = Client()
 8        self.user = create_user("testAdmin", is_superuser=True, password="password")
 9        self.client.force_login(self.user) 
10
11        published_question = create_question(question_text="published Q", days=-1)
12
13        unpublished_question = create_question(question_text="unpublished Q", days=2)
14
15        response = self.client.get(reverse("polls:index"))
16        self.assertQuerysetEqual(
17            response.context["latest_question_list"],
18            [unpublished_question, published_question],
19        )
20
21    def test_question_visibility_for_user(self):
22        """unpublished questions are not visible to normal users."""
23        create_user("testUser")
24        published_question = create_question(question_text="published Q", days=-1)
25
26        unpublished_question = create_question(question_text="unpublished Q", days=2)
27
28        response = self.client.get(reverse("polls:index"))
29        self.assertQuerysetEqual(
30            response.context["latest_question_list"],
31            [published_question],
32        )
33
34(...)

ad self.client.force_login(self.user): Not sure if I absolutely necessarily need to force the login of the created superuser, but I did it just to be sure, after lots of debugging, which actually was caused by the missing password argument I described a bit up.

Done!