不做单元测试的程序员不是好程序员。最近我在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 如下:

#!/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

欢迎关注我的微信公众号:窥豹

窥豹