Django 单元测试

1. 环境准备

1、创建项目和应用:

django-admin startproject django_example_untest
cd django_example
python manage.py startapp users

2、添加应用,注释 csrf

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'users'
]
MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    # 'django.middleware.csrf.CsrfViewMiddleware',		# 防止测试时需要验证 csrf
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

3、目录结构如下:

django_example
├── django_example_untest
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── manage.py
└── users
    ├── __init__.py
    ├── admin.py
    ├── apps.py
    ├── migrations
    │   └── __init__.py
    ├── models.py
    ├── tests.py
    └── views.py

4、添加视图函数 users/views.py

import json

from django.contrib.auth import authenticate, login, logout
from django.http import JsonResponse
from django.shortcuts import render

# Create your views here.
from django.views import View


class UserView(View):
    """获取用户信息"""
    def get(self, request):
        if not request.user.is_authenticated:
            return JsonResponse({
                'code': 401,
                'message': '用户未登录'
            })
        return JsonResponse({
            'code': 200,
            'message': 'OK',
            'data': {
                'username': request.user.username
            }
        })


class SessionView(View):
    def post(self, request):
        """用户登录"""
        # 客户端的请求体是 json 格式
        content_type = request.content_type
        if 'application/json' not in content_type:
            return JsonResponse({
                'code': 400,
                'message': '非 json 格式'
            })
        else:
            data = eval(request.body.decode('utf-8'))
            username = data.get('username', '')
            password = data.get('password', '')
            user = authenticate(username=username,
                                password=password)
            # 检查用户是否存在
            if not user:
                return JsonResponse({
                    'code': 400,
                    'message': '用户名或密码错误'
                })
            # 执行登录
            login(request, user)
            return JsonResponse({
                'code': 201,
                'message': 'OK'
            })

    def delete(self, request):
        """退出登录"""
        logout(request)
        return JsonResponse({
            'code': 204,
            'message': 'OK'
        })

执行 python manage.py makemigrations、python manage.py migrate

2. 单元测试

Django 的单元测试使用的是 python unittest模块,在每个应用下面都有一个 tests.py 文件,将测试代码写入其中即可。如果测试的代码量比较多,我们需要将测试的代码分模块,那么可以在当前应用下创建 tests 包。

2.1 常用方法及使用事项

django 提供了 django.test.TestCase 单元测试基础类,它继承自 python 标准库中 unittest.TestCase

通常测试代码中自定义的类继承 TestCase 类,里面测试方法必须以 test_ 开头,一个类可以包含多个测试方法,如测试登录可以取名为 test_login

两个特殊方法

  • setUp(self):在每个测试方法执行之前调用,一般用于做一些准备工作
  • tearDown(self):在每个测试方法执行之后被调用,一般用来做一些清理工作

两个特殊类方法

  • setUpClass(cls):用于做类级别的准备工作,会在测试之前被调用,且一个类中,只能被调用一次
  • tearDown(self):用于做类级别的准备工作,会在测试之后被调用,且一个类中,只能被调用一次

客户端类 Client

客户端类用于模拟客户端发起 get、post、delet 等请求,且能自动保存 cookieDjango 的客户端类由 django.test.client.Client 提供。

另外 Client 还提供了 login 方法,可以很方便地进行用户登录。

注意:通过 client 发起请求时,URL 路径不用添加 schema://domain 前缀

2.2 如何执行

  • 测试所有类(多个应用/多个测试文件):python manage.py test
  • 测试具体应用、文件、类、方法:python manage.py test [app_name] [.test_file_name] [.class_name] [.test_method_name]

示例:

python manage.py test users
python manage.py test users.tests
python manage.py test users.tests.UserTestCase
python manage.py test users.tests.UserTestCase.test_user

测试时会创建自动创建测试数据库,测试完毕后也会自动销毁:

(MxShop) F:\My Projects\django_example_untest>python manage.py test users.tests.UserTestCase.test_user
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.241s

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

看到 OK 两字表示测试用例通过,否则测试失败,还需检查程序代码哪里有问题。

2.3 测试代码

users/tests.py 编辑如下:

from django.contrib.auth.models import User
from django.test import TestCase
from django.test.client import Client


