“投票练习”笔记

写在最前

这是我最近学习Django2.1官文的一些笔记,其中有很多细节确实自己之前没有遇见过,与大家共同学习。
官网链接

模型(Model)设计

import datetime
from django.db import models
from django.utils import timezone

class Question(models.Model):
    question_text = models.TextField(verbose_name="问题描述")
    pub_date = models.DateField(verbose_name="发布日期")

    def __str__(self):
        return self.question_text
	
    # 是否是近期发布的——这样有个bug~后面测试会说明
    #def was_published_recently(self):
    #    return self.pub_date >= timezone.now() - datetime.timedelta(days=1)
	
    # 正确的was_published_recently的写法
    def was_published_recently(self):
        now = timezone.now()
        # 让它只在日期是过去式的时候才返回 True
        return now - datetime.timedelta(days=1) <= self.pub_date <= now
    

class Choice(models.Model):
    choice_text = models.CharField(max_length=222,verbose_name="选项")
    votes = models.IntegerField(verbose_name="得票数")
    question = models.ForeignKey(to=Question, verbose_name='所属问题', on_delete=models.CASCADE)

    def __str__(self):
        return self.choice_text

路由系统

路由分发的具体过程

现在让我们向 polls/views.py 里添加更多视图。这些视图有一些不同,因为他们接收参数:
polls/views.py

def detail(request, question_id):
    return HttpResponse("You're looking at question %s." % question_id)

def results(request, question_id):
    response = "You're looking at the results of question %s."
    return HttpResponse(response % question_id)

def vote(request, question_id):
    return HttpResponse("You're voting on question %s." % question_id)

把这些新视图添加进 polls.urls 模块里,只要添加几个 url()函数调用就行:
polls/urls.py

from django.urls import path

from . import views

urlpatterns = [
    # ex: /polls/
    path('', views.index, name='index'),
    # ex: /polls/5/
    path('<int:question_id>/', views.detail, name='detail'),
    # ex: /polls/5/results/
    path('<int:question_id>/results/', views.results, name='results'),
    # ex: /polls/5/vote/
    path('<int:question_id>/vote/', views.vote, name='vote'),
]

然后看看你的浏览器,如果你转到 "/polls/34/" ,Django 将会运行 detail() 方法并且展示你在 URL 里提供的问题 ID。再试试 "/polls/34/vote/" 和 "/polls/34/vote/" ——你将会看到暂时用于占位的结果和投票页。
当某人请求你网站的某一页面时——比如说, "/polls/34/" ,Django 将会载入 mysite.urls 模块,因为这在配置项 ROOT_URLCONF 中设置了!!!

然后 Django 寻找名为 urlpatterns 变量并且按序匹配正则表达式。在找到匹配项 'polls/',它切掉了匹配的文本("polls/"),将剩余文本——"34/",发送至 'polls.urls' URLconf 做进一步处理。在这里剩余文本匹配了 '<int:question_id>/',使得我们 Django 以如下形式调用 detail():

detail(request=<HttpRequest object>, question_id=34)

question_id=34<int:question_id> 匹配生成。使用尖括号“捕获”这部分 URL,且以关键字参数的形式发送给视图函数。上述字符串的 :question_id> 部分定义了将被用于区分匹配模式的变量名,而 int: 则是一个转换器决定了应该以什么变量类型匹配这部分的 URL 路径。

为每个 URL 加上不必要的东西,例如 .html ,是没有必要的。不过如果你非要加的话,也是可以的:

path('polls/latest.html', views.index),

但是,别这样做,这太傻了。

初版路由

from django.urls import path, re_path

from polls import views

# 增加名称空间
app_name = "polls"

urlpatterns = [

    # ex: /polls/
    path('', views.index, name='index'),
    # ex: /polls/5/
    path('<int:question_id>/', views.detail, name='detail'),
    # ex: /polls/5/results/
    path('<int:question_id>/results/', views.results, name='results'),
    # ex: /polls/5/vote/
    path('<int:question_id>/vote/', views.vote, name='vote'),
]

视图层

抛出404错误

Http404方法

——注意获取对象的时候要用get方法,不能用filter~否则无法抛出异常!

from django.shortcuts import render,HttpResponse,Http404

from . import models

# 如果文章不存在则返回一个404页面
def year_article(request,year):
    try:
        # 这里必须用get方法
        article = models.Article.objects.get(pub_date__year=year)
        print(article)
    except models.Article.DoesNotExist:
        raise Http404('文章不存在')
    return HttpResponse(year)

快捷函数get_list_or_404()

尝试用 get() 函数获取一个对象,如果不存在就抛出 Http404 错误也是一个普遍的流程。Django 也提供了一个快捷函数,下面是修改后的视图代码:

from django.shortcuts import render,get_list_or_404

from . import models

# 如果文章不存在则返回一个404页面
def year_article(request,year):
    article = get_list_or_404(models.Article,pub_date__year=year)
    return HttpResponse(year)

