Django笔记三十六之单元测试汇总介绍

本文首发于公众号:Hunter后端

原文链接:Django笔记三十六之单元测试汇总介绍

Django 的单元测试使用了 Python 的标准库:unittest。

在我们创建的每一个 application 下面都有一个 tests.py 文件,我们通过继承 django.test.TestCase 编写我们的单元测试。

本篇笔记会包括单元测试的编写方式,单元测试操作流程,如何复用数据库结构,如何测试接口,如何指定 sqlite 作为我们的单元测试数据库等

以下是本篇笔记目录:

  1. 单元测试示例、使用和介绍
  2. 单元测试流程介绍
  3. 单元测试的执行命令
  4. 复用测试数据库结构
  5. 判断函数
  6. 接口的测试
  7. 标记测试
  8. 单元测试配置
  9. 使用 SQLite 作为测试数据库

1、单元测试示例、使用和介绍

首先我们编写 blog/tests.py 文件,创建一个简单的单元测试:

from django.test import TestCase
from blog.models import Blog


class BlogCreateTestCase(TestCase):
    def setUp(self):
        Blog.objects.create(name="Python", tag_line="this is a tag line")

    def test_get_blog(self):
        blog = Blog.objects.get(name="Python")
        self.assertEqual(blog.name, "Python")

以上是一个很简单的单元测试示例,接下来我们执行这个单元测试:

python3 manage.py test blog.tests.BlogCreateTestCase.test_get_blog

执行之后可以看到控制台会输出一些信息,如果没有报错,说明我们的这个单元测试成功执行。

在 BlogCreateTestCase 中,这个单元测试继承了 django.test.TestCase,我们在 setUp() 函数中执行一些操作,这个操作会在执行某个测试,比如 test_get_blog() 前先执行。

我们执行的是 test_get_blog() 函数,这里的逻辑是先获取一个 blog 示例,然后通过 assertEqual() 函数判断两个输入的值是否相等,如果相等,则单元测试通过,否则会报失败的错误。

2、单元测试流程介绍

首先我们看一下 settings.py 中的数据库定义:

# hunter/settings.py

DATABASES = {
    'default': {
        'ENGINE': "django.db.backends.mysql",
        'NAME': "func_db",
        "USER": "root",
        "PASSWORD": "123456",
        "HOST": "192.168.1.9",
        "PORT": 3306,
    },
}

当我们执行下面这个命令之后:

python3 manage.py test blog.tests.BlogCreateTestCase.test_get_blog

系统会去 default 这个数据库的连接地址,创建一个新的数据库,数据库名称为当前数据库的名称加上 test_ 前缀。

比如我们连接的正式数据库名称为 func_db,那么测试数据库名为 test_func_db

创建该数据库之后,系统会将当前系统所有的 migration 都执行一遍到测试数据库,然后依据我们单元测试的逻辑,比如 setUp() 中对数据的初始化,以及 test_get_blog() 中对数据的获取和比较操作执行一遍逻辑。

这个流程结束之后,系统会自动删除刚刚创建的测试数据库,至此,一个单元测试执行的流程就结束了。

3、单元测试的执行命令

执行单个单元测试

上面我们执行的单元测试的命令精确到了类中的函数,我们也可以直接执行某个单元测试,比如我们的 BlogCreateTestCase 内容如下:

class BlogCreateTestCase(TestCase):
    def setUp(self):
        Blog.objects.create(name="Python", tag_line="this is a tag line")

    def test_get_blog(self):
        print("test_get_blog")
    
    def test_get_blog_2(self):
        print("test_get_blog_2")

我们直接执行命令到这个单元测试:

python3 manage.py test blog.tests.BlogCreateTestCase

那么系统就会执行 BlogCreateTestCase 下 test_get_blog 和 test_get_blog_2 这两个函数。

执行单元测试文件

再往上一层,我们可以执行某个单元测试的文件,比如该 tests.py 内容如下:

# blog/tests.py

class BlogCreateTestCase(TestCase):
    def setUp(self):
        Blog.objects.create(name="Python", tag_line="this is a tag line")

    def test_get_blog(self):
        print("test_get_blog")
        
class BlogCreateTestCase2(TestCase):
    
    def test_get_blog_2(self):
        print("test_get_blog_2")

当我们执行:

python3 manage.py test blog.tests

系统就会将 tests.py 中 BlogCreateTestCase 和 BlogCreateTestCase2 这两个单元测试都执行一遍。

执行系统所有单元测试