# Create your tests here.
class UserTestCase(TestCase):
    def setUp(self):
        # 创建测试用户
        self.username = 'rose'
        self.password = 'rose123'
        self.user = User.objects.create_user(
            username=self.username, password=self.password
        )
        # 实例化 client 对象
        self.client = Client()

        # 登录
        self.client.login(username=self.username, password=self.password)

    def tearDown(self):
        """删除测试用户"""
        self.user.delete()

    def test_user(self):
        """测试获取用户信息接口"""
        path = '/users'
        resp = self.client.get(path)
        result = resp.json()
        self.assertEqual(result['code'], 200, result['message'])


class SessionTestCase(TestCase):
    @classmethod
    def setUpClass(cls):
        """测试之前被调用,只调用一次"""
        # 创建测试用户
        cls.username = 'lila'
        cls.password = 'lila123'
        cls.user = User.objects.create_user(
            username=cls.username, password=cls.password
        )
        # 实例化 client 对象
        cls.client = Client()

    @classmethod
    def tearDownClass(cls):
        """测试之后调用,只调用一次,删除测试用户"""
        cls.user.delete()

    def test_login(self):
        """测试登录接口"""
        path = '/session'
        auth_data = {
            "username": self.username,
            "password": self.password
        }
        # 将请求体设置为 json
        resp = self.client.post(
            path, data=auth_data, content_type='application/json'
        )
        # 转换为 字典
        result = resp.json()
        # 检查登录结果
        self.assertEqual(result['code'], 201, result['message'])

    def test_logout(self):
        """测试退出接口"""
        path = '/session'
        resp = self.client.delete(path)
        result = resp.json()

        # 断言,测试 result['code'] 是否为 204,若为 204 则表示测试通过
        self.assertEqual(result['code'], 204, result['message'])

测试:

(MxShop) F:\My Projects\django_example_untest>python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.579s

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

以上表示测试用例通过

2.4 RequestFactory 模拟请求

2.4.1 测试代码

上面测试代码,我们都是通过 client 来模拟请求,该请求最终会通过路由找到视图,并调用视图函数。

那么可不可以直接调用视图函数,而不通过 client,答案是可以的;Django 提供了一个 RequestFactory 对象可以直接调用视图函数 django.test.test.client.RequestFactory

users/tests.py 中新增代码:

from django.contrib.auth.models import User
from django.contrib.sessions.middleware import SessionMiddleware
from django.test import TestCase
from django.test.client import Client, RequestFactory


class SessionRequestFactoryTestCase(TestCase):
    """
    RequestFactory 直接调用视图函数
    """

    @classmethod
    def setUpClass(cls):
        cls.username = 'john'
        cls.password = 'john123'
        cls.user = User.objects.create_user(
            username=cls.username, password=cls.password
        )

    @classmethod
    def tearDownClass(cls):
        """删除测试用户"""
        cls.user.delete()

    def test_login(self):
        """测试登录视图函数"""
        request_factory = RequestFactory()
        path = '/session'
        auth_data = {
            'username': self.username,
            'password': self.password
        }

        # 构建请求对象
        request = request_factory.post(
            path, data=auth_data, content_type='application/json'
        )


        # 登录视图函数
        login_function = SessionView().post

        # 调用视图函数
        resp = login_function(request)

        print(resp.content)

    def test_logout(self):
        """测试退出函数"""
        request_factory = RequestFactory()
        path = '/session'
        request = request_factory.delete(path)


        # 退出视图函数
        logout_function = SessionView().delete

        # 调用视图
        resp = logout_function(request)

        print(resp.content)

测试:

python manage.py test

AttributeError: 'WSGIRequest' object has no attribute 'session'

提示没有 session 属性,这是因为 login、logout 两个函数都有利用 session 对用户信息进行操作(如:获取用户个人信息、清除个人信息等),查看相关源码可获知:

def login(request, user, backend=None):
    """
    Persist a user id and a backend in the request. This way a user doesn't
    have to reauthenticate on every request. Note that data set during
    the anonymous session is retained when the user logs in.
    """
    session_auth_hash = ''
    if user is None:
        user = request.user
    if hasattr(user, 'get_session_auth_hash'):
        session_auth_hash = user.get_session_auth_hash()

    if SESSION_KEY in request.session:
        if _get_user_session_key(request) != user.pk or (
                session_auth_hash and
                not constant_time_compare(request.session.get(HASH_SESSION_KEY, ''), session_auth_hash)):
            # To avoid reusing another user's session, create a new, empty
            # session if the existing session corresponds to a different
            # authenticated user.
            request.session.flush()
    else:
        request.session.cycle_key()

    try:
        backend = backend or user.backend
    except AttributeError:
        backends = _get_backends(return_tuples=True)
        if len(backends) == 1:
            _, backend = backends[0]
        else:
            raise ValueError(
                'You have multiple authentication backends configured and '
                'therefore must provide the `backend` argument or set the '
                '`backend` attribute on the user.'
            )

    # 将相关信息存储到 session 中
    request.session[SESSION_KEY] = user._meta.pk.value_to_string(user)
    request.session[BACKEND_SESSION_KEY] = backend
    request.session[HASH_SESSION_KEY] = session_auth_hash
    if hasattr(request, 'user'):
        request.user = user
    rotate_token(request)
    user_logged_in.send(sender=user.__class__, request=request, user=user)