get_list_or_404()函数,就是按照第二个参数为条件filter过滤第一个参数的Model对象,如果存在的话返回一个QuerySet对象,如果不存在就返回404错误页面!

快捷函数get_object_or_404()

官方文档是这样说明的:

The get_object_or_404() function takes a Django model as its first argument and an arbitrary number of keyword arguments, which it passes to the get() function of the model's manager. It raises Http404 if the object doesn't exist.

需要特别注意一点:get()方法查询出来的对象的数量必须有且只有一个!多一个会有异常~没有也会有异常!

——所以实际中最好用get()方法根据pk去获取值!因为pk是唯一的,这样不会出现查出多个对象的情况!

def detail(request, question_id):
    # 如果能查到的话返回最后的信息,查不到的话返回404错误页面
    obj = get_object_or_404(models.Question,pk=question_id)

    return HttpResponse("You're looking at question %s." % question_id)


def results(request, question_id):
    # 如果能查到的话返回最后的信息,查不到的话返回404错误页面
    obj = get_object_or_404(models.Question,pk=question_id)

    response = "You're looking at the results of question %s."
    return HttpResponse(response % question_id)


def vote(request, question_id):
    # 如果能查到的话返回最后的信息,查不到的话返回404错误页面
    obj = get_object_or_404(models.Question,pk=question_id)

    return HttpResponse("You're voting on question %s." % question_id)

设计哲学

为什么我们使用辅助函数 get_object_or_404() 而不是自己捕获 ObjectDoesNotExist 异常呢?还有,为什么模型 API 不直接抛出 ObjectDoesNotExist 而是抛出 Http404 呢?

因为这样做会增加模型层和视图层的耦合性。指导 Django 设计的最重要的思想之一就是要保证松散耦合。一些受控的耦合将会被包含在 django.shortcuts 模块中。

“竞争条件”的问题

投票操作如果“并发”执行会出现“竞争条件”的问题:

# 投票操作的视图函数
def vote(request, question_id):
    """
    有一个"竞争条件"的问题——需要用F查询解决
    """
    # 如果能查到的话返回最后的信息,查不到的话返回404错误页面
    question_obj = get_object_or_404(models.Question, pk=question_id)
    # 如果前端没有勾选choice就返回错误信息
    try:
        # 这里从request.POST中取值也可以用get方法,就不用做异常判断了
        selected_choice = question_obj.choice_set.get(pk=request.POST['choice'])
    except (KeyError,models.Choice.DoesNotExist):
        # 返回同一个页面~并且携带错误信息!
        return render(request,'polls/detail.html',{'question_obj':question_obj,'error_msg':"you don't select a choice."})
    else:
        # 这样直接加的话~会出现“race conditions”
        selected_choice.votes += 1
        # 对象——最后得save一下!
        selected_choice.save()
        # 重定向~需要的的参数放在元组中~注意加逗号!
        return HttpResponseRedirect(reverse("polls:results",args=(question_id,)))

问题注解

我们的 vote() 视图代码有一个小问题。代码首先从数据库中获取了 selected_choice 对象,接着计算 vote 的新值,最后把值存回数据库。如果网站有两个方可同时投票在 同一时间 ,可能会导致问题。

同样的值,42,会被 votes 返回。然后,对于两个用户,新值43计算完毕,并被保存,但是期望值是44。

这个问题被称为 竞争条件 。如果你对此有兴趣,你可以阅读 Avoiding race conditions using F()来学习如何解决这个问题:

用F()方法避免竞争条件问题

先看一下官方文档中F查询的用法:

from django.db.models import F

reporter = Reporters.objects.get(name='Tintin')
reporter.stories_filed = F('stories_filed') + 1
reporter.save()

用F方法避免竞争条件问题:

# 用F方法避免竞争条件问题
Avoiding race conditions using F()

# F()方法的另外一个优势就是操作数据库去修改数据以避免竞争条件,而不是用Python方法带来这种问题
Another useful benefit of F() is that having the database - rather than Python - update a field's value avoids a race condition.

# 如果两个Python线程执行上面这段代码(selected_choice.votes += 1),其中一个线程可以查询、增加数据,并且在另外一个线程查询完数据库中的数据之后保存一个字段的数据。但是第二个线程保存数据时仍然会在原数据的基础上进行(实际上第一个线程已经对数据进行了加1操作),这样的话第一个线程保存的数据就会丢失。
If two Python threads execute the code in the first example above, one thread could retrieve, increment, and save a field's value after the other has retrieved it from the database. The value that the second thread saves will be based on the original value; the work of the first thread will simply be lost.

# 如果数据库对修改这个字段的数据作出响应,那么这个进程是十分稳固的:数据库只会根据数据库中字段的的值执行保存或者修改的操作,而不是根据Python的ORM检索出来的实例的value值操作。
If the database is responsible for updating the field, the process is more robust: it will only ever update the field based on the value of the field in the database when the save() or update() is executed, rather than based on its value when the instance was retrieved.

解决的方法如下:

