單元測試是個好東西,但是如果程式碼中多處有資料庫訪問(讀/寫),或者程式碼中包含一些複雜的物件,真實環境中難以被觸發的物件的時候,該如何寫單元測試呢?
使用模擬物件機制測試python程式碼,模擬物件(mock object)可以取代真實物件的位置,用於測試一些與真實物件進行互動或依賴真實物件的功能,模擬物件的目的就是建立一個輕量級的,可控制的物件來代替測試中需要的真實物件,模擬真實物件的行為和功能,方便測試。
Stub和Mock以及Fake的理解
Stub: For replacing a method with code that returns a specified result
簡單來說就是可以用stub去fake(偽造)一個方法,阻斷原來方法的呼叫
Mock: A stub with an expectations that the method gets called
簡單來說mock就是stub + expectation, 說它是stub是因為它也可以像stub一樣偽造方法,阻斷對原來方法的呼叫, expectation是說它不僅偽造了這個方法,還期望你(必須)呼叫這個方法,如果沒有被呼叫到,這個test就fail了
Fake: objects actually have working implementations, but usually take some shortcut which makes them not suitable for production
簡單來說就是一個真實物件的一個輕量級的完整實現
mock物件的使用範疇: 真實物件具有不可確定的行為,產生不可預測的結果(如:天氣預報) 真實物件很難被建立 真實物件的某些行為很難被觸發
Fudge
Fudge是一個類似於Java中的JMock的純python的mock測試模組,主要功能就是可以偽造物件,替換程式碼中真實的物件,來完成測試。fudge主要用來模擬那些在應用中不容易構造或者比較複雜的物件(如專案中涉及mongodb或者redis模組,使用fudge後在測試的時候可以不需要真正的redis環境就能測試程式碼),從而使測試順利進行。
如何使用FUDGE
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import twitter_oauth #pip install twitter_oauth consumer_key = '***' consumer_secret = '***' oauth_token = "***" oauth_token_secret = '***' def post_msg_to_twitter(msg): # create GetOauth instance get_oauth_obj = twitter_oauth.GetOauth(consumer_key, consumer_secret) # create Api instance api = twitter_oauth.Api(consumer_key, consumer_secret, oauth_token, oauth_token_secret) # post update api.post_update(u'Hello, Twitter:' + msg) print("send:%s" % msg) |
因為twitter_oauth
是獨立的模組,因此只要呼叫了正確的方法,post_msg_to_twitter
方法就一定能正確執行。Twitter在大陸沒法直接請求訪問,那怎麼測試知道它沒有問題呢? 使用fudge就能完成我們的任務,把twitter相關的物件偽造(fake)出來,只要我們自己的業務邏輯測試正確,那麼測試就通過。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import fudge @fudge.patch('twitter_oauth.GetOauth', 'twitter_oauth.Api') def test_post_msg_to_twitter(msg, FakeGetOauth, FakeApi): FakeGetOauth.expects_call() \ .with_args('***', '***') FakeApi.expects_call() \ .with_args('***', '***', '***', '***') \ .returns_fake() \ .expects('post_update').with_args(u'Hello, Twitter:okey') post_msg_to_twitter(msg) if __name__ == '__main__': test_post_msg_to_twitter('okey') |
- patch裝飾器會在測試階段根據裝飾器裡面的引數偽造物件,作為測試方法
test_post_to_twitter
的引數。這些偽造的物件就是stub或者mock或者是fake - Fudge可以根據你的需求嚴謹或隨意的宣告expectation。
- 如果你不關心具體的引數,就可以呼叫
fudge.Fake.with_args()
不需要指定任何引數,如果要指定的話就必須是指定正確的引數(換句話說就是不能隨意指定) - 如果你不關心方法呼叫與否,那麼就可以用
fudge.Fake.provides()
代替fudge.Fake.expects()
,這樣即使程式碼中沒有呼叫,測試用例也不會fail - 如果不關心方法的引數的具體值,那麼可以用
fudge.Fake.with_arg_count()
來代替fudge.Fake.with_args()
- 如果你不關心具體的引數,就可以呼叫
fudge模組
FUDGE
- fudge.patch(*obj_paths):測試裝飾器,裡面的引數都將作為fake物件將匯出作為測試方法的引數使用。
123@fudge.patch('os.remove')def test(fake_remove):#do sutff
patch方法會去呼叫fudge.clear_calls(),fudge.verify()和fudge.clear_expectations(),verify()方法才是真正驗證所有方法是不是按照期待的那些呼叫了。
123def test():db = fudge.Fake('db').expects('connect')# fudge.verify()
上面這個test函式如果沒有用fudge.patch(), fudge.test() 或者 fudge.with_fakes()修飾,那麼fudge就不會主動去驗證方法是否得到執行,必須加上fudge.verify()方法才會觸發呼叫。加上verify()就會提示你connect沒有被呼叫:
123File "E:\Python27\lib\site-packages\fudge-1.0.3-py2.7.egg\fudge\__init__.py", line 453, in assert_calledraise AssertionError("%s was not called" % (self))AssertionError: fake:db.connect() was not called- fudge.test:裝飾器,直接使用fake,而不是通過patch@fudge.test def test(): db = fudge.Fake(‘db’).expects(‘connect’) 不過絕大多數時候你都應該使用fudge.patch而不是fudge.test
- fudge.Fake:這個一個類,用來替換真實物件的fake物件,如上例
- fudge.calls(call):重新定義一個call,相當於給call換一個名字
123456def hello():print "hello there"def test_calls():f = fudge.Fake().provides("anthor_hello").calls(hello)f.anthor_hello() #輸出"hello there" - expects(call_name):表示期待呼叫call_name方法
- expects_call():表示該對像將得到呼叫
- provides(call_name):這個方法與expects的區別是call_name可以沒有被呼叫
更多參考:fudge
FUDGE.INSPECTOR
fudge.inspector.ValueInspector
例項可以作為一種更具表現力的物件(Value inspector)傳遞給fudge.Fake.with_args()
方法,為了更方便記憶ValueInspector例項簡稱為arg
:
1 2 |
from fudge.inspector import arg image = fudge.Fake('image').expects('save').with_args(arg.endswith('.jpg')) |
上面的測試程式碼就表示傳遞給save方法的引數必須是以.jpg
結尾的值,否則測試沒法通過
- arg.any():表示沒有任何約束
- contains(part):必須包含指定的part引數
- has_attr(**attributes):傳遞給方法的引數必須有屬性在指定的attributes中
-
123456789101112class User:first_name="Bob"last_name = "James"job = "jazz musician"def test_has_attr():from fudge.inspector import argdb = fudge.Fake('db').expects("update").with_args(arg.has_attr(first_name="Bob",last_name="James"))db.update(User())
- passes_test(test):引數傳遞到test函式中必須返回True才能通過測試def is_valid(s): assert s in [‘apple’, ‘ms’, ‘fb’], (‘unexpected company %s’ % s) return Truedef test_passes_test(): system = fudge.Fake(‘system’).expects(‘set_company’).with_args(arg.passes_test(is_valid)) system.set_company(‘fb’)
- startswith(part):引數必須以part開頭
更多參考:fudge.inspector
FUDGE.PATCHER
- fudge.patcher.with_patched_object(obj, attr_name, patched_value):裝飾器,在被裝飾的方法呼叫前給attr_name一個新的值patched_value,方法執行完以後attr_name再恢復成原來的值
-
123456789101112from fudge import with_patched_objectclass Session:state = 'clean'@with_patched_object(Session, "state", 'dirty')def test():print(Session.state)if __name__ == "__main__":test()print (Session.state)
輸出:
12dirtyclean
這樣做的好處就是能獨立於每個測試而不影響原來物件的完整性。 - fudge.pathcher.patched_context(obj, attr_name, patched_value):作用和上面的with_patched_object一樣,只是用法上不一樣而已,他的用法是:
12with patched_context(Session, 'state', 'dirty'):print Sessioin.state
就是with語句的使用方式。
TORNADO.TEST
由於python的單元測試模組式同步的,測試tornado中的非同步程式碼有三種方式
- 使用類似tornado.gen的yield生成器 tornado.testing.gen_test.
1234567class MyTestCase(AsyncTestCase):@tornado.testing.gen_testdef test_http_fetch(self):client = AsyncHTTPClient(self.io_loop)response = yield client.fetch("http://www.tornadoweb.org")# Test contents of responseself.assertIn("FriendFeed", response.body)- 2. 手工方式呼叫self.stop,self.waitclass MyTestCase2(AsyncTestCase): def test_http_fetch(self): client = AsyncHTTPClient(self.io_loop) client.fetch(“http://www.tornadoweb.org/”, self.stop) response = self.wait() # Test contents of response self.assertIn(“FriendFeed”, response.body)
- 3. 回撥函式的方式:class MyTestCase3(AsyncTestCase): def test_http_fetch(self): client = AsyncHTTPClient(self.io_loop) client.fetch(“http://www.tornadoweb.org/”, self.handle_fetch) self.wait() def handle_fetch(self, response): #此處產生的異常會通過stack+context傳播到self.wait方法中去 self.assertIn(“FriendFeed”, response.body) self.stop()
後兩者的原理是一樣的,wait方法會一直執行IOLoop,直到stop方法呼叫或者超時(timeout預設是5’s)2中的fetch的第二個引數self.stop相當於3中的self.handle_fetch,都是一個回撥函式,區別就在於把sotp當成回撥函式時,響應內容就會通過self.wait()函式返回,而像3中一樣寫一個自定義的回撥函式,響應內容就會作為引數傳遞給該函式。可以詳細檢視下tornado.testing.py這個檔案中的stop和wait方法。
預設情況下,每個單元會構造一個新的IOLoop例項,這個IOLoop是在構造HTTP clients/servers的時候使用。如果測試需要一個全域性的IOLoop,那麼就需要重寫get_new_ioloop方法。 原始碼:
1 2 3 4 5 6 7 8 9 10 11 12 |
def setUp(self): super(AsyncTestCase, self).setUp() self.io_loop = self.get_new_ioloop() self.io_loop.make_current() def get_new_ioloop(self): """Creates a new `.IOLoop` for this test. May be overridden in subclasses for tests that require a specific `.IOLoop` (usually the singleton `.IOLoop.instance()`). 獲取全域性IOLoop時,呼叫IOLoop.instance()這個單例方法即可 """ return IOLoop() |
tornado.testing.AsyncHTTPTestCase
這個類是AsyncTestCase的子類,一個測試用例會啟動一個HTTP server,AsyncHTTPTestCase的子類必須重寫get_app()
方法,這個方法返回tornado.web.Application
例項。application例項就是實際程式碼的application。
1 2 3 4 |
app = tornado.web.Application(handlers=[ (r'/sleep', SleepHandler), (r'/now', JustNowHandler) ]) |
返回這個app就好了。測試用例通常使用self.http_client
來請求(fetch)這個server上的url。
1 2 3 4 5 6 7 8 9 10 11 |
class MyHTTPTest(AsyncHTTPTestCase): def get_app(self): app = tornado.web.Application(handlers=[ (r'/sleep', SleepHandler), (r'/now', JustNowHandler) ]) return app def test_now(self): self.http_client.fetch(self.get_url('/'), self.stop) response = self.wait() self.assertIn('xx', response.body) #判斷返回的請求體中是否有字串`xx` |
其實self.http_client就是一個AsyncHTTPClient例項,從原始碼中檢視到:
1 2 |
def get_http_client(self): return AsyncHTTPClient(io_loop=self.io_loop) |