Byte Ebi's Logo

Byte Ebi 🍤

每天一小口,蝦米變鯨魚

[Pytest 101] 02 Mock 的基本用法

在 Pytest 中如何使用 Mock 來模擬行為、拋出例外與測試副作用。

Ray

介紹完基本的工具後,這篇文章要實際操作 Pytest 中 Mock 的使用方式。
Mock 可以讓我們替換原本的函式邏輯,模擬不同情境,寫出更精準的單元測試。
本文將以實例說明如何抽換函式、模擬錯誤發生,甚至處理像是 Redis 這樣的外部資源。

使用 mocker.patch 來抽換自身或是其他模組的函數
以抽換來自 service.my_servicedo_something 函式為例

mocker.patch("service.my_service.do_something", return_value="hello")

Mock function

# my_project/service/do_service.py

def do_something():
    try:
        response = something()
        return response
    except Exception as e:
        return str(e)

def something():
    return "success"

抽換了 something() 這個函式,並且使用 return_value 定義其回傳值。
為了證明抽換的結果如預期,我們直接把值回傳,並且用 assert 斷言進行判斷。

# my_project/test/service/test_do_service.py

import pytest
from pytest_mock import MockFixture
from service.do_service import (
    do_something,
)

def test_do_something_success(mocker: MockFixture):
    success_message = "success"
    mocker.patch("service.do_service.something", return_value=success_message)

    result = do_something()
    assert result == success_message

Mock function from other module

隨著專案複雜度增加,逐漸把共用函式統整成再一起是很常見的。

假設今天 something() 是來自模組 service/do_trick.py
在程式中會使用 import 引入來自 do_trick.py 的函式 something()

# my_project/service/do_trick.py

def something():
    return "success" 
# my_project/service/do_service.py

from service.do_trick import something

def do_something():
    return something()

此時抽換的方法其實同小異,只有一點要特別注意:

當使用 from package_name import function_name 時,會將 function_name 視為當前模組的方法

所以要抽換方法的對象不再是原先的 module package_name
而是當前的模組 service.do_service.something

如果 mock package_name.function_name 則測試時會使用未抽換的方法
而因為呼叫的 function 已經被引入到當前的模組內,屬於 mock 了個寂寞

# my_project/test/service/test_do_service.py

import pytest
from pytest_mock import MockFixture
from service.do_service import (
    do_something,
)

def test_do_something(mocker: MockFixture):
    mocker.patch("service.do_service.something", return_value="not success")
    assert do_something() == "not success"

在這個例子中就是抽換了 service.do_service 內的 something(),而不是 service.do_trick 的函式。

Mock try-except

當有 try-except 時,也可以抽換方法來模擬某個地方出錯造成 except 後的行為。

使用 side_effect 宣告 something() 在執行時拋出錯誤類別 Exception

def test_do_something_error(mocker: MockFixture):
    error_message = "Exception error"
    mocker.patch("service.do_service.something", side_effect=Exception(error_message))

    result = do_something()
    assert result == error_message

return_value 只能定義「回傳值」,而沒辦法做更複雜的使用
side_effect 可以帶入 function, iterable 以及常用到的 exception class

Mock raise Exception

有時候是程式執行沒有出錯,但是在特定條件時我們要主動拋出錯誤

# my_project/service/do_service.py

def do_something(err):
    if err:
        raise Exception(f"Something went wrong")
    return "success"

這時候可以使用 with pytest.raises() 來聲明執行時會拋出的錯誤類型及訊息

# my_project/test/service/test_do_service.py

import pytest
from pytest_mock import MockFixture
from service.test_do_service import (
    do_something,
)

def test_do_something_success():
    assert do_something(err=False) == "success"

def test_do_something_err():
    with pytest.raises(Exception, match=f"Something went wrong"):
        do_something(err=True)

Mock Redis

因為 redis.get 會回傳 bytes 所以需要用到 encodedecode

def get_from_redis(id:int):
    # connect to redis
    redis = Redis(host=os.environ["REDIS_HOST"])
    byte_data = redis.get("mykey.{}".format(id))
    data = byte_data.decode("utf-8")

    # set key to redis and expire after 1 hour
    redis.set(
        "mykey.{}".format(id),
        data,
        ex=3600,
    )

    return data
def test_get_from_redis(mocker: MockFixture):
    test_data = "test_data".encode("utf-8")

    mock_redis = mocker.patch("service.do_service.Redis")
    mock_redis_instance = mock_redis.return_value
    mock_redis_instance.get.return_value = test_data
    mock_redis_instance.set.return_value = None

    result = get_from_redis(1)
    assert result == test_data.decode("utf-8")

最新文章

Category

Tag