else:
	# F查询解决"竞争条件"
	selected_choice.votes = F("votes") + 1
	# 对象——最后得save一下!
	selected_choice.save()
	# 重定向~需要的的参数放在元组中~注意加逗号!
	# 也可以用 return redirect(reverse("polls:results",args=(question_id,)))
	return HttpResponseRedirect(reverse("polls:results", args=(question_id,)))

初版视图

from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse

from . import models


# 问题展示页面
def index(request):
    latest_question_list = models.Question.objects.order_by('-pub_date')[:5]
    # output = ';\t'.join([q.question_text for q in latest_question_list ])
    return render(request, 'polls/index.html', {'latest_question_list': latest_question_list})


# 单个问题的详情
def detail(request, question_id):
    # 如果能查到的话返回最后的信息,查不到的话返回404错误页面
    question_obj = get_object_or_404(models.Question, pk=question_id)

    return render(request, 'polls/detail.html', {"question_obj": question_obj})


# 结果展示的页面
def results(request, question_id):
    # 如果能查到的话返回最后的信息,查不到的话返回404错误页面
    question_obj = get_object_or_404(models.Question, pk=question_id)

    return render(request,'polls/results.html',{"question_obj":question_obj})


# 投票操作的视图函数
def vote(request, question_id):
    """
    有一个"竞争条件"的问题——需要用F查询解决
    """
    # 如果能查到的话返回最后的信息,查不到的话返回404错误页面
    question_obj = get_object_or_404(models.Question, pk=question_id)
    # 如果前端没有勾选choice就返回错误信息
    try:
        # 这里从request.POST中取值也可以用get方法,就不用做异常判断了
        selected_choice = question_obj.choice_set.get(pk=request.POST['choice'])
    except (KeyError,models.Choice.DoesNotExist):
        # 返回同一个页面~并且携带错误信息!
        return render(request,'polls/detail.html',{'question_obj':question_obj,'error_msg':"you don't select a choice."})
    else:
        selected_choice.votes += 1
        # 对象——最后得save一下!
        selected_choice.save()
        # 重定向~需要的的参数放在元组中~注意加逗号!
        return HttpResponseRedirect(reverse("polls:results",args=(question_id,)))

“通用视图” ***

detail() (在 教程第3部分 中)和 results() 视图都很简单 —— 并且,像上面提到的那样,存在冗余问题。用来显示一个投票列表的 index() 视图(也在 教程第 3 部分 中)和它们类似。

这些视图反映基本的 Web 开发中的一个常见情况:根据 URL 中的参数从数据库中获取数据、载入模板文件然后返回渲染后的模板。 由于这种情况特别常见,Django 提供一种快捷方式,叫做“通用视图”系统。

通用视图将常见的模式抽象化,可以使你在编写应用时甚至不需要编写Python代码。

让我们将我们的投票应用转换成使用通用视图系统,这样我们可以删除许多我们的代码。我们仅仅需要做以下几步来完成转换,我们将:

  1. 转换 URLconf。
  2. 删除一些旧的、不再需要的视图。
  3. 基于 Django 的通用视图引入新的视图。

请继续阅读来了解详细信息。

为什么要重构代码?

一般来说,当编写一个 Django 应用时,你应该先评估一下通用视图是否可以解决你的问题,你应该在一开始使用它,而不是进行到一半时重构代码。本教程目前为止是有意将重点放在以“艰难的方式”编写视图,这是为将重点放在核心概念上。

就像在使用计算器之前你需要掌握基础数学一样。

改良URLconf ***

首先,打开 polls/urls.py 这个 URLconf 并将它修改成:

from django.urls import path

from . import views

app_name = 'polls'
urlpatterns = [
    path('', views.IndexView.as_view(), name='index'),
    path('<int:pk>/', views.DetailView.as_view(), name='detail'),
    path('<int:pk>/results/', views.ResultsView.as_view(), name='results'),
    path('<int:question_id>/vote/', views.vote, name='vote'),
]

注意,第二个和第三个匹配准则中,路径字符串中匹配模式的名称已经由 <question_id> 改为 <pk>

改良视图(视图第二版)及具体说明 ***

下一步,我们将删除旧的 indexdetail, 和 results 视图,并用 Django 的通用视图代替。打开 polls/views.py 文件,并将它修改成:

from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.views import generic

from . import models


class IndexView(generic.ListView):
    template_name = "polls/index.html"
    # 传给前端模板的对象名称~ListView需要用get_queryset方法获取
    context_object_name = "latest_question_list"

    def get_queryset(self):
        """
        返回15条数据~
        """
        ret =  models.Question.objects.order_by("-pub_date")[:15]
        return ret

class DetailView(generic.DetailView):
    model = models.Question
    template_name = "polls/detail.html"
    # 传给前端模板的对象名称~DetailView自己根据URLconf中的pk值获取对象
    context_object_name = "question_obj"

class ResultsView(generic.DetailView):
    model = models.Question
    template_name = "polls/results.html"
    # 传给前端模板的对象名称~DetailView自己根据URLconf中的pk值获取对象
    context_object_name = "question_obj"


def vote(request, question_id):
    ... # same as above, no changes needed.

