跳至主要内容

[note] unittest and pytest

CLI

$ pytest --disable-warnings
$ pytest -s test_xxx.py # 把 print 的內容顯示出來
$ pytest -s -vv -W ignore test_xxx.py

Unit testing framework (unittest)

官方文件

unittest @ Python Standard Library > Development Tools

內建的 asset keyword

assert <condition>, 'Message if condition is not met'

當 condition 是 false 時,會 raise AssertionError

建立 Test Cases

keywords: unittest.TestCase
  • 建立一個繼承 unittest.TestCase 的類別
  • 欲進行測試的項目(test cases)會定義成該 Class 中的 instance method,並以 test_ 作為方法名稱的前綴,例如,def test_square_of_zero(self)
  • 需要使用 unittest 提供的 assertEqual 而不是內建的 assert
  • 使用 unittest.main() 可以執行測試
import unittest

class TestSquareFunction(unittest.TestCase):
def test_square_of_zero(self):
self.assertEqual(square(0), 0, 'Expected square(0) to return 0')

def test_square_of_large_number(self):
self.assertEqual(square(1000), 1000000, 'Expected square(1000) to return 1000000')

def test_square_of_negative_number(self):
self.assertEqual(square(-10), 100, 'Expected square(-10) to return 100')

# 執行測試
unittest.main()

常用的 assertions

TestCase

TestCase:所有可用的方法和 assertions @ 官方文件

  • assertEqual(a, b):檢查是不是相同,等同於 a == b
  • assertTrue(a, b):等同於 bool(xxx) is True
  • assertIs(a, b):檢查是不是同一個物件,等同於 a is b
  • assertIsNone(x):等同於 x is None
  • assertIn(member, container, msg=None):等同於 member in container
  • assertLess(a, b):等同於 a < b
  • assertAlmostEqual(a, b):等同於 round(a - b) == 0
  • assertRaises(exception, callable, *args, **kwds)
    • exception 是預期要看到的 Error 類別,例如 TypeErrorCustomError
    • callable 帶入的是要測試的 function
    • *args**kwds 則是會帶入 callable 的參數
  • assertWarns(warning, callable, *args, **kwds)

使用 subTest 達到 Test Parameterization

keywords: self.subTest(<test_case>)

透過 subTest 的使用,每一個 loop 中的 assertion 都會被視為獨立的 test case,如此可以把測試資料用參數方式帶入 test case 中,測試多種不同的可能情況:

  • 在迴圈中使用使用 with self.subTest
import unittest
import music

class MusicSystemTests(unittest.TestCase):

def test_song_license(self):
daily_songs = music.get_daily_playlist()
licensed_songs = music.get_licensed_songs()

for song in daily_songs:
# `self.subTest()` 的參數中可以放入每次執行的測試資料,如此當有錯誤產生時,能比較清楚知道是哪個測試資料導致 test failed
with self.subTest(song=song):
self.assertIn(song, licensed_songs)

unittest.main()
不用 subTest 也能執行

不使用 self.subTest() 也能執行測試,差別在於使用 subTest 的話,迴圈中的每個測資都會被視為獨立的 test case,所以如果其中一個 failed,還是會繼續執行剩下的測試資料;但如果沒有用 subTest 的話,只要一個資料 failed,這個測試就會被終止。

Test Fixtures

keywords: setUptearDownsetUpClasstearDownClass

在 Test Class 中,下列這個 instance method 會在特定時間點被執行:

  • setUp:每個 test case 執行前會先執行這個 instance method,類似 Vitest 中的 beforeEach
  • tearDown:每個 test case 執行後會接著執行這個 instance method,類似 Vitest 中的 afterEach
  • setUpClass:在所有 test cases 被執行前,會先執行這個 class method,類似 Vitest 中的 beforeAll
  • tearDownClass:在所有 test cases 被執行後,會接著執行這個 class method,類似 Vitest 中的 afterAll
import unittest

class DatabaseTests(unittest.TestCase):

@classmethod
def setUpClass(cls):
print("Setting up the test database connection...")
# Code to initialize a database connection
cls.db_connection = "Database connection established"

@classmethod
def tearDownClass(cls):
print("Closing the test database connection...")
# Code to close the database connection
cls.db_connection = None

def setUp(self):
print("Setting up for a test...")
# Code to initialize test data or reset state
self.test_data = {"user": "test_user", "password": "test_password"}

