Django 自动化测试详解

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

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

为了解决人工测试的种种问题,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这样的工具进行交互。

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

总结

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

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

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

教程来源于Github,感谢杜赛 www.dusaiphoto.com大佬的无私奉献,致敬!

技术教程推荐

深入浅出gRPC -〔李林锋〕

OpenResty从入门到实战 -〔温铭〕

网络编程实战 -〔盛延敏〕

张汉东的Rust实战课 -〔张汉东〕

打造爆款短视频 -〔周维〕

大数据经典论文解读 -〔徐文浩〕

编程高手必学的内存知识 -〔海纳〕

云原生架构与GitOps实战 -〔王炜〕

Python实战 · 从0到1搭建直播视频平台 -〔Barry〕