说明:

我们在这里使用两个通用视图: ListViewDetailView 。这两个视图分别抽象“显示一个对象列表”和“显示一个特定类型对象的详细信息页面”这两种概念。

  • 每个通用视图需要知道它将作用于哪个模型。 这由 model 属性提供。
  • DetailView期望从 URL 中捕获名为 "pk" 的主键值,所以我们为通用视图把 question_id 改成 pk

默认情况下,通用视图DetailView使用一个叫做 <app name>/<model name>_detail.html 的模板。在我们的例子中,它将使用 "polls/question_detail.html" 模板。template_name 属性是用来告诉 Django 使用一个指定的模板名字,而不是自动生成的默认名字。 我们也为 results 列表视图指定了 template_name —— 这确保 results 视图和 detail 视图在渲染时具有不同的外观,即使它们在后台都是同一个 DetailView

类似地,ListView 使用一个叫做 <app name>/<model name>_list.html 的默认模板;我们使用 template_name 来告诉 ListView 使用我们创建的已经存在的 "polls/index.html" 模板。

在之前的教程中,提供模板文件时都带有一个包含 questionlatest_question_list 变量的 context。对于 DetailViewquestion 变量会自动提供—— 因为我们使用 Django 的模型 (Question), Django 能够为 context 变量决定一个合适的名字。

然而对于 ListView, 自动生成的 context 变量是 question_list。为了覆盖这个行为,我们提供 context_object_name 属性,表示我们想使用 latest_question_list。作为一种替换方案,你可以改变你的模板来匹配新的 context 变量 —— 这是一种更便捷的方法,告诉 Django 使用你想使用的变量名。

启动服务器,使用一下基于通用视图的新投票应用。

**更多关于通用视图的详细信息,请查看 通用视图的文档**

模板层

模板渲染的实例—基于对象的跨表查询

假设视图函数中找到了Question对象,那么在模版中可以这样渲染

<h1>{{ question.question_text }}</h1>
<ul>
{% for choice in question.choice_set.all %}
    <li>{{ choice.choice_text }}</li>
{% endfor %}
</ul>

——基于对象的跨表查询,反向查询按表名小写_set()

模板系统统一使用点符号来访问变量的属性。在示例 {{ question.question_text }} 中,首先 Django 尝试对 question 对象使用字典查找(也就是使用 obj.get(str) 操作),如果失败了就尝试属性查找(也就是 obj.str 操作),结果是成功了。如果这一操作也失败的话,将会尝试列表查找(也就是 obj[int] 操作)。

{% for %}循环中发生的函数调用:question.choice_set.all 被解释为 Python 代码 question.choice_set.all(),将会返回一个可迭代的 Choice 对象,这一对象可以在 {% for %}标签内部使用。

去除模版中的硬编码URL

链接是硬编码的:

<li><a href="/polls/{{ question.id }}/">{{ question.question_text }}</a></li>

问题在于,硬编码和强耦合的链接,对于一个包含很多应用的项目来说,修改起来是十分困难的。然而,因为你在 polls.urls 的 url() 函数中通过 name 参数为 URL 定义了名字,你可以使用 {% url %} 标签代替它:

<li><a href="{% url 'detail' question.id %}">{{ question.question_text }}</a></li>

这里的detail是路由的“别名”

为URL添加命名空间

教程项目只有一个应用,polls 。在一个真实的 Django 项目中,可能会有五个,十个,二十个,甚至更多应用。Django 如何分辨重名的 URL 呢?举个例子,polls 应用有 detail 视图,可能另一个博客应用也有同名的视图。Django 如何知道 {% url %} 标签到底对应哪一个应用的 URL 呢?

答案是:在根 URLconf 中添加命名空间。在 polls/urls.py 文件中稍作修改,加上 app_name 设置命名空间:

from django.urls import path

from . import views

# 为分发的URL添加名称空间
app_name = 'polls'
urlpatterns = [
    path('', views.index, name='index'),
    path('<int:question_id>/', views.detail, name='detail'),
    path('<int:question_id>/results/', views.results, name='results'),
    path('<int:question_id>/vote/', views.vote, name='vote'),
]

现在,编辑 polls/index.html 文件,从:

<li><a href="{% url 'detail' question.id %}">{{ question.question_text }}</a></li>

修改为指向具有命名空间的详细视图:

<li><a href="{% url 'polls:detail' question.id %}">{{ question.question_text }}</a></li>

最终的展示页面

index页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Index</title>
</head>
<body>
{% if latest_question_list %}
    <ul>
        {% for question in latest_question_list %}
            <li>
                <a href="{% url 'polls:detail' question.pk %}">{{ question.question_text }}</a>
            </li>
        {% endfor %}
    </ul>

{% else %}
    <p>No Polls are available</p>
{% endif %}


</body>
</html>

results页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Results</title>
</head>
<body>
<h1>{{ question_obj.question_text }}</h1>

<ul>
    {% for choice in question_obj.choice_set.all %}
        <li>{{ choice.choice_text }} -- {{ choice.votes }}vote{{ choice.votes|pluralize }}</li>
    {% endfor %}