def tearDown(self):
print("Cleaning up after a test...")
# Code to clean up test data or reset state
self.test_data = None

def test_insert_data(self):
print("Testing data insertion...")
# Insert data using self.db_connection and self.test_data
self.assertEqual(self.test_data["user"], "test_user")

def test_delete_data(self):
print("Testing data deletion...")
# Delete data using self.db_connection
self.assertIsNotNone(self.db_connection)

unittest.main()

MagicMock

有兩種使用 MagicMock 的兩種方式

方式一:使用 decorator 後,在 test function 的第一個參數就可以拿到 MagicMock

class TestExample(unittest.TestCase):
@patch("package.path.foo")
def test_foo_bar(self, mock_foo):
# 這裡的 mock_foo 會來自 MagicMock,可以對它進行一些 assertion
# ...

# mock bar 這個 method 的 return value
mock_foo.bar.return_value = "return_value"

# assert bar 這個 method 會被執行一次,且這個 method 被呼叫到的參數會是 arguments_to_be_called
mock_foo.bar.assert_called_one_with("arguments_to_be_called")

方式二:自己定義 MagicMock 後傳入 decorator

class TestExample(unittest.TestCase):

mock_foo = MagicMock()
@patch("package.path.foo", mock_foo)
def test_foo_bar(self):
# 這裡的 mock_foo 會來自 MagicMock,可以對它進行一些 assertion
# ...

# mock mock_foo 裡的 bar 這個 method 的 return value
mock_foo.bar.return_value = "return_value"

# assert mock_foo 裡的 bar 這個 method 會被執行一次,且這個 method 被呼叫到的參數會是 arguments_to_be_called
mock_foo.bar.assert_called_one_with("arguments_to_be_called")

如果一個 method 有多個地方需要 Mock

可以針對一個 method 使用多個 @patch decorator,但是 method 中拿到 Mock 的參數順序是反過來的,這個需要特別留意

class TestExample(unittest.TestCase):
@patch("package.path.foo")
@patch("package.path.bar")
def test_foo_bar(self, mock_bar, mock_foo):
pass
# ...

略過某些測試(Skipping tests)

要略過某些測試可以使用:

  1. 使用 unittest.TestCase 提供的 skipTest 這個 instance method

    • self.skipTest(<reason>)
  2. @unittest 的 skip decorator

  • @unittest.skip(<reason>)

  • @unittest.skipUnless(<condition>, <reason>)

  • @unittest.skipIf(<condition>, <reason>)

import unittest
import power_management

class PowerManagementTests(unittest.TestCase):

@unittest.skipIf(power_management.is_low_power_mode(), 'Test skipped in low power mode')
def test_battery_charging(self):
battery_status = power_management.get_battery_status()
self.assertEqual(battery_status, "charging")

@unittest.skipUnless(power_management.is_connected_to_ac(), 'Test requires AC power')
def test_high_performance_mode(self):
performance_mode = power_management.get_performance_mode()
self.assertEqual(performance_mode, "high")

def test_battery_temperature(self):
if power_management.is_low_power_mode():
self.skipTest('Test skipped in low power mode')
battery_temp = power_management.get_battery_temperature()
self.assertLess(battery_temp, 45)

def test_cpu_performance(self):
if not power_management.is_connected_to_ac():
self.skipTest('Test requires AC power')
cpu_performance = power_management.get_cpu_performance()
self.assertGreater(cpu_performance, 80)

unittest.main()

除了 SkipTest 之外,如果我們預期某個測試一定會失敗(可能是還沒修好的 bug),這時候比較適合的使用 @unittest.expectedFailure 而不是把這個 test skip 掉。

Decorators

@pytest.mark.skip  # 略過這個 test case

fixture

@pytest.fixture 可以用在幫每個 test 做 setup、提供資料、cleanup 等等(類似 Vitest 中的 beforeEachafterEach),需用到的時候再把定義好的 fixture 當成參數傳入。

例如,使用 @pytest.fixture 可以不用重複建立物件實例,而是可以初始化一次就好:

class Student:
def __init__(self, name, age):
self.name = name
self.age = age


@pytest.fixture
def default_student():
return Student('John', 20)


# 可以把 fixture 帶入後,就可以使用
def test_student_initialization(default_student):
assert default_student.name == 'John'
assert default_student.age == 20

參考資料