乐趣区

Django搭建个人博客自动化测试

测试是伴随着开发进行的,开发有多久,测试就要多久 。本教程已经进行了 30 多章了,都是如何测试的?当然是runserver 啦!每当开发新功能后,都需要运行服务器,假装自己就是用户,测试是否运行正常。

这样的人工测试优点是非常直观,你看到的和用户看到的是完全相同的。但是缺点也很明显:

  • 效率低。在开发时可能你需要反复的修改代码、测试功能,这样重复查看几十次甚至几百次网页时会相当的让人烦躁。
  • 容易遗漏 bug。随着你的项目越来越复杂,组件之间的交互也更加复杂。修改某一个组件可能会导致另一个组件出现意想不到的 bug,但是在人工测试时却很难检查出来,总不能每写几行代码就把整个网站统统检查一遍吧。过了很久之后你终于发现了这个 bug,但此时你已经搞不清它来源于什么地方了。
  • 有的测试不方便进行。比如说有个功能,限制每个用户每天发表评论不能超过 10 条,人工测试就显得比较麻烦,特别是需要反复调试的时候。

为了解决人工测试的种种问题,Django引入了 Python 标准库的单元测试模块,也就是 自动化测试 了:你可以写一段代码,让代码帮你测试!(程序员是最会偷懒的职业..)代码会忠实的完成测试任务,帮助你从繁重的测试工作中解脱出来。除此之外,自动化测试还有以下优点:

  • 预防错误。当应用过于复杂时,代码的意图会变得非常不清晰,甚至你都看不懂自己写的代码,这是很常见的。而测试就好像是从内部审查代码一样,可以帮助你发现微小的错误。
  • 有利于团队协作。良好的测试保证其他人不会不小心破坏了你的代码(也保证你不会不小心弄坏别人的..)。现在已经不是单打独斗出英雄的年代了,想要成为优秀的 Django 程序员,你必须擅长编写测试!

虽然学习自动化测试不会让你的博客增加一丝丝的功能,但是可以 让代码更加强壮,所以我觉得很有必要拿出一章来专门讲讲。

Django 官方文档的第 5 部分讲测试讲得非常的好,并且有中文版本。本章节就大量借鉴了官方文档,也非常非常推荐读者去拜读。

第一个测试

给我 bug!

为了演示测试是如何工作的,让我们首先在 文章模型 中写个有 bug 的方法:

article/models.py

from django.utils import timezone

class ArticlePost(models.Model):
    ...

    def was_created_recently(self):
        # 若文章是 "最近" 发表的,则返回 True
        diff = timezone.now() - self.created
        if diff.days <= 0 and diff.seconds < 60:
            return True
        else:
            return False

这个方法用于检测当前文章是否是最近发表的。

这个方法稍微扩展一下就会变得非常实用。比如可以将博文的发表日期显示为“刚刚”、“3 分钟前”、“5 小时前”等相对时间,用户体验将大有提升。

仔细看看,它是没办法正确判断“未来”的文章的:

>>> import datetime
>>> from django.utils import timezone
>>> from article.models import ArticlePost
>>> from django.contrib.auth.models import User

# 创建一篇 "未来" 的文章
>>> future_article = ArticlePost(author=User(username='user'), title='test',body='test', created=timezone.now() + datetime.timedelta(days=30))

# 是否是“最近”发表的?>>> future_article.was_created_recently()
True

未来发生的肯定不是最近发生的,因此代码是错误的。

写个测试暴露 bug

接下来就要写测试用例,将测试转为自动化。

还记得最初生成 文章 app时候的目录结构吗?

article
 │  admin.py
 │  apps.py
 │  models.py
 │  tests.py
 │  views.py
 │  __init__.py
 │
 └─migrations
       └─ __init__.py

这个 tests.py 就是留给你写测试用例的地方了:

article/tests.py

from django.test import TestCase

import datetime
from django.utils import timezone
from article.models import ArticlePost
from django.contrib.auth.models import User


class ArticlePostModelTests(TestCase):

    def test_was_created_recently_with_future_article(self):
        # 若文章创建时间为未来,返回 False
        author = User(username='user', password='test_password')
        author.save()

        future_article = ArticlePost(
            author=author,
            title='test',
            body='test',
            created=timezone.now() + datetime.timedelta(days=30)
            )

        self.assertIs(future_article.was_created_recently(), False)

基本就是把刚才在 Shell 中的测试代码抄了过来。有点不同的是末尾这个 assertIs 方法,了解 “断言” 的同学会对它很熟悉:它的作用是检测方法内的两个参数是否完全一致,如果不是则抛出异常,提醒你这个地方是有问题滴。