</ul>

<a href="{% url 'polls:detail' question_obj.pk %}">Vote again?</a>

</body>
</html>

Form(表单)

detail页面进行投票

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Detail</title>
</head>
<body>
<h1>{{ question_obj.question_text }}</h1>

{% if error_msg %}
    <p><strong>{{ error_msg }}</strong></p>
{% endif %}

<form action="{% url 'polls:vote' question_obj.pk %}" method="post">
    {% csrf_token %}
    {# 基于对象的反向跨表查询 #}
    {% for choice in question_obj.choice_set.all %}
        <input type="radio" name="choice" id="choice{{ forloop.counter }}" value="{{ choice.id }}">
        <label for="choice{{ forloop.counter }}">{{ choice.choice_text }}</label>
    {% endfor %}

    <input type="submit" value="Vote">
</form>


</body>
</html>

创建测试

发布时间的一个bug

在上面的Model中如果was_published_recently()方法这样写会有问题:

def was_published_recently(self):
	return self.pub_date >= timezone.now() - datetime.timedelta(days=1)

1、我们的要求是如果 Question 是在一天之内发布的, Question.was_published_recently() 方法将会返回 True ,然而现在这个方法在 Question 的 pub_date 字段比当前时间还晚时也会返回 True(这是个 Bug)。

2、在manage.py同级的目录下新创建一个能调用django环境的外部脚本:test_timedelay.py

# -*- coding:utf-8 -*-
import os

if __name__ == '__main__':
    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'DjangoJourney.settings')
    import django
    django.setup()

    import datetime
    from django.utils import timezone
    from polls import models
    # 延后30天发布
    future_question = models.Question(question_text='选我选我',pub_date=timezone.now()+datetime.timedelta(days=20))
    # 但是was_published_recently()方法依然打印True
    is_recently = future_question.was_published_recently()
    print(is_recently)

这时返回的是True,明显不符合我们设计的思路~~

创建测试来暴露这个bug

我们可以看到,Django的每个应用中都有一个tests.py文件,这就是我们写自动化测试代码的地方。

polls应用中的tests.py文件中加入我们的自动化测试代码:

import datetime
from django.test import TestCase
from django.utils import timezone

from .models import Question


class QuestionModelTests(TestCase):
    # 注意方法名必须以test开头!
    def test_was_published_recently_with_future(self):
        """
        测试是否当pub_date超过一天~was_published_recently方法返回False
        """
        future_time = timezone.now() + datetime.timedelta(days=30)
        future_question = Question(pub_date=future_time)

        self.assertIs(future_question.was_published_recently(),False)

发生了什么呢?以下是自动化测试的运行过程:

  • python manage.py test polls 将会寻找 polls 应用里的测试代码
  • 它找到了 django.test.TestCase的一个子类
  • 它创建一个特殊的数据库供测试使用
  • 它在类中寻找测试方法——以 test 开头的方法。
  • test_was_published_recently_with_future_question 方法中,它创建了一个 pub_date 值为 30 天后的 Question 实例。
  • 接着使用 assertls() 方法,发现 was_published_recently() 返回了 True,而我们期望它返回 False。

运行程序会抛出下面的异常:

AssertionError: True is not False

测试系统通知我们哪些测试样例失败了,和造成测试失败的代码所在的行号。

修复这个bug

我们早已知道,当 pub_date 为未来某天时, Question.was_published_recently() 应该返回 False。我们修改 models.py 里的方法,让它只在日期是过去式的时候才返回 True

def was_published_recently(self):
    now = timezone.now()
    return now - datetime.timedelta(days=1) <= self.pub_date <= now

重新运行测试正常~

发现 bug 后,我们编写了能够暴露这个 bug 的自动化测试。在修复 bug 之后,我们的代码顺利的通过了测试。

将来,我们的应用可能会出现其他的问题,但是我们可以肯定的是,一定不会再次出现这个 bug,因为只要简单的运行一遍测试,就会立刻收到警告。我们可以认为应用的这一小部分代码永远是安全的。

更全面的测试——完整的测试代码

polls/tests.py文件中的测试类新增两个测试方法,使得 Question.was_published_recently() 方法对于过去,最近,和将来的三种情况都返回正确的值。

import datetime
from django.test import TestCase
from django.utils import timezone

from .models import Question

# 测试类,继承TestCase
class QuestionModelTests(TestCase):
    
    # 注意方法名必须以test开头!
    def test_was_published_recently_with_future(self):
        """
        当pub_date在未来某天~was_published_recently方法返回False
        """
        future_time = timezone.now() + datetime.timedelta(days=30)
        future_question = Question(pub_date=future_time)

        self.assertIs(future_question.was_published_recently(),False)

    def was_published_recently_with_old_question(self):
        """
        当pub_date超过当前时间一天的时候was_published_recently()方法返回False
        """
        old_time = timezone.now() - datetime.timedelta(days=1,seconds=1)
        old_question = Question(pub_date=old_time)
        self.assertIs(old_question.was_published_recently(),False)

    def test_was_published_recently_with_recent_question(self):
        """
        当pub_date在最近一天,was_published_recently()方法返回True
        """
        recent_time = timezone.now() - datetime.timedelta(hours=23,minutes=59,seconds=59)
        recent_question = Question(pub_date=recent_time)
        self.assertIs(recent_question.was_published_recently(),True)

