不做单元测试的程序员不是好程序员。最近我在Pylons下面做开发, 使用 nose 做单元测试,颇有心得, 在这里分享一下。

1. Pylons中依赖包

先简单介绍一下Pylons, Pylons与其说是一个框架,不如说是一堆框架的组合, Pylons在其中做到一个胶水的作用。Pylons依赖的包如下。

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 如下:

1
2
3
4
#!/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

这样虽然不能从根本上解决问题,但是至少规避了问题。


原文链接: https://blog.alswl.com/2011/09/nose/
3a1ff193cee606bd1e2ea554a16353ee
欢迎关注我的微信公众号:窥豹

Comments

comments powered by Disqus