接下来运行测试:

(env) > python manage.py test

运行结果如下:

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_was_created_recently_with_future_article (article.tests.ArticlePostModelTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "E:\django_project\my_blog\article\tests.py", line 19, in test_was_created_recently_with_future_article
    self.assertIs(future_article.was_created_recently(), False)
AssertionError: True is not False

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)
Destroying test database for alias 'default'...

这里面名堂就很多了:

  • 首先测试系统会在所有以 tests 开头的文件中寻找测试代码
  • 所有 TestCase 的子类都被认为是测试代码
  • 系统创建了一个特殊的数据库供测试使用,即所有测试产生的数据不会对你自己的数据库造成影响
  • 类中所有以 test 开头的方法会被认为是测试用例
  • 在运行测试用例时,assertIs抛出异常,因为True is not False
  • 完成测试后,自动销毁测试数据库

测试系统明确指明了错误的数量、位置和种类等信息,请读者细细品尝。

修正 bug

既然通过测试找到了 bug,那接下来就要把代码进行修正:

article/models.py

from django.utils import timezone

class ArticlePost(models.Model):
    ...

    def was_created_recently(self):
        diff = timezone.now() - self.created
        
        # if diff.days <= 0 and diff.seconds < 60:
        if diff.days == 0 and diff.seconds >= 0 and diff.seconds < 60:
            return True
        else:
            return False

重新运行测试:

(env) > python manage.py test

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK
Destroying test database for alias 'default'...

这次代码顺利通过了测试。

可以肯定的是,在往后的开发中,这个 bug 不会再出现了,因为你只需要运行一遍测试,就会立即得到警告。可以认为项目的这一小部分代码永远是安全的

更全面的测试

既然一个测试用例就可以保证一小段代码永远安全,那我写一堆测试岂不是可以保证整个项目永远安全吗?确实如此,这个买卖绝对是不亏的。

因此我们继续再增加几个测试,全面强化代码:

article/tests.py

...

from django.test import TestCase

import datetime
from django.utils import timezone
from article.models import ArticlePost
from django.contrib.auth.models import User


class ArticlePostModelTests(TestCase):

    def test_was_created_recently_with_future_article(self):
        # 若文章创建时间为未来,返回 False
        ...

    def test_was_created_recently_with_seconds_before_article(self):
        # 若文章创建时间为 1 分钟内,返回 True
        author = User(username='user1', password='test_password')
        author.save()
        seconds_before_article = ArticlePost(
            author=author,
            title='test1',
            body='test1',
            created=timezone.now() - datetime.timedelta(seconds=45)
            )
        self.assertIs(seconds_before_article.was_created_recently(), True)

    def test_was_created_recently_with_hours_before_article(self):
        # 若文章创建时间为几小时前,返回 False
        author = User(username='user2', password='test_password')
        author.save()
        hours_before_article = ArticlePost(
            author=author,
            title='test2',
            body='test2',
            created=timezone.now() - datetime.timedelta(hours=3)
            )
        self.assertIs(hours_before_article.was_created_recently(), False)

    def test_was_created_recently_with_days_before_article(self):
        # 若文章创建时间为几天前,返回 False
        author = User(username='user3', password='test_password')
        author.save()
        months_before_article = ArticlePost(
            author=author,
            title='test3',
            body='test3',
            created=timezone.now() - datetime.timedelta(days=5)
            )
        self.assertIs(months_before_article.was_created_recently(), False)

现在我们拥有了 4 个测试,来保证 was_created_recently() 方法对于 过去 最近 未来 中的 4 种情况都返回正确的值。你还可以继续扩展,直到你觉得完全没有任何 bug 藏匿的可能性为止。

在实际的开发中,有些难缠的 bug 会把自己伪装得非常的好,而不是像教程这样明确的知道它就在那里。有了自动化测试,无论以后你的项目怎么变化、app 交互多么的复杂,只要在测试中写好的逻辑就一定是符合预期的,而你所需要做的只是运行一条测试指令而已。

虽然教程中仅使用了 assertIs,但实际上 Django 中的断言有大概几十种之多,比如assertEqualassertContains 等,并且还在不断更新。详见 Python 标准断言和 Django 扩展断言

测试视图

上面的测试都是针对模型的。视图 该怎么测试?如何通过测试系统模拟出用户的请求呢?