再次申明,尽管 polls 现在是个非常简单的应用,但是无论它以后成长到多么复杂,要和其他代码进行怎样的交互,我们都能保证进行过测试的那些方法的行为永远是符合预期的。

测试视图 ***

我们的投票应用对所有问题都一视同仁:它将会发布所有的问题,也包括那些 pub_date 字段值是未来的问题。我们应该改善这一点。如果 pub_date 设置为未来某天,这应该被解释为这个问题将在所填写的时间点才被发布,而在之前是不可见的。

针对视图的测试

为了修复上述 bug ,我们这次先编写测试,然后再去改代码。事实上,这是一个简单的「测试驱动」开发模式的实例,但其实这两者的顺序不太重要。
在我们的第一个测试中,我们关注代码的内部行为。我们通过模拟用户使用浏览器访问被测试的应用来检查代码行为是否符合预期。
在我们动手之前,先看看需要用到的工具们。

Django 测试工具之 Client ***

Django 提供了一个供测试使用的 Client来模拟用户和视图层代码的交互。我们能在 tests.py 甚至是 shell中使用它。
我们依照惯例从 shell 开始,首先我们要做一些在 tests.py 里不是必须的准备工作。
官方文档用的是shell做的,我这里利用外部脚本调用Django环境实现:
我这里在与manage.py同级目录下新建了一个test_client.py文件,里面的内容如下:

# -*- coding:utf-8 -*-
import os


if __name__ == '__main__':
    # 外部脚本调用Django环境
    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'DjangoJourney.settings')
    import django
    django.setup()
    # 导入Django测试需要的模块
    from django.test.utils import setup_test_environment
    from django.test import Client

    #setup_test_environment() 提供了一个模板渲染器,允许我们为 responses 添加一些额外的属性~例如 response.context
    # 未安装此 app 无法使用此功能。
    setup_test_environment()
    # 实例化一个Client对象
    client = Client()

    # 开始测试工作
    response1 = client.get('/')  # Not Found: /
    print(response1.status_code) # 404

    response2 = client.get('/polls')
    print(response2.status_code) # 301

    from django.urls import reverse
    response3 = client.get(reverse("polls:index"))
    print(response3.status_code) # 200
    #print(response3.content) # 返回二进制格式的index的页面内容
    print(response3.context['latest_question_list'])#<QuerySet [<Question: 选举最强王者>, <Question: 选举最强嘴遁>]>

说明如下:
1、setup_test_environment() 提供了一个模板渲染器,允许我们为 responses 添加一些额外的属性,例如 response.context,未安装此 app 无法使用此功能。

2、注意,这个方法并不会配置测试数据库,所以接下来的代码将会在当前存在的数据库上运行,输出的内容可能由于数据库内容的不同而不同。**

3、如果你的 settings.py 中关于 TIME_ZONE 的设置不对,你可能无法获取到期望的结果。如果你之前忘了设置,在继续之前检查一下。

4、然后我们需要导入 django.test.TestCase 类(在后续 tests.py 的实例中我们将会使用 django.test.TestCase 类,这个类里包含了自己的 client 实例,所以不需要这一步):

改善Index视图代码(index的视图第三版)

现在的投票列表会显示将来的投票( pub_date 值是未来的某天)。我们来修复这个问题。

我们需要改进 get_queryset() 方法,让他它能通过将 Question 的 pub_data 属性与 timezone.now() 相比较来判断是否应该显示此 Question。

首先我们需要一行 import 语句:

from django.utils import timezone

然后我们把IndexView类的 get_queryset 方法改写成下面这样:

class IndexView(generic.ListView):
    template_name = "polls/index.html"
    # 传给前端模板的对象名称~ListView需要用get_queryset方法获取
    context_object_name = "latest_question_list"

    def get_queryset(self):
        """
        返回15条数据~仅包含pub_date小于等于当时的时间的数据
        需要设置一下 TIME_ZONE !!!
        """
        ret =  models.Question.objects.filter(pub_date__lte=timezone.now()).order_by("-pub_date")[:15]
        return ret

测试ListView视图

启动服务器、在浏览器中载入站点、创建一些发布时间在过去和将来的 Questions ,然后检验只有已经发布的 Questions 会展示出来,现在你可以对自己感到满意了。
你不想每次修改可能与这相关的代码时都重复这样做 —— 所以让我们需要再编写一个测试。*
将下面的代码添加到 polls/tests.py :

from django.urls import reverse

然后我们写一个公用的快捷函数用于创建投票问题,再为视图创建一个测试类:

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