def logout(request):
    """
    Remove the authenticated user's ID from the request and flush their session
    data.
    """
    # Dispatch the signal before the user is logged out so the receivers have a
    # chance to find out *who* logged out.
    user = getattr(request, 'user', None)
    if hasattr(user, 'is_authenticated') and not user.is_authenticated:
        user = None
    user_logged_out.send(sender=user.__class__, request=request, user=user)

    # remember language choice saved to session
    language = request.session.get(LANGUAGE_SESSION_KEY)

    # 清除 session 
    request.session.flush()     

    if language is not None:
        request.session[LANGUAGE_SESSION_KEY] = language

    if hasattr(request, 'user'):
        from django.contrib.auth.models import AnonymousUser
        request.user = AnonymousUser()

2.4.2 SessionMiddleware 源码

Django 是通过 SessionMiddleware 中间件 process_request 方法来实现的,具体可见源码:

# middleware.py
class SessionMiddleware(MiddlewareMixin):
    def __init__(self, get_response=None):
        self.get_response = get_response
        engine = import_module(settings.SESSION_ENGINE)
        self.SessionStore = engine.SessionStore

    def process_request(self, request):
        session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME)

        # 新增 session 属性(request 对象中)
        request.session = self.SessionStore(session_key)

2.4.3 解决方法

在上面 SessionRequestFactoryTestCase 测试类中我们只创建了请求对象 request,但是并没有添加 session 属性,所以导致后面的测试用例无法通过。

为此可以通过调用 SessionMiddleware.process_request 方法来设置 session 属性。

class SessionRequestFactoryTestCase(TestCase):
    ....
    def test_login(self):
        """测试登录视图函数"""
        request_factory = RequestFactory()
        path = '/session'
        auth_data = {
            'username': self.username,
            'password': self.password
        }

        # 构建请求对象
        request = request_factory.post(
            path, data=auth_data, content_type='application/json'
        )

        # 调用中间件处理
        session_middleware = SessionMiddleware()
        session_middleware.process_request(request)

        # 登录视图函数
        login_function = SessionView().post

        # 调用视图函数
        resp = login_function(request)

        print(resp.content)

    def test_logout(self):
        """测试退出函数"""
        request_factory = RequestFactory()
        path = '/session'
        request = request_factory.delete(path)

        # 调用中间件处理
        session_middleware = SessionMiddleware()
        session_middleware.process_request(request)

        # 退出视图函数
        logout_function = SessionView().delete

        # 调用视图
        resp = logout_function(request)

        print(resp.content)

再进行测试:

(MxShop) F:\My Projects\django_example_untest>python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 5 tests in 0.676s

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

2.5 总结

一个完整的 Django 生命周期:创建 request 对象 —> 执行中间层处理 —> 路由匹配 —> 视图处理 ----> 中间层处理 ----> 返回响应对象。

Django 模拟客户端发送请求的两种方式:

  • Client 对象:本质也是继承 RequestFactory 对象,调用 request 方法来发起完整的请求: 创建 request 对象–>执行中间层处理–>视图函数处理–>中间层处理–>返回响应对象。
  • RequestFactory 对象:request 对象就只做一件事,就是创建 request 对象,因此需要手动实现其他流程

注意:实际工作中一般采用 Client 对象,更为方便,只有遇到请求对象比较特殊或执行流程较复杂时,才通过 RequestFactory 对象来实现。

3. 代码测速覆盖度

3.1 快速上手

Coverage 是一个用来测试 python 程序代码覆盖率的功劳,可以识别代码的哪些部分已经被执行,有哪些可以执行但为执行的代码,可以用来衡量测试的有效性和完善性。

1、安装:

pip install coverage

2、配置:

项目根目录新建 .coveragerc 文件:

[run]
branch = True   # 是否统计条件语句分支覆盖情况,if 条件语句中的判断通常有 True 和 False,设置为 TRUE 是,会测量这两种情况是否都被测试到
source = .  # 指定需统计的源代码目录,这里为当前目录,即项目根目录

[report]
show_missing = True # 在生成的统计报告中显示未被测试覆盖到的代码行号

coverage 遵循 ini 配置语法,上面有两个配置块,每个配置块表示不同的含义。

3、运行:

清除上一次的统计信息:coverage erase

coverage run 来代替 python manage.py test 来测试:

(MxShop) F:\My Projects\django_example_untest>coverage run manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 5 tests in 0.745s

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

生成覆盖率统计报告(查看已覆盖数、未覆盖数及覆盖百分比):

(MxShop) F:\My Projects\django_example_untest>coverage report
Name                                Stmts   Miss Branch BrPart  Cover   Missing
-------------------------------------------------------------------------------
django_example_untest\__init__.py       0      0      0      0   100%
django_example_untest\settings.py      18      0      0      0   100%
django_example_untest\urls.py           4      0      0      0   100%
django_example_untest\wsgi.py           4      4      0      0     0%   10-16
manage.py                               9      2      2      1    73%   5->exit, 9-10
users\__init__.py                       0      0      0      0   100%
users\admin.py                          1      0      0      0   100%
users\apps.py                           3      0      0      0   100%
users\migrations\__init__.py            0      0      0      0   100%
users\models.py                         1      0      0      0   100%
users\tests.py                         64      0      0      0   100%
users\views.py                         26      3      6      3    81%   11->12, 12, 30->31, 31, 42->43, 43
-------------------------------------------------------------------------------
TOTAL                                 130      9      8      4    91%

注意:倒数第二列为测试覆盖率,倒数第一列为未覆盖的代码行号

获取更详细的信息,会在同级目录生成一个 htmlcov 文件夹,打开 index.html 文件即可查看测试覆盖情况:


生成 xml 格式的报告:

(MxShop) F:\My Projects\django_example_untest>coverage xml

更多命令可使用:coverage --help 查看

3.2 高级用法

查看上述的报告,可看到很多覆盖度 100% 的文件(通常为源码文件),有时我们并不关心这些报告,当文件很多的时候要想快速找到我们需要测试的文件就有点困难,因此我们要将一些不必要的文件排除掉,通过 [run] 配置的 omit 配置项即可实现:

[run]
branch = True
source = .
omit =          # 忽略一些非核心的项目文件(具体按照自己项目来配置)
    django_example_untest\__init__.py
    django_example_untest\settings.py
    django_example_untest\urls.py
    django_example_untest\wsgi.py
    django_example_untest\manage.py
    users\__init__.py
    users\admin.py
    users\apps.py
    users\migrations\__init__.py
    users\models.py
    *\migrations\*


[report]
show_missing = True
skip_covered = True     # 指定统计报告中不显示 100%的文件

再重新进行统计:

(MxShop) F:\My Projects\django_example_untest>coverage run manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
b'{"code": 201, "message": "OK"}'
.b'{"code": 204, "message": "OK"}'
....
----------------------------------------------------------------------
Ran 5 tests in 0.687s

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

(MxShop) F:\My Projects\django_example_untest>coverage report
Name                            Stmts   Miss Branch BrPart  Cover   Missing
---------------------------------------------------------------------------
django_example_untest\wsgi.py       4      4      0      0     0%   10-16
manage.py                           9      2      2      1    73%   5->exit, 9-10
users\views.py                     26      3      6      3    81%   11->12, 12, 30->31, 31, 42->43, 43
---------------------------------------------------------------------------
TOTAL                             103      9      8      4    88%

注意:除第一次统计外,后面的统计最好按照 coverage erase --> coverage run manage.py test —> coverage report/html 的顺序来统计,这样做的目的是避免上一次统计结果影响下一次统计

另外 coverage html 可生成更为详细的统计报告,coverage report 只能查看未覆盖的范围的行号,不够直观。但是 coverage html 生成的 index.html 通过浏览器打开,点击相关文件可以可视化查看具体的范围来查看代码的覆盖情况,更为直观。

参考文章

  • Django 单元测试:http://www.itheima.com/news/20200807/182220.html
  • 统计 Django 项目的测试覆盖率:https://blog.csdn.net/a419240016/article/details/104708147
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

风老魔

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值