答案是 TestCase 类提供了一个供测试使用的 Client 来模拟用户通过请求和视图层代码的交互。

文章详情视图 浏览量统计 为例,比较容易出现的潜在 bug 有:

  • 增加的浏览量未能正常保存进数据库(即每次请求则浏览量 +1)
  • 增加浏览量的同时,updated字段也错误的一并更新

所以有针对的写 2 条测试。新写一个专门 测试视图的类 ,与前面的 测试模型的类 区分开:

article/tests.py

...
from time import sleep
from django.urls import reverse


class ArticlePostModelTests(TestCase):
    ...


class ArtitclePostViewTests(TestCase):

    def test_increase_views(self):
        # 请求详情视图时,阅读量 +1
        author = User(username='user4', password='test_password')
        author.save()
        article = ArticlePost(
            author=author,
            title='test4',
            body='test4',
            )
        article.save()
        self.assertIs(article.total_views, 0)

        url = reverse('article:article_detail', args=(article.id,))
        response = self.client.get(url)

        viewed_article = ArticlePost.objects.get(id=article.id)
        self.assertIs(viewed_article.total_views, 1)

    def test_increase_views_but_not_change_updated_field(self):
        # 请求详情视图时,不改变 updated 字段
        author = User(username='user5', password='test_password')
        author.save()
        article = ArticlePost(
            author=author,
            title='test5',
            body='test5',
            )
        article.save()

        sleep(0.5)

        url = reverse('article:article_detail', args=(article.id,))
        response = self.client.get(url)

        viewed_article = ArticlePost.objects.get(id=article.id)
        self.assertIs(viewed_article.updated - viewed_article.created < timezone.timedelta(seconds=0.1), True)

注意看代码是如何与 视图层 交互的:response = self.client.get(url)向视图发起请求并获得了响应,剩下的就是从数据库中取出更新后的数据,并用 断言 语句来判断代码是否符合预期了。

运行测试:

(env) > python manage.py test

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
......
----------------------------------------------------------------------
Ran 6 tests in 0.617s

OK
Destroying test database for alias 'default'...

6 条测试用例全部通过。

越多越好的测试

仅仅是 app 中的两个非常小的功能,就已经写了 6 条测试用例了,并且还可以继续扩展。除此之外,其他的每个模型、视图都可以扩展出几十甚至上百条测试,这样下去代码总量很快就要失去控制了,并且相对于业务代码来说,测试代码显得繁琐且不够优雅。

但是没关系!就让测试代码继续肆意增长吧。大部分情况下,你写完一个测试之后就可以忘掉它了。在你继续开发的过程中,它会一直默默无闻地为你做贡献的。最坏的情况是当你继续开发的时候,发现之前的一些测试现在看来是多余的。但是这也不是什么问题,多做些测试也不错。

深入代码测试

在前面的测试中,我们已经从模型层和视图层的角度检查了应用的输入输出,但是模板呢?虽然可以用 assertInHTMLassertJSONEqual 等断言大致检查模板中的某些内容,但更加近似于浏览器的检查就要使用 Selenium 等测试工具(毕竟 Django 的重点是后端而不是前端)。

Selenium不仅可以测试 Django 框架里的代码,甚至还可以检查 JavaScript 代码。它假装成是一个正在和你站点进行交互的浏览器,就好像有个真人在访问网站一样。Django 提供了 LiveServerTestCase 来和 Selenium 这样的工具进行交互。

关于测试的话题这里只是开了个头,读者可以继续阅读下面的内容进一步了解:

  • Django: Writing and running tests
  • Django: Testing tools
  • Django: Advanced testing topics
  • Selenium 官方文档

总结

有一帮崇尚“测试驱动”的开发者,他们开发时先写测试代码,然后才写业务代码。而普通开发者通常是先写业务代码,再写测试代码,这也是没问题的。但如果你已经写了很多业务代码了,再回头写测试确实有些无从下手,那么至少在以后写新功能时,记得加上测试。测试写得好不好,甚至比功能本身更能看出编程水平。

测试可以让代码更加强壮。项目没出 bug 时,皆大欢喜,有没有测试都一样;一旦出现难缠的 bug,你就会无比想念一套完善的测试代码了。

博主写自己的网站时就没有对测试给与足够的重视,回想起来走了很多弯路。希望读者以前车之鉴,培养良好的编程习惯。


  • 有疑问请在杜赛的个人网站留言,我会尽快回复。
  • 或 Email 私信我:dusaiphoto@foxmail.com
  • 项目完整代码:Django_blog_tutorial
退出移动版