class QuestionIndexViewTests(TestCase):
    def test_no_questions(self):
        """
        If no questions exist, an appropriate message is displayed.
        """
        response = self.client.get(reverse('polls:index'))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "No polls are available.")
        self.assertQuerysetEqual(response.context['latest_question_list'], [])

    def test_past_question(self):
        """
        Questions with a pub_date in the past are displayed on the
        index page.
        """
        create_question(question_text="Past question.", days=-30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past question.>']
        )

    def test_future_question(self):
        """
        Questions with a pub_date in the future aren't displayed on
        the index page.
        """
        create_question(question_text="Future question.", days=30)
        response = self.client.get(reverse('polls:index'))
        self.assertContains(response, "No polls are available.")
        self.assertQuerysetEqual(response.context['latest_question_list'], [])

    def test_future_question_and_past_question(self):
        """
        Even if both past and future questions exist, only past questions
        are displayed.
        """
        create_question(question_text="Past question.", days=-30)
        create_question(question_text="Future question.", days=30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past question.>']
        )

    def test_two_past_questions(self):
        """
        The questions index page may display multiple questions.
        """
        create_question(question_text="Past question 1.", days=-30)
        create_question(question_text="Past question 2.", days=-5)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past question 2.>', '<Question: Past question 1.>']
        )

让我们更详细地看下以上这些内容。
1、首先是一个快捷函数 create_question,它封装了创建投票的流程,减少了重复代码。

2、test_no_questions 方法里没有创建任何投票,它检查返回的网页上有没有 "No polls are available." 这段消息和 latest_question_list 是否为空。

3、注意到 django.test.TestCase类提供了一些额外的 assertion 方法,在这个例子中,我们使用了 assertContains()assertQuerysetEqual()

4、test_past_question 方法中,我们创建了一个投票并检查它是否出现在列表中。

5、test_future_question 中,我们创建 pub_date 在未来某天的投票。

6、数据库会在每次调用测试方法前被重置,所以第一个投票已经没了,所以主页中应该没有任何投票。
剩下的那些也都差不多。
实际上,测试就是假装一些管理员的输入,然后通过用户端的表现是否符合预期来判断新加入的改变是否破坏了原有的系统状态。

改善Detail与Results视图代码(Detail与Results视图第三版)

我们的工作似乎已经很完美了?
不,还有一个问题:就算在发布日期时未来的那些投票不会在目录页 index 里出现,但是如果用户知道或者猜到正确的 URL ,还是可以访问到它们。
所以我们得在 DetailView 里增加一些约束:

class DetailView(generic.DetailView):
    model = models.Question
    template_name = "polls/detail.html"
    # 传给前端模板的对象名称~DetailView自己根据URLconf中的pk值获取对象
    context_object_name = "question_obj"

    def get_queryset(self):
        """
        一开始根据pk找到的Question对象有可能是future没有发布的对象~
        排除没有发布的Question,只显示已经发布的Question
        """
        return models.Question.objects.filter(pub_date__lte=timezone.now())


class ResultsView(generic.DetailView):
    model = models.Question
    template_name = "polls/results.html"
    # 传给前端模板的对象名称~DetailView自己根据URLconf中的pk值获取对象
    context_object_name = "question_obj"

    def get_queryset(self):
        """
        一开始根据pk找到的Question对象有可能是future没有发布的对象~
        排除没有发布的Question,只显示已经发布的Question
        """
        return models.Question.objects.filter(pub_date__lte=timezone.now())

测试DetailView视图

当然,我们将增加一些测试来检验 pub_date 在过去的 Question 可以显示出来,而 pub_date 在未来的不可以:
polls/tests.py中继续增加测试类:

