搜尋

Evan Gui

Love food and program!

分類

Python

輕鬆的 Python 專案

Python 是一個方便且強大的語言,給予開發者極大的彈性,但是這樣的彈性也造成很多開發上的混亂,例如不同的開發與部屬環境、工程師不同的開發習慣,所幸的是有不少的工具可以幫助我們解決這些問題,當環境與套件等問題被解決後,大家可以集中更多心力在程式上,藉由這些工具的組合可以讓團隊開發更輕鬆。

  1. 首先需要以下這4個工具:
    • git:版本控制
    • virtualenv:將專案所需要的環境獨立開來,避免污染
    • pip:套件安裝與管理
    • make:腳本工具,簡化專案控制
  2. 開啟一個 python 的專案,在裡頭包含以下 3 個檔案:
    • .gitignore:濾掉不需要被紀錄的檔案類型
    • requirements.txt:指名清楚需要的套件與版本
    • Makefile:描述在專案中會需要的操作指令
  3. 透過 git 與 make 來操作專案
    Clone project
    git clone https://repository/...
    
    為專案開虛擬環境,在 Makefile 中有預先定義好了 build27, build32, build33, build34, build35
    make build35
    source local/bin/activate
    
    安裝我們在 requirements.txt 指定的套件
    make install
    
    執行測試
    make test
    
    清除 pyc, pyo 或一些暫存的檔案
    make clean
    


以下再分別對.gitignore, requirements.txt, makefile作描述

.gitignore

用定義檔來忽略不需要被紀錄到 git 中的類型,保持專案的乾淨,這是別人整理好的各種 gitignore templates

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]

# C extensions
*.so

# Distribution / packaging
bin/
build/
develop-eggs/
dist/
eggs/
lib/
env/
include/
local/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg

# Installer logs
pip-log.txt
pip-delete-this-directory.txt
pip-selfcheck.json

# Unit test / coverage reports
.tox/
.coverage
.cache
nosetests.xml
coverage.xml

# Translations
*.mo

# Mr Developer
.mr.developer.cfg
.project
.pydevproject

# Rope
.ropeproject

# Django stuff:
*.log
*.pot

# Sphinx documentation
docs/_build/

requirements.txt

在 python 中可以利用 pip 來安裝套件,這是個非常好用的工具,除了PyPI上的套件外,也可以用 pip 從私人的 git repository 來安裝。我們透過 requirements format 來描述專案需要的套件及版本,之後只需要透過 pip install -r requirements.txt 就可以安裝好套件。

requirements.txt:

beautifulsoup4>=4.4
-e git://github.com/kennethreitz/requests/@v2.13.0#egg=requests

如果因為網路受阻,無法去抓取 PyPI 的套件時,可以透過自建一個 private PyPI repo 來處理。

Makefile

Make 是個非常好的腳本工具,藉由設定好對應的規則可以簡化操作,避免下重複指令或輸入錯誤的指令,在這裡參考 python project makefile 定義了這些規則: build27, build32, build33, build34, build35, install, test, clean等等

Makefile:

SHELL=/bin/bash
TEST_PATH=./tests

.PHONY: auto install test clean

auto: build35 install

build27:
	virtualenv local --python=python2.7 --never-download --system-site-packages

build32:
	virtualenv local --python=python3.2 --never-download --system-site-packages

build33:
	virtualenv local --python=python3.3 --never-download --system-site-packages

build34:
	virtualenv local --python=python3.4 --never-download --system-site-packages

build35:
	virtualenv local --python=python3.5 --never-download --system-site-packages

install:
	local/bin/pip install -r requirements.txt

clean-pyc:
	find . -name '*.pyc' -exec rm --force {} +
	find . -name '*.pyo' -exec rm --force {} +
	find . -name '*~' -exec rm --force {} +

clean-build:
	rm --force --recursive build/
	rm --force --recursive dist/
	rm --force --recursive *.egg-info

test: clean-pyc
	. local/bin/activate && python -m pytest "$(TEST_PATH)"

clean: clean-pyc clean-build
	rm --recursive --force local
廣告

用 Mock 來做 Python Unit Test

Untitled (1)

單元測試可以幫助我們確保開發時有按照目標的規格,並且在程式變動後可以檢查部份變動所造成的影響。Python 這個程式本身就提供了很好的單元測試模組 unittest,這其實已經足夠滿足大部分的測試需求,而到了 python3 時則更進一步將 mock 加入到 unittest 模組中,藉由這個強大的模組我們可以更有效率的來處理單元測試。
那究竟什麼是 mock 呢?在物件導向程式中,程式設計師可以藉由偽造的物件來替換要執行的部份程式,這樣的作法可以使得測試的目標變得更明確並且與其他程式獨立開來,不會因為其他沒有通過測試的物件而影響到現在測試的目標。下面利用一個簡單的例子來介紹 mock 在單元測試中的好處!