如果我们想要统一执行系统全部单元测试,可以直接如下操作:

python3 manage.py test

单元测试查找逻辑

当我们执行上面那条命令的时候,系统是如何查找处测试文件的呢?

系统会搜索目录下所有 test 开头的文件夹或者文件,如果是文件夹,则继续寻找文件夹下 test 开头的文件,对于每个 test 开头的文件,找到继承了 django.test.TestCase 的类,然后执行每个开头名为 test 的类函数。

接下来我们举几个示例,假设我们在 blog 的目录下有这样的结构:

blog/
    test_123/
        no_test.py
        test_ok.py
        tests.py
    tests/
        tests.py
        test_123.py
    no_test/
        test_123.py
    test.py
    test_123.py
    no_test.py

在上面这个目录结构下,系统会去搜索 test_123tests 文件夹下 test 开头的文件,以及 blog 下的 test.pytest_123.py,寻找其中继承了 django.test.TestCase 的类作为单元测试然后执行。

在这里,比如 test_123/no_test.py 这个文件就不会被判定为测试文件,因为它名称不是 test 开头的。

而在 test 开头的测试文件中,如果一个类继承了 django.test.TestCase,但是它的类函数并不是以 test 开头的,这样的函数也不会被执行,比如:

class BlogCreateTestCase(TestCase):
    def setUp(self):
        Blog.objects.create(name="如何Python", tag_line="this is a tag line")

    def test_ok(self):
        print("12344444............")
        self.assertEqual(1, 1)

    def no_test(self):
        print("no test")

比如上面这个单元测试,test_ok 这个类函数就会被作为单元测试的一部分,而 no_test 则不会被执行。

如果测试文件较多,为了统一管理,我们可以都放在 application 下的 tests 文件夹下,比如:

blog/
    tests/
        test_1.py
        test_2.py
        test_3.py

4、复用测试数据库结构

当我们写完一个功能,然后编写这个功能的单元测试,紧接着去测这个单元测试,系统就会去创建一个数据库,然后执行所有的 migration,然后执行单元测试逻辑,执行结束之后会删掉该测试数据库。

在我们的项目中,如果维护到了后期,拥有的 migration 较多,每次执行单元测试都要删掉然后重建数据库,在时间上是一个很大的消耗,那么我们如何在执行完一个单元测试之后保存当前的测试数据库用于下一次执行呢。

那就是使用 --keepdb 参数。

按照前面的逻辑,我们的测试数据库会在 DATABASES 中定义的数据库地址新建一个数据库,我们可以使用 --keepdb 执行这样的操作:

python3 manage.py test --keepdb blog.tests.BlogCreateTestCase

加上 --keepdb 参数之后,执行单元测试结束之后,我们可以通过 workbench 或者 navicat 等工具去该数据库地址查看,会多出一个名为 test_fund_db 的数据库,那就是我们执行单元测试之后没有删除的测试数据库。

当我们下次再执行这个或者其他单元测试的时候,可以发现执行的时间就变得很快了,而且在控制台会输出这样一条信息:

Using existing test database for alias 'default'...

意思就是使用已经存在的测试数据库。

而不加 --keepdb 的时候,输出的是:

Creating test database for alias 'default'...

表示的是正在创建新的测试数据库。

注意: 虽然单元测试结束之后数据库的结构还会保留,但是在单元测试中我们创建的数据还是会被删除。这个仅限于在单元测试中创建的数据,通过 migration 初始化的数据还是存在数据库中。

5、判断函数

在介绍测试接口前,我们先介绍一下几个判定函数。

self.assertEqual

这个函数接收三个参数,前两个参数用于比较是否相等,第三个参数为 msg,用于在前两个参数不相等时报出的错误信息,但是可不传,默认为 None。

比如我们这样操作:

self.assertEqual(Blog.objects.count(), 20, msg="blog count error")

self.assertEqual(Blog.objects.count(), 20)

如果前两个参数不相等则单元测试会不通过。

self.assertTrue

这个函数接收两个参数,前一个参数是一个表达式,后一个参数是 msg,也是用于前一个参数不为 True 的时候报出的错误信息,可不传,默认为 None。

我们可以这样操作:

self.assertTrue(Blog.objects.filter(name="Python").exists(), "Pyrhon blog not exists")

self.assertTrue(Blog.objects.filter(name="Python").exists())

同样,如果表达式参数不为 True,则单元测试不会通过。

self.assertIn

