读书笔记">《Web接口开发与自动化测试基于Python语言》–读书笔记
第6章 Django测试
这章来到本书的正题了。
Web应用的难点在于: HTTP层面的请求处理、表单验证和处理、模板渲染;
Django框架的测试模块解决的问题: 模拟请求、插入测试数据、检查应用输出。
6.1 unittest单元测试框架
6.1.1 单元测试框架
误区:
不用单元测试框架一样可以编写单元测试,单元测试本质上就是通过一段代码去测试另一段代码;
单元测试框架不仅可以用于程序单元级别的测试,同样可以用于UI自动化测试、接口自动化测试,以及移动APP自动化测试。
单元测试框架:
提供用例编写规范与执行: 单元测试框架提供了统一的用例编写规范,灵活指定不同级别的测试,如针对一个测试方法、一个测试类、一个测试文件,或者一个测试目录等不同级别的测试。
提供专业的比较方法: 测试用例最关键的步骤,实际测试结果与预期结果的比较,单元测试将这个比较过程命名为“断言”,单元测试框架提供了丰富的断言方法,eg:相等/不相等,包含/不包含,True/False等。
提供丰富的测试日志: 单元测试框架提供了丰富的执行日志,当测试用例执行失败的时候会抛出明确的失败信息,测试完成后提供结果信息,失败用例数、成功用例数、执行时间等。
单元测试框架可帮助我们完成不同级别测试的自动化:
单元测试:unittest
HTTP接口自动化测试:unittest+Requests
Web UI自动化测试:unittest+Selenium
移动自动化测试:unittest+Appium
6.1.2 编写单元测试用例
简单示例:
对两个整数的简单计算module.py:
#! /usr/bin python # -*- coding:utf-8 -*- class Calculator(): """实现两个数的加、减、乘、除""" def __init__(self, a, b): self.a = int(a) self.b = int(b) # 加法 def add(self): return self.a + self.b # 减法 def sub(self): return self.a - self.b # 乘法 def mul(self): return self.a * self.b # 除法 def p(self): return self.a / self.b
编写对应的测试文件test.py:
#! /usr/bin python # -*- coding:utf-8 -*- import unittest from module import Calculator class ModuleTest(unittest.TestCase): def setUp(self): self.cal = Calculator(8, 4) def tearDown(self): pass def test_add(self): result = self.cal.add() self.assertEqual(result, 12) def test_sub(self): result = self.cal.sub() self.assertEqual(result, 4) def test_mul(self): result = self.cal.mul() self.assertEqual(result, 32) def test_p(self): result = self.cal.p() self.assertEqual(result, 2) if __name__ == "__main__": # unittest.main() # 构造测试集 suite = unittest.TestSuite() suite.addTest(ModuleTest("test_add")) suite.addTest(ModuleTest("test_mul")) suite.addTest(ModuleTest("test_sub")) suite.addTest(ModuleTest("test_p")) # 执行测试 runner = unittest.TextTestRunner() runner.run(suite)
通过unittest单元测试框架编写的测试用例,更加规范和整洁。
对代码进行解释:
首先,import导入unittest单元测试框架;
其次,创建ModuleTest类继承unittest.TestCase类;
setUp()方法,用于测试用例执行前的初始化工作,eg:初始化变量、生成数据库测试数据、打开浏览器等;
tearDown()方法,用于测试用例执行之后的善后工作,eg:清除数据库测试数据、关闭文件、关闭浏览器等;
然后,创建具体的测试用例,包含被测试数据、预期测试结果;
接下来,调用unittest.TestSuite()类的addTest()方法,向测试套件中添加测试用例,所谓测试套件可以理解为测试用例的集合;
最后,通过unittest.TextTestRunner()类的run()方法运行测试套件中的测试用例。
注意:
根据unittest单元测试框架的要求,测试用例必须以“test”开头,eg:test_add、test_mul;
如果想默认运行当前测试文件中的所有测试用例,可以使用:unittest.main()方法。
测试结果如下:
from django.test import TestCase from sign.models import Guest, Event # Create your tests here. class ModelTest(TestCase): def setUp(self): Event.objects.create(id=1, name="oneplus 3 event", status=True, limit=2000, address="shenzhen", start_time="2016-08-31 02:18:22") Guest.objects.create(id=1, event_id=1, realname="alen", phone='13711001101',email="alen@mail.com", sign=False) def test_event_models(self): result = Event.objects.get(name="oneplus 3 event") self.assertEqual(result.address, "shenzhen") self.assertTrue(result.status) def test_guest_models(self): result = Guest.objects.get(phone="13711001101") self.assertEqual(result.realname, "alen") self.assertFalse(result.sign)
对上述代码进行分析:
首先,还是创建ModelTest类继承django.test.TestCase测试类;
然后,setUp()方法,初始化针对发布会表和嘉宾表的测试数据;
最后,通过test_event_models()、test_guest_models()测试方法,分别查询创建的数据,并对返回结果进行断言是否符合预期;
注意:
千万不要单独执行tests.py文件,Django专门提供了test命令来运行测试,效果如下:
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'HOST': '127.0.0.1', 'PORT': '3306', 'NAME': 'guest', 'USER': 'root', 'PASSWORD': 'nsfocus', #'OPTIONS': { # 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", #}, } }
然后修改MySQL数据库的配置文件:/etc/mysql/mysql.conf.d/mysqld.cnf,增加配置:
sql_mode = ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION第二个警告就简单了,只需要将提示的内容直接从配置里去掉即可。
6.2.2 运行测试用例
test命令,提供了可以控制测试用例执行的级别。
运行sign应用下的所有测试用例:
#! /usr/bin python # -*- coding:utf-8 -*- from django.test import TestCase # Create your tests here. # 测试sign应用的视图 class IndexPageTest(TestCase): def test_index_page_renders_index_template(self): '''测试index视图''' response = self.client.get('/index/') # 虽然没有导入django.test.Client类,但是self.client最终调用的依然是django.test.Client类的方法,请求/index/路径 self.assertEqual(response.status_code, 200) # status_code获取HTTP返回的状态码,使用assertEqual断言状态码是否为200 self.assertTemplateUsed(response, 'index.html') # 使用assertTemplateUsed()断言服务器是否使用的是index.html模板进行响应
6.3.2 测试登录动作
继续使用上面的方法对首页的登录动作进行测试,修改/guest/sign/tests.py:
class LoginActionTest(TestCase): '''测试登录动作''' def setUp(self): # 初始化,调用User.objects.create_user创建登录用户数据 User.objects.create_user('admin', 'admin@mail.com', 'admin123456') def test_add_admin(self): '''测试添加的用户数据是否正确''' user = User.objects.get(username='admin') self.assertEqual(user.username, 'admin') self.assertEqual(user.email, 'admin@mail.com') # 注意这里书中有误,user表里的字段是email而不是mail,否则会报错 def test_login_action_username_password_null(self): '''测试用户名密码为空''' test_data = {'username':'', 'password': ''} response = self.client.post('/login_action/', data=test_data) # 通过post()方法请求'/login_aciton/'路径测试登录功能 self.assertEqual(response.status_code, 200) self.assertIn(b'username or password error!', response.content) # assertIn()方法断言返回的HTML页面中是否包含指定的提示字符串 def test_login_action_username_password_error(self): '''测试用户名密码错误''' test_data = {'username':'abc', 'password':'123'} response = self.client.post('/login_action/', data=test_data) self.assertEqual(response.status_code, 200) self.assertIn(b'username or password error!', response.content) def test_login_action_success(self): '''测试登录成功''' test_data = {'username':'admin', 'password':'admin123456'} response = self.client.post('/login_action/', data=test_data) self.assertEqual(response.status_code, 302) # 这里为什么断言的是302,是因为登录成功后,通过HttpResponseRedirect()跳转到了'/event_manage/'路径,这是一个重定向
6.3.3 测试发布会管理
继续使用上面的方法对发布会管理视图进行测试,修改/guest/sign/tests.py:
class EventManageTest(TestCase): """测试发布会管理""" def setUp(self): '''初始化测试数据,包括登录用户数据,发布会数据''' User.objects.create_user('admin', 'admin@mail.com', 'admin123456') Event.objects.create(name='xiaomi5', limit=2000, address='beijing', status=1, start_time='2017-08-10 12:30:00') self.login_user = {'username':'admin', 'password':'admin123456'} # 定义登录变量 def test_event_manage_success(self): '''测试发布会:xiaomi5''' response = self.client.post('/login_action/', data=self.login_user) response = self.client.post('/event_manage/') self.assertEqual(response.status_code, 200) self.assertIn(b'xiaomi5', response.content) self.assertIn(b'beijing', response.content) def test_event_manage_search_success(self): '''测试发布会搜索''' # 这里自己给自己挖了个坑,post登录请求的时候少写了一个/,当时写成了'/login_action',我擦一执行测试就返回302,排查了好半天才发现,哎,需要认真仔细啊 response = self.client.post('/login_action/', data=self.login_user) response = self.client.post('/search_name/', {'name':'xiaomi5'}) self.assertEqual(response.status_code, 200) self.assertIn(b'xiaomi5', response.content) self.assertIn(b'beijing', response.content)
注意:
由于发布会管理event_manage和发布会名称搜索search_name两个视图都被@login_required装饰器修饰,所以想测试这两个功能,必须要先登录成功,并且需要构造登录用户的数据。
6.3.4 测试嘉宾管理
继续使用上面的方法对嘉宾管理视图进行测试,修改/guest/sign/tests.py:
class GuestManageTest(TestCase): """测试嘉宾管理""" def setUp(self): '''还是使用setUp初始化一些测试数据''' User.objects.create_user('admin', 'admin@mail.com', 'admin123456') Event.objects.create(id=1, name='xiaomi5', limit=2000, address='beijing', status=1, start_time='2017-08-10 12:30:00') Guest.objects.create(realname='alen', phone=18611001100, email='alen@mail.com', sign=0, event_id=1) self.login_user = {'username':'admin', 'password':'admin123456'} def test_event_manage_success(self): '''测试嘉宾信息:alen''' response = self.client.post('/login_action/', data=self.login_user) response = self.client.post('/guest_manage/') self.assertEqual(response.status_code, 200) self.assertIn(b'alen', response.content) self.assertIn(b'18611001100', response.content) def test_guest_manage_search_success(self): '''测试嘉宾搜索功能''' response = self.client.post('/login_action/', data=self.login_user) # 这里就是坑了,我们根据书中描述一步一步来得话,我们在views.py里定义的搜索功能是根据名字来搜索的,而不是根据手机号,下面应该修改为('/search_realname/', {'realname':'alen'}) # response = self.client.post('/search_phone/', {'phone':'18611001100'}) response = self.client.post('/search_realname/', {'realname':'alen'}) self.assertEqual(response.status_code, 200) self.assertIn(b'alen', response.content) self.assertIn(b'18611001100', response.content)
上面的代码,虫师给大家挖了很多坑,如果只编写了测试代码而未进行实际测试,是不会发现有问题的,我已经备注了,大家参见上面的备注吧。
其他知识点没有什么,基本和上面的类似,都是setUp初始化测试数据,然后分别对两个视图函数进行测试。
6.3.5 测试用户签到
继续使用上面的方法对签到管理视图进行测试,修改/guest/sign/tests.py:
class SignIndexActionTest(TestCase): """测试发布会签到""" def setUp(self): User.objects.create_user('admin', 'admin@mail.com', 'admin123456') Event.objects.create(id=1, name="xiaomi5", limit=2000, address='beijing', status=1, start_time='2017-8-10 12:30:00') Event.objects.create(id=2, name="oneplus4", limit=2000, address='shenzhen', status=1, start_time='2017-6-10 12:30:00') Guest.objects.create(realname="alen", phone=18611001100, email='alen@mail.com', sign=0, event_id=1) Guest.objects.create(realname="una", phone=18611011101, email='una@mail.com', sign=1, event_id=2) self.login_user = {'username':'admin', 'password':'admin123456'} def test_event_models(self): '''测试添加的发布会数据''' result1 = Event.objects.get(name='xiaomi5') self.assertEqual(result1.address, 'beijing') self.assertTrue(result1.status) result2 = Event.objects.get(name='oneplus4') self.assertEqual(result2.address, 'shenzhen') self.assertTrue(result2.status) def test_guest_models(self): '''测试添加的嘉宾数据''' result = Guest.objects.get(realname='alen') self.assertEqual(result.phone, '18611001100') self.assertEqual(result.event_id, 1) self.assertFalse(result.sign) def test_sign_index_action_phone_null(self): '''测试手机号为空''' response = self.client.post('/login_action/', data=self.login_user) response = self.client.post('/sign_index_action/1/', {"phone":""}) self.assertEqual(response.status_code, 200) self.assertIn(b"phone error.", response.content) def test_sign_index_action_phone_or_event_id_error(self): '''测试手机号或发布会id错误''' response = self.client.post('/login_action/', data=self.login_user) response = self.client.post('/sign_index_action/2/', {"phone":"18611001100"}) self.assertEqual(response.status_code, 200) self.assertIn(b"event id or phone error.", response.content) def test_sign_index_action_user_sign_has(self): '''测试嘉宾已签到''' response = self.client.post('/login_action/', data=self.login_user) response = self.client.post('/sign_index_action/2/', {"phone":"18611011101"}) self.assertEqual(response.status_code, 200) self.assertIn(b"user has sign in.", response.content) def test_sign_index_action_sign_success(self): '''测试嘉宾签到成功''' response = self.client.post('/login_action/', data=self.login_user) response = self.client.post('/sign_index_action/1/', {"phone":"18611001100"}) self.assertEqual(response.status_code, 200) self.assertIn(b"sign in success!", response.content)
测试嘉宾签到功能只是在数据初始化构造上内容多了,测试的点覆盖了签到功能全部分支,都比较好理解,只是在最终执行测试结果的时候,我崩溃了,6个测试用例中出现了2个失败,详细失败原因见下面:
def test_sign_index_action_phone_null(self): '''测试手机号为空''' response = self.client.post('/login_action/', data=self.login_user) response = self.client.post('/sign_index_action/1/', {"phone":""}) self.assertEqual(response.status_code, 200) self.assertIn(b"phone error.", response.content) def test_sign_index_action_sign_success(self): '''测试嘉宾签到成功''' response = self.client.post('/login_action/', data=self.login_user) response = self.client.post('/sign_index_action/1/', {"phone":"18611001100"}) self.assertEqual(response.status_code, 200) self.assertIn(b"sign in success!", response.content)
发现了一个共同点,这两个测试用例都是针对测试数据发布会id=1的,而与之关联的测试嘉宾的签到sign值在初始化的时候是sign=0,也就是未签到的状态。
将涉及到的两个值,分别做修改,如果我把这两个用例里的发布会id从1改为2,会发现再次执行的结果里“测试手机号为空”执行成功了,但是“测试嘉宾签到成功”依然还是失败的,捋一下,改成2,也就是对应的嘉宾已经签到了,所以“测试嘉宾签到成功”失败也是自然的。
如果我们把初始化数据里的嘉宾alen的sign改为1已签到,再次执行的时候,发现结果和上面一样,都是“测试手机号为空”能通过,但是“测试嘉宾签到成功”失败。
这里也真的是奇怪了,为什么会出现这种情况,我现在抛开测试数据,直接去看下真实的数据情况,因为之前为了验证嘉宾签到代码,已经全部都签到了,只能通过修改数据库的方式,将sign从1改为0,点击发布会页面的sign链接,发现的确会报404错误,奇怪了,为什么没嘉宾签到,就返回404错误呢?按理来说,即使没有嘉宾签到,从发布会点击签到页面,也应该展示签到页面,只不过显示的已签到数为0。
自己在这段时间里,跑偏了很久,想过是不是签到功能不完善,难道需要先将sign从初始化的0update为1,再执行测试用例?或者是提示404,找不到页面,那我就单独把sign_index_action的html从sign_index.html里独立出来?……
自己真的是跑偏了太久,休假前到休假后,中间隔了快一周时间,再次查看views.py里的签到功能代码才发现问题所在:
并不是虫师给大家挖坑,而是自己给自己挖了一个大坑!自己在做虫师的作业的时候,也就是在签到页面显示总的嘉宾数和已签到嘉宾数的时候,使用了一个擅自查资料使用的方法:get_list_or_404,一切的罪过都是由它而来,我们来看下面的代码就:
# 签到页面 @login_required def sign_index(request, eid): username = request.session.get('user', '') event = get_object_or_404(Event, id=eid) guest_list = len(get_list_or_404(Guest, event_id=eid)) guest_sign = len(get_list_or_404(Guest, event_id=eid, sign=1)) return render(request, 'sign_index.html', {"user": username, "event": event, 'guest_list': guest_list, 'guest_sign': guest_sign})
这里使用的get_list_or_404()方法,当sign=1的时候并没有问题,因为始终都能获取到已经签到的嘉宾数量,但是一旦sign=0,就会导致get_list_or_404方法直接返回404错误,而不是返回0个已签到嘉宾,自己当时以为查找到了一个类似get_object_or_404相类似的好方法去获取嘉宾数量,但是没想到当获取不到嘉宾数量的时候应该怎样展示!聪明反被聪明误啊!
那么该如何去修改呢?其实很简单,只要正常去查询数据库,获取到嘉宾总数和已签到嘉宾数量就可以了,修改后的代码如下:
# 签到页面 @login_required def sign_index(request, eid): username = request.session.get('user', '') event = get_object_or_404(Event, id=eid) guest_list = len(Guest.objects.filter(event_id=eid)) guest_sign = len(Guest.objects.filter(event_id=eid, sign=1)) return render(request, 'sign_index.html', {"user": username, "event": event, 'guest_list': guest_list, 'guest_sign': guest_sign})
修复后,再去执行测试用例,就全部通过了。
6.4 总结
至此,本章关于Django测试的内容就结束了,总结起来,就是如下几点:
Django的test库,提供了丰富的单元测试方法;
test库中的TestCase方法可以测试模型、视图;
每个测试用例必须以test命名开头;
一些断言关键字如:assertEqual判断是否相等、assertFalse判断为否、assertTrue判断为是、assertIn判断包含;
基本的测试套路就是构造数据,对指定测试内容传递数据进行测试,对返回结果进行判断。
更多关于Django测试方法技巧请参见官方文档:
Django测试部分官方文档