example.py:

def func1(x):
    return x**2

def func2(x):
    return func1(x) + x*5

tests.py:

import unittest
from example import func1, func2


class ExampleTest(unittest.TestCase):
    """Test example
    """
    def test_func2(self):
        self.assertEqual(func2(5), 50)
        self.assertEqual(func2(-5), 0)

在 example 這個模組中 func2 中 會呼叫 func1 並加上 x*5 然後回傳結果,如果要對 func2 做單元測試時,要先確定 func1 這支函式是正確如預期的運作,test_func2 中的 assertEqual 才能成立。
在簡單的程式中這樣做自然不是問題,但如果 func1 變得很複雜且又有使用到其他函數時,要怎麼保證 func2 在測試時不會受到 func1 的影響呢?這時候就可以利用 mock 來將我們的目標獨立開來。

tests.py:

import unittest
from unittest import mock
from example import func1, func2

class ExampleTest(unittest.TestCase):
    """Test example for mocking
    """
    @mock.patch('example.func1')
    def test_func2(self, mock_func1):
        mock_func1.return_value = 0
        self.assertEqual(func2(5), 25)
        mock_func1.return_value = 10
        self.assertEqual(func2(-5), -15)

在 tests.py 中:
1. 利用 mock 將 example module 中 func1 替換成 mock_func1,之後在 fun2 中碰到 func1 的時候就會變成使用 mock_func1
2. 而在這裡我們定義 mock_func1.return_value = 0,也就是之後不管 func1 的輸入是什麼都只會回傳 0 這個值
3. 藉由這樣的方式就可以把 func1 獨立開來,只要確認 func2 的邏輯是否有符合我們的目標

接下來我們就對 mock 的兩個常用方法做介紹,第一個是 Mock 這個 class,提供很好的方式幫我們偽造一個想要的物件,讓我們自由的訂定輸入和輸出,第二個則是 patch,這個裝飾器幫助我們處理模組層的名稱替換,像第一個範例 func2 會呼叫到 func1,如果我們想要使用 mock_func1,就必須要將模組中的名稱替換成我們的目標,之後func2就會去使用我們替換的目標而不是呼叫原本的 func1,而 patch 則幫助我們實踐這一塊。

MagicMock

MagicMock 是 Mock 的 subclass,他預先幫我們處理了 python 中的 magic method,如果想要處理一些如 get index 等事情,使用 MagicMock 會方便很多。

初始化一個 MagicMock 的物件,利用 return_value 這個 attribute,我們就可以給這個物件一個想要的回傳值,當物件被呼叫時就會回傳我們預先設定好的結果。

>>> mock_thing = MagicMock()
>>> mock_thing.return_value = 10
>>> mock_thing()
10

或者我們希望物件能夠照序列的給出結果,這時候就可以使用 side_effect,給予這物件一個 list,之後呼叫物件時就會根據 list 內的順序來回傳

>>> mock_thing.side_effect = [1, 3, 5]
>>> mock_thing()
1
>>> mock_thing()
3
>>> mock_thing()
5

那如果今天希望物件被呼叫時能得到 exception 該怎麼做呢?只要將 side_effect 指定想要的 exception 就可以了

>>> moch_thing.side_effect = Exception('HaHa')
>>> mock_thing()
Traceback (most recent call last):
...
Exception: HaHa

又或者想要模擬一個物件的 method 時該怎麼辦做?Mock 提供了很簡潔的方式,我們只需要在 mock 物件後面直接使用我們想要的 method name or attribute name

>>> mock_thing.some_method.return_value = 5
>>> mock_thing.some_method(1, 3, 5)
5

>>> mock_thing.some_method.side_effect = ['a', 'b']
>>> mock_thing.some_method()
a
>>> mock_thing.some_method()
b

>>> mock_thing.some_attribute = 'This is attribute!'
>>> mock_thing.some_attribute
This is attribute!

下面介紹幾個在測試中很實用的方法

1. called

這個 attribute 告訴我們 mock 的物件有沒有被呼叫過

>>> mock_thing = mock.MagicMock()
>>> mock_thing.called
False
>>> mock_thing()
>>> mock_thing.called
True

2. call_count

而這則是可以知道 mock 的物件被呼叫了幾次

>>> mock_thing.some_method2()
>>> mock_thing.some_method2()
>>> mock_thing.some_method2.call_count
2

3. assert_called_with(*args, **kwargs)

或者我們想測試物件是否有被輸入指定的參數來呼叫,如果沒有則會 trigger AssertionError

>>> mock_thing.some_method3(a=1, b=4)
>>> mock_thing.some_method3.assert_called_with(a=1, b=4)
>>> mock_thing.some_method3.assert_called_with(a=1, b=5)
Traceback (most recent call last):
...
raise AssertionError(_error_message()) from cause
AssertionError: Expected call: some_method3(a=1, b=5)
Actual call: some_method3(a=1, b=4)