# pub_date 在未来的数据不可以显示出来                                               
class QuestionDetailViewTests(TestCase):                               
    def test_future_question(self):                                    
        """                                                            
        detail视图如果收到一个pub_date在未来的question的pk返回一个404错误                 
        """                                                            
        future_question = create_question(question_text='Future Questio
        url = reverse("polls:detail" ,args=(future_question.pk,))      
        response = self.client.get(url)                                
        self.assertEqual(response.status_code,404)                     
                                                                       
    def test_past_question(self):                                      
        """                                                            
        在当天时间之前的Question对象展示它的text
        """                                                            
        past_question = create_question(question_text="Past Question",d
        url = reverse("polls:detail",args=(past_question.pk,))         
        response = self.client.get(url)                                
        self.assertContains(response,past_question.question_text)      

更多的测试思路

我们应该给 ResultsView 也增加一个类似的 get_queryset 方法,并且为它创建测试。这和我们之前干的差不多,事实上,基本就是重复一遍。

我们还可以从各个方面改进投票应用,但是测试会一直伴随我们。比方说,在目录页上显示一个没有选项 Choices 的投票问题就没什么意义。我们可以检查并排除这样的投票题。测试可以创建一个没有选项的投票,然后检查它是否被显示在目录上。当然也要创建一个有选项的投票,然后确认它确实被显示了。

恩,也许你想让管理员能在目录上看见未被发布的那些投票,但是普通用户看不到。不管怎么说,如果你想要增加一个新功能,那么同时一定要为它编写测试。不过你是先写代码还是先写测试那就随你了。

在未来的某个时刻,你一定会去查看测试代码,然后开始怀疑:「这么多的测试不会使代码越来越复杂吗?」。别着急,我们马上就会谈到这一点。

当需要测试的时候,测试用例越多越好

貌似我们的测试多的快要失去控制了。按照这样发展下去,测试代码就要变得比应用的实际代码还要多了。而且测试代码大多都是重复且不优雅的,特别是在和业务代码比起来的时候,这种感觉更加明显。

但是这没关系! 就让测试代码继续肆意增长吧。大部分情况下,你写完一个测试之后就可以忘掉它了。在你继续开发的过程中,它会一直默默无闻地为你做贡献的。

但有时测试也需要更新。想象一下如果我们修改了视图,只显示有选项的那些投票,那么只前写的很多测试就都会失败。但这也明确地告诉了我们哪些测试需要被更新,所以测试也会测试自己。

最坏的情况是,当你继续开发的时候,发现之前的一些测试现在看来是多余的。但是这也不是什么问题,多做些测试也 不错

如果你对测试有个整体规划,那么它们就几乎不会变得混乱。下面有几条好的建议:

  • 对于每个模型和视图都建立单独的 TestClass
  • 每个测试方法只测试一个功能
  • 给每个测试方法起个能描述其功能的名字

深入代码测试

在本教程中,我们仅仅是了解了测试的基础知识。你能做的还有很多,而且世界上有很多有用的工具来帮你完成这些有意义的事。

举个例子,在上述的测试中,我们已经从代码逻辑和视图响应的角度检查了应用的输出,现在你可以从一个更加 "in-browser" 的角度来检查最终渲染出的 HTML 是否符合预期,使用 Selenium 可以很轻松的完成这件事。这个工具不仅可以测试 Django 框架里的代码,还可以检查其他部分,比如说你的 JavaScript。它假装成是一个正在和你站点进行交互的浏览器,就好像有个真人在访问网站一样!Django 它提供了 LiveServerTestCase来和 Selenium 这样的工具进行交互。

如果你在开发一个很复杂的应用的话,你也许想在每次提交代码时自动运行测试,也就是我们所说的持续集成 [continuous integration] ,这样就能实现质量控制的自动化,起码是部分自动化。

一个找出代码中未被测试部分的方法是检查代码覆盖率。它有助于找出代码中的薄弱部分和无用部分。如果你无法测试一段代码,通常说明这段代码需要被重构或者删除。想知道代码覆盖率和无用代码的详细信息,查看文档 [Integration with coverage.py]获取详细信息。

文档 Django 中的测试 里有关于测试的更多信息。

定制admin页面

admin页面的定制代码如下:

admin.py:

from django.contrib import admin

from .models import Question,Choice


# 在你创建“投票”对象时直接添加好几个选项
# 比较占地方
class ChoiceInline(admin.StackedInline):
    # 指定model
    model = Choice
    extra = 3

# 创建“投票”对象时~紧凑式的批量添加选项
class ChoiceInlineTab(admin.TabularInline):
    model = Choice
    extra = 3



class QuestionAdmin(admin.ModelAdmin):
    # 字段多的话,方便排序
    # pub_date显示在question_text的上面
    fields = ["question_text","pub_date",]
    # 将字段显示在展示页面
    list_display = ["question_text","pub_date","was_published_recently"]

    # 在你创建“投票”对象时直接添加好几个选项
    # 这会告诉 Django:“Choice 对象要在 Question 后台页面编辑
    # 后台会先把已经存在的Choice对象显示出来(可以修改)~并提供3个足够的选项字段供我们填写”
    # inlines = [ChoiceInline]

    # 更加紧凑的表格式的展示
    inlines = [ChoiceInlineTab]

    # 基于日期进行过滤~前提是Question这个Model中定义了相关的属性
    list_filter = ("pub_date",)

    # 添加搜索框~可以有多个条件
    search_fields = ["question_text","pub_date"]

    # 分页相关
    # 总数据——默认是200
    list_max_show_all = 300
    # 每页显示的数据——默认是100
    list_per_page = 3


class ChoiceAdmin(admin.ModelAdmin):

    # fields = ["question","votes","choice_text"]

    list_display = ["choice_text","votes","question"]

    # 分为几个"字段集"
    fieldsets = [
        ("所属问题",{"fields":["question"]}),
        ("得票数",{"fields":["votes"]}),
        ("描述",{"fields":["choice_text"]}),
    ]

    # 添加搜索框
    search_fields = ["choice_text"]

    # 分页相关
    # 总数据——默认是200
    list_max_show_all = 300
    # 每页显示的数据——默认是100
    list_per_page = 5


# 将自定义的类写在后面
admin.site.register(Question,QuestionAdmin)
admin.site.register(Choice,ChoiceAdmin)

posted on 2019-07-30 23:09  江湖乄夜雨  阅读(613)  评论(0编辑  收藏  举报