TL;DR

Test-Driven Design的作者 Kent-Beck說過 “Write tests until fear is transformed into boredom.”

在進行重構時,每次最害怕的莫過於改了一個地方,其他地方就壞掉了。這時候,單元測試就是你的好幫手。你可以透過點擊一下按鈕,就能知道你的程式碼是否有問題。以前都寫java的單元測試,現在開始學習機器學習的相關領域,覺得也要培養寫測試的習慣。

Pytest vs Unittest

以 Python 後端來說,眾多測試框架裡最熱門的當屬下面兩者:

  1. 第三方 Pytest
    • Pytest 不需要把測試的 function 封裝在類別(class)之中,你可以更加自由地定義你的測試案例
      • e.g. 不需要放unittest.TestCase class TestAddition(unittest.TestCase)
    • 僅僅需要在測試 .py 檔以及檔案裡的測試 function 加上 test_ 的前綴,pytest 就可以自動去辨認並執行這些測試腳本
      • e.g. 直接定義function 並且加上test的前綴 test_add_function()
  2. Python 內建的 Unittest
    • Unittest 在建立測試案例時,需要將程式寫在 Test 作為命名開頭的類別 (class) 中,並且要繼承 unittest.TestCase 這個類別
    • 編寫上雖較為嚴謹,但也較不直覺、需要較多步驟

Unittest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import unittest

def add(a, b):
return a + b

# 必須定義一個 Test 開頭的測試類別
class TestAddition(unittest.TestCase): # 需要繼承 unittest.TestCase
def test_add_function(self):
self.assertEqual(add(2, 3), 5)

# 並且斷言比較長
def test_equal(self):
self.assertEqual(2 + 2, 4)

def test_in(self):
self.assertIn('yo', 'yo bro')

def test_true(self):
self.assertTrue(2 + 2 == 4)

def test_false(self):
self.assertFalse(2 + 2 == 5)

pytest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def add(a, b):
return a + b

# 只需要簡單寫一個 test_ 開頭的 function
def test_add_function():
assert add(2, 3) == 5

# 斷言簡潔
def test_equal():
assert 2 + 2 == 4

def test_in():
assert 'yo' in 'yo bro'

def test_true():
assert (2 + 2 == 4) is True

def test_false():
assert (2 + 2 == 5) is False

這篇文章主要介紹 Pytest,因為 Pytest 是一個功能更強大、更簡潔的測試框架,並且近年來,pytest 已成為 Python 測試最受歡迎的選擇之一

安裝教學

開啟終端機並寫入:

1
pip install pytest

在開始寫測試之前,這邊想要先介紹一下 pytest 的基本規則:

  1. 測試檔案必須以 test_ 開頭
  2. 測試函數必須以 test_ 開頭
  3. 測試函數中的斷言必須使用 assert 關鍵字

Test資料夾結構

使用 Pytest 測試框架進行測試時,需要按照特定格式擺放檔案。並沒有唯一正確的格式,身為一個廣泛使用的測試框架,Pytest 可依照使用者的需求自行指定。

1
2
3
4
5
6
7
8
9
10
11
12
|
|_ Your Repo
|
|_ src/ # 主要程式碼資料夾
|_ module_a.py # 範例模組 A
|_ module_b.py # 範例模組 B
|
|_ tests/ # 測試程式碼資料夾
|_ test_module_a.py # 模組 A 的測試程式碼
|_ test_module_b.py # 模組 B 的測試程式碼
|
|_ pytest.ini # pytest 相關設定

從上述的架構我們可以掌握幾個原則 :

  1. 主要的程式碼會統一放在 src/ (也有人稱為 app, lib 或直接用模組的用途命名)
  2. 測試用的程式碼則統一放在 tests 資料夾
  3. 通常測試用的程式碼會以 test_xxx.py 命名 (我自己的習慣是直接對應到 src/ 中的模組)

fixture

在unittest中我們必須要在每個測試函數中重複的初始化一些變數,或是在運行測試函試之前的工作,但是有可能很多測試函式要設定的參數都很雷同,使用unittest會讓測試程式碼變得冗長。而在pytest中,我們可以使用fixture來解決這個問題,讓多個function可以共用一些初始化變數

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import unittest

class TestExample(unittest.TestCase):

def setUp(self):
# 測試前的設定
self.resource = "yo bro"

def tearDown(self):
# 測試後的清理
self.resource = None

def test_something(self):
# 不需要每一次要調用時都要重新定義一次
self.assertEqual(self.resource, "yo bro")

def test_another_thing(self):
self.assertIsNotNone(self.resource)

但是在pytest中可以透過fixture來設定測試前的設定,使得一個function的資源可以被其他測試function所使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import pytest

# 可以透過fixture來設定測試前的設定
@pytest.fixture(scope="Module")
def setup_resource():
resource = "yo bro"
return resource

# 可以發現setup_resource可以直接被使用 不需要() 來呼叫
# 注意要把setup_resource當作參數傳入才可以使用
def test_something(setup_resource):
assert setup_resource == "yo bro"

def test_another_thing(setup_resource):
assert setup_resource is not None

如果想要多個function共用一些變數還可以這樣寫

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 定義一個class裡面放所有變數
class MyEnum:
FILE_NAME = 'german-legal'
JSON_PATH = '../resource/result/{}/final-result.json'.format(FILE_NAME)
FILE_PATH = '../resource/pdf/{}.pdf'.format(FILE_NAME)
SAVE_PATH = '../resource/result/{}/'.format(FILE_NAME)
ROLE_ARN = 'arn:aws:iam::975050286405:role/TextractRole'
BUCKET = 'shannon-thesis-bucket'
DOCUMENT = '{}.pdf'.format(FILE_NAME)
REGION_NAME = 'us-east-1'

# 設定fixture這樣每個function test就可以注入
@pytest.fixture
def values():
return MyEnum

# 把values當作參數傳入
def test_read_json_res(values):
assert read_json_file(values.JSON_PATH) is not None