4. call_args

列出被呼叫的參數

>>> mock_thing.some_method3(a=1, b=4)
>>> mock_thing.some_method3.call_args
call(a=1, b=4)

5. call_args_list

跟上面很像,不同的是會將曾經被呼叫過的都顯示出來

>>> mock_thing.some_method3(a=1, b=4)
>>> mock_thing.some_method3(a=1, b=5)
>>> mock_thing.some_method3.call_args_list
[call(a=1, b=4), call(a=1, b=5)]

6. reset_mock

將 mock 物件重置,要注意的是這個 method 只是清除呼叫的紀錄,對於reutrn_value,side_effect不會有影響

>>> mock_thing = mock.MagicMock()
>>> mock_thing.return_value = 10
>>> mock_thing()
10
>>> mock_thing.called
True
>>> mock_thing.reset_mock()
>>> mock_thing.called
False
>>> mock_thing()
10

Patch

接下來介紹 patch 這個 method,竟然 Mock 已經這麼方便了,為什麼還需要 patch 這個東西呢?

a.py
-> Defines SomeClass
b.py
-> from a import SomeClass
-> some_function instantiates SomeClass

假設 B 模組裡會使用到 A 模組的 class,而這時因為 B 已經有 A 的 reference,如果我們直接 mock SomeClass 是不會有任何影響的,B 模組還是會參考到原本的 A 模組,那應該怎麼做呢?
實際上應該是將模組中參照的地方替換成我們的 mock class,我們只需要把他當成 decorator 放在測試案例前面來處理我們想要替換的名稱就可以了,像這樣子:

@mock.patch('module_name.SomeClassName.some_method_name')

example.py:

def func1(x):
    return x**2

def func2(x):
    return func1(x) + x*5

def func3(x):
    return func1(x) + func2(x) + x*3

tests.py:

import unittest
from unittest import mock
from example import func1, func2, func3


class ExampleTest(unittest.TestCase):
    """Test example for patch
    """
    @mock.patch('example.func2')
    @mock.patch('example.func1')
    def test_func2(self, mock_func1, mock_func2):
        mock_func1.return_value = 0
        mock_func2.return_value = 0
        self.assertEqual(func3(5), 15)

而 patch 還有像是給特定用法的 patch.object,patch.dict,patch.multiple 等等,這些可以在官方的說明看到更詳細的用法,最後就用一個實際的例子,來體會一下這強大的工具到底有多方便吧!

Practice

utils.py

import gzip


class Reader(object):

    def __init__(self, filename):
        self.f = self.open(filename)

    def open(self, filename):
        try:
            f = gzip.open(filename, 'rb')
        except:
            f = open(filename, 'r')
        return f

    def get(self):
        return self.f.readline()


def convert(reader):
    return reader.get().split(',')

tests.py

import unittest
try:
    # Python3
    from unittest import mock
except:
    # Python2
    import mock
from utils import Reader, convert


class ReaderTest(unittest.TestCase):

    @mock.patch('utils.open')
    @mock.patch('gzip.open')
    def test_gzip_open(self, mock_gzip, mock_open):
        mock_gzip.return_value = 'Mock Gzip'
        reader = Reader('test.csv.gz')
        mock_gzip.assert_called_with('test.csv.gz', 'rb')
        mock_open.assert_not_called()
        self.assertEqual(reader.f, 'Mock Gzip')

    @mock.patch('utils.open')
    @mock.patch('gzip.open')
    def test_builtins_open(self, mock_gzip, mock_open):
        mock_gzip.side_effect = Exception('Not this')
        mock_open.return_value = 'Open'
        reader = Reader('test.csv')
        mock_gzip.assert_called_with('test.csv', 'rb')
        mock_open.assert_called_with('test.csv', 'r')
        self.assertEqual(reader.f, 'Open')

    @mock.patch('utils.Reader.open')
    def test_get(self, mock_open):
        mock_open.return_value.readline.side_effect = [1, 2]
        reader = Reader('test.csv')
        self.assertEqual(reader.get(), 1)
        self.assertEqual(reader.get(), 2)
        with self.assertRaises(StopIteration):
            reader.get()


class ConverterTest(unittest.TestCase):

    def test_convert(self):
        mock_reader = mock.MagicMock()
        mock_reader.get.return_value = '1,2,3'
        self.assertEqual(convert(mock_reader), ['1', '2', '3'])


if __name__ == '__main__':
    unittest.main()

References

https://docs.python.org/3/library/unittest.mock.html
https://docs.python.org/3/library/unittest.mock-examples.html
https://www.toptal.com/python/an-introduction-to-mocking-in-python
http://python-mock-tutorial.readthedocs.org/en/latest/mock.html

在WordPress.com寫網誌.

向上 ↑