接收三个参数,如果第二个参数不包含第一个参数,则会报错,比如:

self.assertIn(6, [1,2,3], "not in list")

self.assertIn("a", "def", "not in string")

self.assertIsNone

接口两个参数,表示如果传入的参数为 None 则通过单元测试:

a = None
self.assertIsNone(a)

对于 assertEqual、 assertTrue、assertIn、assertIsNone 还有对应的相反意义的函数

  • assertNotEqual 表示判定两者不相等
  • assertFalse 表示判定表达式为 False
  • assertNotIn 表示判定后者不包含前者
  • assertIsNotNone 表示判定不为 None

这里还有一些判定大于、小于、大于等于、小于等于的函数,这里就不做多介绍了 assertGreater、assertLess、assertGreaterEqual、assertLessEqual

self.fail(msg="failed testcase")

如果我们希望在某些判断条件下直接让单元测试不通过,可以直接使用 self.fail() 函数,比如:

a = 1
b = 2
if a < b:
    self.fail(msg="a < b")

6、接口的测试

在上面我们的单元测试中,我们使用的只是简单的对于 model 的创建查询和验证,但是一般来说,除了测试系统的工具类函数,我们常用到的测试用途是测试和验证接口的逻辑。

在介绍如何对接口进行测试前,一下 model_mommy 库。

model_mommy 库

这是个可以模拟 model 数据的库,它有什么用处呢,比如我们想创建几条 model 的数据,但是不关心一些必填字段的值,或者只想指定某几个字段特定的值,或者想批量创建某个 model 的数据。

首先我们引入这个库:

pip3 install model_mommy

使用 model_mommy 来创建模拟数据:

from model_mommy import mommy

blog_1 = mommy.make(Blog, name="Python")

这样我们就创建了一条数据,这个时候如果我们打印出 blog_1 的内容,可以发现 Blog 的有默认值的字段都被默认值填充,无默认值的都会被无意义数据填充

print(blog_1.__dict__)

#  'id': 4, 'name': 'Python', 'tag_line': 'sIDENcYqKVwESvEUAwZGIVtGdWHhKyNNoDzoaZCdDuqQuIKCkwazqwfcNEEtzfcoZeEnVVDiVLzAhhOuYsxiuKUOVFifUimnCLbMNHMpYLYxHCVSVfiggeBQhmRPFuIUwiKDUSDZztzQzFlKfcSxdnewsekQBzlCuMZLVPyOrfTXYWgPIkBhytzBkcMbpvCvidSETxZRjWeeEBPLELHpHYOmKgKHdNxrmjjLlewGWKTLQNFPFWOGndzncghTEcuFnEfRQvGgXcsPTfaGAHDDqPGyNeerTmOHDTUmnWmzHIXF', 'char_count': 0, 'is_published': 0, 'pub_datetime': None}

或者我们想批量创建二十条 Blog 的数据,我们可以通过 _quantity 参数这样操作:

mommy.make(Blog, _quantity=20)

Client() 调用接口

调用接口用到的函数是 Client()

假设我们想要调用登录接口,我们可以如下操作:

from django.test import Client

url = "/users/login"
c = Client()
response = c.post(url, data={"username": "admin", "password": "123456"}, content_type="application/json")

self.assertEqual(response.json().get("code"), 0)

使用单元测试而不是使用 postman 调用有一个好处就是我们不用把后端服务启动起来,所以这里的 url 相应的也不用加上 ip 地址或者域名。

调用接口还有另一种方式,就是在继承了 django.test.TestCase 的单元测试中直接使用 self.client,它与实例化 Client() 后的直接作用效果是一样的,都可以用来调用接口。

那为什么要使用 self.client 呢,是为了自动保存登录接口的 session。

比如对于 /users/user/info 这个需要登录后才能访问到的用户信息接口,我们就可以使用 self.client 在 setUp() 初始化数据的时候先进行登录操作,接着就可以以已登录状态访问用户信息接口了。

class UserInfoTestCase(TestCase):
    def setUp(self):
        username = "admin"
        password = make_password("123456")
        User.objects.create(username=username, password=password)

        url = "/users/login"
        response = self.client.post(url, data={"username": "admin", "password": "123456"}, content_type="application/json")
        resp_data = response.json()
        print("login...")
        self.assertEqual(resp_data.get("code"), 0)
    
    def test_user_info(self):
        url = "/users/user/info"
        response = self.client.post(url)
        print(response.json())

