不做单元测试的程序员不是好程序员。最近我在Pylons下面做开发, 使用 nose 做单元测试,颇有心得, 在这里分享一下。
1. Pylons中依赖包
先简单介绍一下Pylons, Pylons与其说是一个框架,不如说是一堆框架的组合, Pylons在其中做到一个胶水的作用。Pylons依赖的包如下。
- breaker,缓存和Session
- FormEncode,用户输入检查
- Mako,模板渲染
- nose,自动化测试
- Paste,服务器
- Routes, 路由
- Tempita,Paste的模板
- Weberror
- WebOb,提供WSGI请求响应等对象
- WebTest,Paste自带的测试小框架, 提供TestResponse和TestRequest两个有用的小东西
Pylons的测试主要使用的其中的 Paste / nose / WebOb / WebTest。 遇到问题的时候,可以去翻一翻上面的文档。
2. Pylons中测试目录结构
目录结构如下
├─config
├─controllers
├─lib
├─model
├─public
├─templates
└─tests
└─functional
目录中的 config / controllers / lib / model / public
在不同的web框架下面可能会略有差别,在这里我不关注他们,我关注 tests / functional
中存放相应的测试脚本,比如
test_user.py
3. 第一个简单的测试用例
3.1. 撰写单元测试文件
最简单的test脚本如下
from myb.tests import *
class TestIndexController(TestController):
def test_index(self):
pass
# Test response...
这里我们从 myb.tests
这个目录下面引入了所有包 (其实起作用的是 __init__.py
)
__init__.py
如下:
#!/usr/bin/env python
#coding: utf-8
from webob.headers import ResponseHeaders
from unittest import TestCase
from paste.deploy import loadapp
from paste.script.appinstall import SetupCommand
from pylons import url
from routes.util import URLGenerator
from webtest import TestApp
import pylons.test
__all__ = ['environ', 'url', 'TestController']
# Invoke websetup with the current config file
SetupCommand('setup-app').run([pylons.test.pylonsapp.config['__file__']])
environ = {}
class TestController(TestCase):
def __init__(self, *args, **kwargs):
wsgiapp = pylons.test.pylonsapp
config = wsgiapp.config
self.app = TestApp(wsgiapp)
url._push_object(URLGenerator(config['routes.map'], environ))
TestCase.__init__(self, *args, **kwargs)
可以看到,这里使用了 TestController
继承了 TestCase
这个单元测试基类, 并且在里面进行了web应用的环境初始化。
3.2. 撰写测试配置文件
上文撰写了一个最简单的测试代码,我们接着做一些单元测试配置。
在app应用的同级文件里面,修改 test.ini
文件。
[DEFAULT]
debug = true
#email_to = you@yourdomain.com
smtp_server = localhost
error_email_from = paste@localhost
[server:main]
use = egg:Paste#http
host = 127.0.0.1
port = 5000
[app:main]
use = config:development.ini
sqlalchemy.url = mysql://username:password@localhost/myb_test?charset=utf8&use_unicode=1
# Logging configuration
[loggers]
keys = root, routes, myb, sqlalchemy
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = INFO
handlers = console
[logger_routes]
level = INFO
handlers =
qualname = routes.middleware
# "level = DEBUG" logs the route matched and routing variables.
[logger_myb]
level = DEBUG
handlers =
qualname = myb
[logger_sqlalchemy]
level = INFO
handlers =
qualname = sqlalchemy.engine
# "level = INFO" logs SQL queries.
# "level = DEBUG" logs SQL queries and results.
# "level = WARN" logs neither. (Recommended for production systems.)
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(asctime)s,%(msecs)03d %(levelname)-5.5s [%(name)s] [%(threadName)s] %(message)s
datefmt = %H:%M:%S
这个配置文件设定了基本调试信息,数据库(使用myb_test数据库来避免修改原始数据) ,log方式。
在 [app:main]
里面,我直接引用了 development.ini
的配置。
3.3. 运行nose
在shell里面切换到app所在的目录(test.ini)所在的目录,运行 nosetests myb/tests/functional/test_hello world.py
。 之后会出现一些log内容,不出意外的话,应该出现 OK
。
如果遇到 FAILED
,那就根据错误提示的信息来查错。 nose会输出log的信息和print标准输出的信息。
4. 高级一点的测试方法
在开发过程中,我们需要判定单元测试是否正确,我罗列一些常见的用法
4.1. 测试返回类型为HTTP STATUS的方法
每次HTTP请求都会返回HTTP STATUS,正常是200,找不到是404,服务器错误是500, 我们可以根据这些返回状态值来判断测试是否跑通。
class TestQuestionController(TestController):
def test_suggest_question(self):
#正常返回200
response = self.app.get(url=url(controller='question',
action='suggest_question',
),
params={
},
headers=self.headers,
status=200,
)
#不存在的id返回404
response = self.app.get(url=url(controller='question',
action='suggest_question',
),
params={
'id': '345',
},
headers=self.headers,
status=404,
)
我习惯使用 url()
方法来生成url,这样一方面不用记住冗长的url, 另外在url路由表发生变化之后,也不用去改变测试代码。
4.2. 测试返回类型为html的方法
def test_register(self):
response = self.app.post(url(controller = 'users',
action = 'register',
format = 'json'),
{
'login_name': 'nose_json',
'login_pass': '123',
'user_name': '测试机器人_json',
},
status=200
)
assert '202cb962ac59075b964b07152d234b70' in response.body #返回的加密密码
#log.debug( u'器' in response.unicode_body) #无法测试中文
#log.debug( u'测试机器人_json' in response.unicode_body) #无法测试中文
使用 response.body
来判定html里面的内容(这里对中文支持不太好)。
4.3. 测试返回类型为json的方法
AJAX请求正常返回的状态吗都是200,我们需要判定里面的内容进行assert
response = self.app.post(url=url(controller='invitation',
action='invite_by_mail'),
params={
'to_address': '',
'to_user_name': '大爷',
},
headers=self.headers,
status=200
)
result = response.json
assert(result['success'] == False)
assert(result['message'] == u'发送失败:你妹不漂亮')
4.4. 测试返回类型为重定向的方法
这是HTTP状态吗的特殊形式,比如登录之后做一次跳转之类的。
def test_add(self):
#成功之后返回302做跳转,同时判定返回内容中跳转路径
response = self.app.post(url=url(controller='question',
action='add',
),
params={
'question_title': 'hwti1',
'question_content': 'wgtinzrs1',
},
headers=self.headers,
status=302,
)
assert re.match(r'^http://localhost/question/d*',
response.headers['Location'])
4.5. 用户登录生成Session
有些方法需要登录后才能运行,这依赖于服务器和浏览器之间的Cookie。如果要对这类 方法进行测试,我们需要事先获取Cookie,再在每一次请求发出的时候附带这个Cookie。
在下面的方法中,我实现了用户登录操作。 在test目录下的 __init.py__
中 TestController
加入新方法 login()
def login(self, login_name, login_pass):
"""
用户登录操作,获取Cookie
"""
response = self.app.post(url=url(controller='users',
action='login'),
params={
'login_name': login_name,
'login_pass': login_pass,
},
)
cookie = response.headers.getall('Set-cookie')[0]
self.headers = ResponseHeaders()
self.headers.add('Cookie', cookie)
这样就可以通过 self.headers
保存登录之后的cookie。
4.6. 批量测试
除了制定 test_xxx.py
文件进行单元测试,我们还可以直接使用 nosetests
测试所有测试用例。
nosetests
//该目录下需要存在 test.ini 配置文件
5. 遇到的问题
5.1. 编码问题
File "buildbdist.win32eggwebtest__init__.py", line 211, in post
content_type=content_type)
File "buildbdist.win32eggwebtest__init__.py", line 191, in _gen_request
expect_errors=expect_errors)
File "buildbdist.win32eggwebtest__init__.py", line 370, in do_request
res = req.get_response(app, catch_exc_info=True)
File "buildbdist.win32eggwebobrequest.py", line 1004, in get_response
application, catch_exc_info=True)
File "buildbdist.win32eggwebobrequest.py", line 977, in call_application
app_iter = application(self.environ, start_response)
File "buildbdist.win32eggwebtestlint.py", line 170, in lint_app
iterator = application(environ, start_response_wrapper)
File "d:programmingpython26libsite-packagespaste-1.7.5.1-py2.6.eggpastecascade.py", line 130, in __call__
return self.apps[-1](environ, start_response)
File "d:programmingpython26libsite-packagespaste-1.7.5.1-py2.6.eggpasteregistry.py", line 379, in __call__
app_iter = self.application(environ, start_response)
File "d:programmingpython26libsite-packagespylons-1.0-py2.6.eggpylonsmiddleware.py", line 150, in __call__
self.app, environ, catch_exc_info=True)
File "d:programmingpython26libsite-packagespylons-1.0-py2.6.eggpylonsutil.py", line 48, in call_wsgi_application
app_iter = application(environ, start_response)
File "d:programmingpython26libsite-packagesweberror-0.10.3-py2.6.eggweberrorevalexception.py", line 235, in __call__
return self.respond(environ, start_response)
File "d:programmingpython26libsite-packagesweberror-0.10.3-py2.6.eggweberrorevalexception.py", line 418, in respond
return self.application(environ, start_response)
File "d:programmingpython26libsite-packagesbeaker-1.5.4-py2.6.eggbeakermiddleware.py", line 152, in __call__
return self.wrap_app(environ, session_start_response)
File "d:programmingpython26libsite-packagesroutes-1.12.3-py2.6.eggroutesmiddleware.py", line 131, in __call__
response = self.app(environ, start_response)
File "d:programmingpython26libsite-packagespylons-1.0-py2.6.eggpylonswsgiapp.py", line 107, in __call__
response = self.dispatch(controller, environ, start_response)
File "d:programmingpython26libsite-packagespylons-1.0-py2.6.eggpylonswsgiapp.py", line 312, in dispatch
return controller(environ, start_response)
File "F:workxintongworkspaceMYB_WENDAmybmyblibbase.py", line 52, in __call__
return WSGIController.__call__(self, environ, start_response)
File "d:programmingpython26libsite-packagespylons-1.0-py2.6.eggpylonscontrollerscore.py", line 266, in __call__
return response(environ, self.start_response)
File "d:programmingpython26libsite-packageswebob-1.0.7-py2.6.eggwebobexc.py", line 517, in __call__
environ, start_response)
File "d:programmingpython26libsite-packageswebob-1.0.7-py2.6.eggwebobexc.py", line 341, in __call__
return self.generate_response(environ, start_response)
File "d:programmingpython26libsite-packageswebob-1.0.7-py2.6.eggwebobexc.py", line 322, in generate_response
body = self.plain_body(environ)
File "d:programmingpython26libsite-packageswebob-1.0.7-py2.6.eggwebobexc.py", line 301, in plain_body
body = self._make_body(environ, no_escape)
File "d:programmingpython26libsite-packageswebob-1.0.7-py2.6.eggwebobexc.py", line 294, in _make_body
args[k] = escape(v)
File "d:programmingpython26libsite-packageswebob-1.0.7-py2.6.eggwebobexc.py", line 182, in no_escape
value = str(value)
File "d:programmingpython26libsite-packagespylons-1.0-py2.6.eggpylonsutil.py", line 112, in __repr__
value_repr = repr(value)
UnicodeEncodeError: 'ascii' codec can't encode characters in position 8-18: ordinal not in range(128)
这是一个明显由编码引起的错误。
修改pylons-1.0-py2.6.eggPylonsutil.py中112行修改为
try:
value_repr = repr(value)
except UnicodeEncodeError, e:
log.error('encode error in pylons/utils.py')
continue
这样虽然不能从根本上解决问题,但是至少规避了问题。
原文链接: 使用nose做测试 | Log4D
3a1ff193cee606bd1e2ea554a16353ee
欢迎关注我的微信公众号:窥豹