概要
pytest-timeoutで、method="thread"
を指定しないと想定通りにテストが失敗してくれない事象に遭遇したのでメモを残します。
経緯
TCPIPクライアントの挙動をpytestでテストするため、テスト側でTCPサーバーを実装していました。
以下のようなテストコードを作成しました:
import socket
import asyncio
import pytest
import pytest_asyncio
from typing import AsyncGenerator
@pytest_asyncio.fixture
async def server() -> AsyncGenerator[socket.socket, None]:
"""終わらないサーバー"""
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.bind(("localhost", 12345))
server_socket.listen(1)
server_socket.settimeout(0.1)
async def handle_client():
while True:
try:
client, _ = server_socket.accept()
client.close()
except socket.timeout:
continue # sleepもないのでCPU使用率が高くなる
server_task = asyncio.create_task(handle_client())
try:
yield server_socket
finally:
server_task.cancel()
server_socket.close()
@pytest.mark.asyncio
async def test_server(server):
"""終わらないテスト"""
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(("localhost", 12345))
client.close()
このテストを実行すると以下の出力で止まります。
handle_client
関数内のループが終了条件を持たないためです。
% uv run pytest test_server.py
============================================ test session starts =============================================
platform darwin -- Python 3.11.2, pytest-8.3.4, pluggy-1.5.0
rootdir: /path/to
configfile: pyproject.toml
plugins: asyncio-0.25.3, anyio-4.8.0, timeout-2.3.1
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=function
collected 1 item
test_server.py
そこで、テストが一定時間で終わらない場合は失敗とみなせるように、pytest-timeoutを導入します。
uv add pytest-timeout
@pytest.mark.asyncio
@pytest.mark.timeout(1)
async def test_server(server):
"""終わらないテスト"""
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(("localhost", 12345))
client.close()
しかし、このコードを実行すると1秒後にタイムアウトするものの、テストが成功として扱われてしまいます。
% uv run pytest test_server_timeout_signal.py
============================================ test session starts =============================================
platform darwin -- Python 3.11.2, pytest-8.3.4, pluggy-1.5.0
rootdir: /path/to
configfile: pyproject.toml
plugins: asyncio-0.25.3, anyio-4.8.0, timeout-2.3.1
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=function
collected 1 item
test_server_timeout_signal.py . [100%]
============================================= 1 passed in 1.02s ==============================================
タイムアウトした場合はテストを失敗とみなしたいので、この動作は期待と異なります。
対策
timeoutのオプションにmethod="thread"を指定します。
@pytest.mark.asyncio
@pytest.mark.timeout(1, method="thread")
async def test_server(server):
"""終わらないテスト"""
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(("localhost", 12345))
client.close()
実行してみます。
% uv run pytest test_server_timeout_thread.py
============================================ test session starts =============================================
platform darwin -- Python 3.11.2, pytest-8.3.4, pluggy-1.5.0
rootdir: /path/to
configfile: pyproject.toml
plugins: asyncio-0.25.3, anyio-4.8.0, timeout-2.3.1
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=function
collected 1 item
test_server_timeout_thread.py ++++++++++++++++++++++++++++++++++++++++++++++++++ Timeout +++++++++++++++++++++++++++++++++++++++++++++++++++
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Stack of MainThread (8597578304)
...
++++++++++++++++++++++++++++++++++++++++++++++++++ Timeout +++++++++++++++++++++++++++++++++++++++++++++++++++
タイムアウトになりスタックオーバートレースが出力されました。
考察
pytest-timeoutにはsignal
とthread
の2つのタイムアウト方式があり、デフォルトはsignalらしいです。
threadは安定するらしいので成功したのだと思いますが、なぜsignalだと期待通りの動作にならないのかはよくわかりません。タイムアウトが完全に機能しないならまだしも、タイムアウトで指定した秒数でテストが終了したうえで「成功」の扱いになっているのが不思議です。
tcpipserverとの組み合わせが原因の可能性もあり、signalの発呼によって詰まっていた処理が解消され、タイムアウトの「失敗」判定がなされるよりも先にテストが終了するのかもしれません(自分でも筋の通ったことを言えているかはよくわかりません)
OSに依存するのかもしれません(ちなみにMacOSで実験しました)
きちんと納得するにはもう少し調査が必要ですが、とりあえず簡単なメモとして残しておきます。