如果系统大部分接口都需要以登录状态才能访问,我们甚至可以将登录操作写入一个基础类,其他的单元测试都继承这个类,这样就不需要重复编写登录的接口了:

class BaseTestCase(TestCase):
    def setUp(self):
        username = "admin"
        password = make_password("123456")
        User.objects.create(username=username, password=password)

        url = "/users/login"
        response = self.client.post(url, data={"username": "admin", "password": "123456"}, content_type="application/json")
        resp_data = response.json()
        print("login...")
        self.assertEqual(resp_data.get("code"), 0)



class UserInfoTestCase(BaseTestCase):
    def test_user_info(self):
        url = "/users/user/info"
        response = self.client.post(url)
        print(response.json())


class TestCase2(BaseTestCase):
    def test_case(self):
        url = "/xx/xxx"
        response = self.client.post(url)
        print(response.json())

7、标记测试

一般来说,我们的单元测试是都要全部通过才能上线进入生产环境的,但是某些情况下,我们对系统只进行了少部分的修改,或者说只需要测试某些特定的重要功能就可以上线,这种情况下可以给我们的测试用例打上 tag,这样在测试的时候就可以挑选特定的单元测试,通过即可上线。

这个 tag 可以打到一个单元测试上,也可以打到某个单元测试的函数上,比如我们有三个标记,fast,slow,core,以下是几个单元测试:

from django.test import tag

class SingleTestCase(TestCase):
    @tag("fast", "core")
    def test_1(self):
        print("fast, core from SingleTestCase.test_1")

    @tag("slow")
    def test_2(self):
        print("slow from SingleTestCase.test_2")


@tag("core")
class CoreTestCase(TestCase):
    def test_1(self):
        print("core from CoreTestCase")

然后我们可以通过 --tag 指定标记的单元测试:

python3 manage.py test --keepdb --tag=core

python3 manage.py test --keepdb --tag=core --tag=slow

8、单元测试配置

编码配置

在前面我们的数据库链接中,并没有指定数据库的编码,而我们创建生产数据库的时候使用的 charset 是 utf-8,而测试数据库在创建的时候没有指定编码的话,默认使用的是 latin1 编码。

这样会造成一个问题,就是我们的单元测试在往数据库写入数据的时候就会因为不支持中文而导致报错。

比如在不设置编码的时候我们使用下面的单元测试就会报错:

from django.test import TestCase
from blog.models import Blog


class BlogCreateTestCase(TestCase):
    def setUp(self):
        Blog.objects.create(name="测试数据", tag_line="this is a tag line")

    def test_get_blog(self):
        blog = Blog.objects.get(name="测试数据")
        self.assertEqual(blog.name, "测试数据")

所以如果要指定创建的测试数据库的编码,我们需要加上一个配置:

DATABASES = {
    'default': {
        ...
        "TEST": {
            "CHARSET": "utf8",
        },
    }
}

测试数据库名称

默认情况下,测试数据库的名称是 'test_' + DATABASES['default']['name'],如果我们想指定测试数据库名称,可以额外加一个 NAME 字段:

DATABASES = {
    'default': {
        ...
        "TEST": {
            "CHARSET": "utf8",
            "NAME": "test_default_db",
        },
    }
}

9、使用 SQLite 作为测试数据库

目前我们的测试数据库是在 default 数据库的地址新建一个数据库,如果我们想要运行单元测试的时候直接在本地使用 SQLite 作为我们的测试数据库,可以在 settings.py 中定义 DATABASES 的后面加上下面的定义:

import sys

if "test" in sys.argv:
    DATABASES = {
        "default": {
            "ENGINE": "django.db.backends.sqlite3",
            "NAME": os.path.join(BASE_DIR, "db.sqlite3"),
            "TEST": {
                "NAME": os.path.join(BASE_DIR, "test_db.sqlite3"),
            }
        }
    }

其中,sys.argv 是一个列表,列表元素是我们执行命令的各个参数。

所以当我们执行单元测试命令的时候,会包含 test,所以数据库的链接内容就会走我们这个逻辑。

在这部分,我们使用 ENGINE 来确定了后端数据库的类型为 SQLite,然后通过 DATABASES["default"]["test"]["NAME"] 来指定我们的测试数据库地址。

当我们执行单元测试的命令时,在系统根目录下就会多出一个 test_db.sqlite3 的数据库。

如果想获取更多后端相关文章,可扫码关注阅读:
image

posted @ 2023-05-06 23:24  XHunter  阅读(193)  评论(0编辑  收藏  举报