ข้ามไปยังเนื้อหา

pytest — การทดสอบ Python สมัยใหม่

pytest คือมาตรฐานโดยพฤตินัยสำหรับการทดสอบโค้ด Python ต่างจากโมดูล unittest ที่มีอยู่ในตัว pytest ไม่ต้องการการสืบทอดคลาส ไม่ต้องการเมธอด assertion พิเศษ และไม่มี boilerplate — คุณเขียนแค่ฟังก์ชันธรรมดาและคำสั่ง assert ธรรมดา ระบบค้นหาเทส ผลลัพธ์เมื่อเกิดความล้มเหลวที่ละเอียด ระบบ fixture และระบบนิเวศปลั๊กอินทำให้ pytest เป็นเครื่องมือแรกที่ควรหยิบใช้ในทุกโปรเจกต์ Python

บทนี้ไม่มีปุ่ม Run — pytest ต้องรันในเครื่อง: uv init myapp && cd myapp && uv add --dev pytest && uv run pytest

pytest ค้นหาไฟล์ทดสอบที่มีชื่อขึ้นต้นด้วย test_ หรือลงท้ายด้วย _test.py และภายในนั้น ฟังก์ชันที่มีชื่อขึ้นต้นด้วย test_ ไม่จำเป็นต้องมีคลาส — ฟังก์ชันระดับโมดูลก็เพียงพอ

math_utils.py
def add(a: int, b: int) -> int:
return a + b
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
test_math_utils.py
from math_utils import add, divide
import pytest
def test_add_positive():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, -2) == -3
def test_divide_normal():
assert divide(10, 2) == 5.0
def test_divide_by_zero():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(1, 0)
Terminal window
# run all tests
python -m pytest
# verbose output
python -m pytest -v
# run only tests matching a keyword
python -m pytest -k "divide"

Fixture คือฟังก์ชัน setup (และ teardown) ที่นำกลับมาใช้ซ้ำได้ pytest จะส่งผ่านเข้าสู่ฟังก์ชันทดสอบตามชื่อพารามิเตอร์ — ไม่ต้องมีคลาสฐาน ไม่ต้องมีเมธอด setUp ใช้ yield เพื่อแยกส่วน setup ออกจาก teardown

import pytest
@pytest.fixture
def sample_data():
"""Provide a list of numbers for testing."""
return [1, 2, 3, 4, 5]
def test_sum(sample_data):
assert sum(sample_data) == 15
def test_length(sample_data):
assert len(sample_data) == 5
@pytest.fixture
def db_connection():
"""Setup and teardown a fake DB connection."""
conn = {"connected": True} # imagine a real DB here
yield conn
conn["connected"] = False # teardown runs after test
def test_db(db_connection):
assert db_connection["connected"] is True

@pytest.mark.parametrize รันฟังก์ชันทดสอบเดียวกันหลายครั้งด้วยชุดข้อมูล input ที่แตกต่างกัน ทำให้ test suite กระชับและเพิ่ม edge case ได้ง่าย

import pytest
from math_utils import add
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(100, -50, 50),
])
def test_add_parametrized(a, b, expected):
assert add(a, b) == expected

โค้ดจริงต้องแตะ environment variable, filesystem, stdout และ logger pytest มี fixture ที่ทำให้ทั้งสี่อย่างนี้ปลอดภัยและตรวจสอบได้ในเทส โดยไม่ต้อง cleanup เอง

"""app.py -- exercises an env var, a file, stdout, and a logger."""
import logging
import os
from pathlib import Path
logger = logging.getLogger(__name__)
def greeting() -> str:
user = os.environ.get("USER_NAME", "stranger")
return f"Hello, {user}!"
def write_report(path: Path, lines: list[str]) -> None:
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"wrote {len(lines)} lines to {path}")
logger.info("report written to %s", path)
"""test_fixtures.py"""
import logging
from pathlib import Path
import app
def test_monkeypatch_env_var(monkeypatch):
monkeypatch.setenv("USER_NAME", "Ada")
assert app.greeting() == "Hello, Ada!"
def test_monkeypatch_missing_env_var_uses_default(monkeypatch):
monkeypatch.delenv("USER_NAME", raising=False)
assert app.greeting() == "Hello, stranger!"
def test_write_report_uses_tmp_path(tmp_path: Path, capsys):
report = tmp_path / "out.txt" # a real, isolated, auto-cleaned directory
app.write_report(report, ["line one", "line two"])
assert report.read_text(encoding="utf-8") == "line one\nline two\n"
captured = capsys.readouterr() # captured print() output
assert "wrote 2 lines" in captured.out
def test_write_report_logs_at_info(tmp_path: Path, caplog):
report = tmp_path / "out.txt"
with caplog.at_level(logging.INFO):
app.write_report(report, ["x"])
assert "report written to" in caplog.text

รันจริง ผ่านทั้งสี่:

$ uv run pytest test_fixtures.py -v
test_fixtures.py::test_monkeypatch_env_var PASSED [ 25%]
test_fixtures.py::test_monkeypatch_missing_env_var_uses_default PASSED [ 50%]
test_fixtures.py::test_write_report_uses_tmp_path PASSED [ 75%]
test_fixtures.py::test_write_report_logs_at_info PASSED [100%]
4 passed in 0.12s

monkeypatch คืนค่าทุกอย่างที่เปลี่ยน (env var, attribute, dict item, sys.path) กลับอัตโนมัติตอน teardown — ห้าม mutate os.environ ตรง ๆ ในเทส tmp_path ให้ไดเรกทอรีจริงที่ว่างเปล่าและถูกลบอัตโนมัติแยกต่อเทส (scope ระดับ function; มี tmp_path_factory สำหรับ scope ที่กว้างกว่า) capsys จับ print()/stdout+stderr ส่วน caplog จับ record ของโมดูล logging ไม่ใช่ print() — ความผิดพลาดที่พบบ่อยในเทสที่ AI เขียนคือใช้ capsys ตรวจเนื้อหา log หรือใช้ caplog ตรวจเนื้อหา print

import sys
import pytest
import app
@pytest.mark.skip(reason="Windows path handling not implemented yet")
def test_windows_only_behavior():
assert False # never runs
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX permissions only")
def test_posix_permissions():
assert True
@pytest.mark.xfail(reason="Account.withdraw does not support negative amounts yet")
def test_negative_withdraw_is_rejected():
acc = app.Account(balance=100)
acc.withdraw(-50) # currently just increases the balance -- a real bug
assert acc.balance == 100 # the assertion we WANT to hold
@pytest.mark.slow
def test_large_transfer_batch():
src, dst = app.Account(1_000_000), app.Account(0)
for _ in range(10_000):
app.transfer(src, dst, 1)
assert dst.balance == 10_000

mark ที่กำหนดเองอย่าง slow ต้องลงทะเบียนก่อน ไม่งั้น pytest จะถือว่าเป็นการพิมพ์ผิด:

pyproject.toml
[tool.pytest.ini_options]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
]

รันจริง mark ที่ไม่ได้ลงทะเบียนถูกปฏิเสธทันที:

$ uv run pytest --strict-markers -q
ERROR test_marks_and_raises.py - Failed: 'totally_unregistered_mark' not found in `markers` configuration option

หลังลงทะเบียนแล้ว ทุกอย่างรันได้ (skip, posix, xfail, slow และเทส match= ด้านล่าง):

$ uv run pytest test_marks_and_raises.py -v
test_marks_and_raises.py::test_windows_only_behavior SKIPPED (Window...) [ 20%]
test_marks_and_raises.py::test_posix_permissions PASSED [ 40%]
test_marks_and_raises.py::test_negative_withdraw_is_rejected XFAIL (...) [ 60%]
test_marks_and_raises.py::test_large_transfer_batch PASSED [ 80%]
test_marks_and_raises.py::test_withdraw_message_matches_exactly PASSED [100%]
3 passed, 1 skipped, 1 xfailed in 0.10s
$ uv run pytest test_marks_and_raises.py -m "not slow" -q
s.x. [100%]
2 passed, 1 skipped, 1 deselected, 1 xfailed in 0.09s

เทสที่ทำ xfail แล้วดันผ่านโดยไม่คาดคิดจะถูกรายงานเป็น XPASS — สัญญาณที่มีประโยชน์ว่า bug ที่รู้จักถูกแก้แล้ว และควรเอา mark ออก

match= รัน re.search กับ str(exception) — มันจับ exception ที่ ถูกต้อง ด้วยเหตุผลที่ ถูกต้อง ไม่ใช่แค่ exception ประเภทเดียวกันตัวไหนก็ได้

def test_withdraw_message_matches_exactly():
acc = app.Account(balance=10)
with pytest.raises(ValueError, match="^insufficient funds$"):
acc.withdraw(50)

ถ้าไม่มี match= การเขียน pytest.raises(ValueError) เฉย ๆ จะผ่านแม้โค้ด raise ValueError("something unrelated") — ประเภทของ exception ถูกต้อง แต่สาเหตุอาจไม่ใช่

โดย default fixture จะรันใหม่ทุกเทส (scope="function") scope ที่กว้างกว่าแลกความ isolation กับความเร็ว — setup ครั้งเดียว ใช้ซ้ำกับหลายเทส

import pytest
@pytest.fixture(scope="session")
def session_fixture():
print("\n [setup] session (once for the whole test run)")
yield "session-value"
print(" [teardown] session")
@pytest.fixture(scope="module")
def module_fixture():
print("\n [setup] module (once per test file)")
yield "module-value"
print(" [teardown] module")
@pytest.fixture(scope="function")
def function_fixture():
print("\n [setup] function (before every test)")
yield "function-value"
print(" [teardown] function")
def test_one(session_fixture, module_fixture, function_fixture):
assert function_fixture == "function-value"
def test_two(session_fixture, module_fixture, function_fixture):
assert module_fixture == "module-value"

ลำดับจริง ยืนยันด้วย -s (ปิดการ capture output เพื่อให้เห็น print):

$ uv run pytest test_fixture_scope.py -v -s
test_fixture_scope.py::test_one
[setup] session (once for the whole test run)
[setup] module (once per test file)
[setup] function (before every test)
PASSED [teardown] function
test_fixture_scope.py::test_two
[setup] function (before every test)
PASSED [teardown] function
[teardown] module
[teardown] session

fixture ระดับ session และ module setup ครั้งเดียวก่อนเทสแรกที่ต้องใช้ และ teardown ครั้งสุดท้ายหลังเทสสุดท้ายใน scope ของมัน fixture ระดับ session ที่ถือ state ที่เปลี่ยนแปลงได้แล้วเทสหนึ่ง mutate มัน จะรั่วไหลไปยังทุกเทสถัดไปในรันนั้น — เป็น bug ที่แนบเนียนเมื่อ fixture ที่ AI สร้างตั้ง scope="session" “เพื่อความเร็ว” โดยไม่ตรวจว่า object นั้นถูกแชร์อย่างปลอดภัยหรือไม่

unittest.mock.patch — patch ที่ที่ name ถูก lookup ไม่ใช่ที่ที่มันถูกนิยาม

หัวข้อที่มีชื่อว่า “unittest.mock.patch — patch ที่ที่ name ถูก lookup ไม่ใช่ที่ที่มันถูกนิยาม”

นี่คือความผิดพลาดในการ mock ที่พบบ่อยที่สุด ทั้งในเทสที่ AI เขียนและที่คนเขียนเอง patch(target) แทนที่ attribute บนโมดูลที่ถือ reference นั้นตอนเรียกจริง — โมดูลไหนขึ้นอยู่กับวิธีที่โค้ดที่ถูกทดสอบ import name นั้นเข้ามา

"""weather.py -- two ways to import requests.get."""
import requests
def get_temperature_direct(city: str) -> float:
# import requests; requests.get(...) -- requests.get is looked up on
# the requests module EVERY call, so patching requests.get works.
resp = requests.get(f"https://api.example.com/weather/{city}", timeout=5)
return resp.json()["temp_c"]
from requests import get as _get
def get_temperature_wrong_target(city: str) -> float:
# from requests import get -- weather._get is now a name in THIS
# module's namespace, bound once at import time.
resp = _get(f"https://api.example.com/weather/{city}", timeout=5)
return resp.json()["temp_c"]
"""test_patch_location.py"""
from unittest.mock import MagicMock, patch
import pytest
import requests
import weather
def test_patching_requests_get_works_when_the_caller_did_import_requests():
fake_response = MagicMock()
fake_response.json.return_value = {"temp_c": 21.5}
with patch("requests.get", return_value=fake_response) as mock_get:
assert weather.get_temperature_direct("bangkok") == 21.5
mock_get.assert_called_once()
def test_patching_requests_get_is_a_NO_OP_after_a_from_import():
# weather._get was bound at import time -- patching the attribute on the
# requests module never touches weather's own reference to it. The
# "mocked" function never runs; the real call fires and hits the network.
with patch("requests.get", return_value=MagicMock()) as mock_get:
with pytest.raises(requests.exceptions.ConnectionError):
weather.get_temperature_wrong_target("bangkok")
mock_get.assert_not_called() # proof the patch never intercepted the call
def test_the_correct_target_is_the_importing_modules_own_name():
fake_response = MagicMock()
fake_response.json.return_value = {"temp_c": 99.0}
with patch("weather._get", return_value=fake_response) as mock_get:
assert weather.get_temperature_wrong_target("bangkok") == 99.0
mock_get.assert_called_once()

รันจริง — ผ่านทั้งสาม และเทสตรงกลางคือหลักฐาน ไม่ใช่แค่สมมติฐาน:

$ uv run pytest test_patch_location.py -v
test_patch_location.py::test_patching_requests_get_works_when_the_caller_did_import_requests PASSED
test_patch_location.py::test_patching_requests_get_is_a_NO_OP_after_a_from_import PASSED
test_patch_location.py::test_the_correct_target_is_the_importing_modules_own_name PASSED
3 passed in 0.22s

กฎง่าย ๆ: patch name ตามที่มันถูกเขียนไว้ในคำสั่ง import ของโค้ดที่ถูกทดสอบ เช่น patch("weather._get") สำหรับ from requests import get as _get หรือ patch("weather.requests.get") / patch("requests.get") สำหรับ import requests ธรรมดา

MagicMock() เปล่า ๆ จะสร้าง attribute อะไรก็ได้ที่คุณเรียก — รวมถึงชื่อที่พิมพ์ผิดจากเมธอดจริง autospec=True สร้าง mock จาก signature จริงของ object นั้น ทำให้ attribute หรือ signature ที่ผิดล้มเหลวดัง ๆ แทนที่จะผ่านเงียบ ๆ

from unittest.mock import MagicMock, create_autospec, patch
import app
def send_notification(account: app.Account, message: str) -> None:
# BUG: Account has withdraw/deposit, not notify() -- a typo an AI
# assistant can introduce as easily as a human can.
account.notify(message)
def test_plain_magicmock_hides_the_typo():
fake_account = MagicMock()
send_notification(fake_account, "hi") # MagicMock invents .notify() on the fly
fake_account.notify.assert_called_once_with("hi") # passes -- proves nothing
def test_autospec_catches_the_typo():
fake_account = create_autospec(app.Account, instance=True)
with pytest.raises(AttributeError, match="notify"):
send_notification(fake_account, "hi")
@patch("app.Account", autospec=True)
def test_patch_autospec_true_also_validates_call_signatures(MockAccount):
instance = MockAccount(balance=10)
instance.withdraw(5) # fine -- real signature
with pytest.raises(TypeError):
instance.withdraw(amount=5, extra=1) # wrong signature, rejected

รันจริง ผ่านทั้งสาม — ตัวแรกที่ “ผ่าน” คือกับดักตัวจริง:

$ uv run pytest test_autospec.py -v
test_autospec.py::test_plain_magicmock_hides_the_typo PASSED
test_autospec.py::test_autospec_catches_the_typo PASSED
test_autospec.py::test_patch_autospec_true_also_validates_call_signatures PASSED
3 passed in 0.09s

ใช้ create_autospec/autospec=True เป็นค่าเริ่มต้นสำหรับ object ที่มีคลาสจริงอยู่เบื้องหลัง เก็บ MagicMock() เปล่า ๆ ไว้ใช้กับ object ใช้แล้วทิ้งที่ไม่มี interface ต้องปกป้อง

pytest ธรรมดาไม่ await coroutine ให้คุณ — async def test_... โดยไม่มีปลั๊กอินจะแค่คืน coroutine object ที่ไม่ได้ await และเทส “ผ่าน” เงียบ ๆ ทั้งที่ไม่ได้ทดสอบอะไรเลย @pytest.mark.asyncio ของ pytest-asyncio แก้ปัญหานี้

import asyncio
import pytest
async def fetch_value() -> int:
await asyncio.sleep(0)
return 42
@pytest.mark.asyncio
async def test_fetch_value():
assert await fetch_value() == 42
$ uv add --dev pytest-asyncio
$ uv run pytest test_asyncio_teaser.py -v
test_asyncio_teaser.py::test_fetch_value PASSED
1 passed in 0.08s

ปลั๊กอิน pytest ของ anyio (anyio.pytest_plugin) ให้แนวคิดเดียวกันแต่รันแต่ละเทส async กับหลาย backend (asyncio, trio) — ใช้เมื่อไลบรารีต้องรองรับทั้งคู่ โมดูล Async & Concurrency สอน asyncio เองแบบลึกกว่านี้

Coverage วัดว่าบรรทัดไหนถูกรัน ไม่เคยวัดว่า assertion ตรวจสิ่งที่ถูกต้องหรือไม่

"""pricing.py"""
def apply_discount(price: float, percent: float) -> float:
if percent < 0 or percent > 100:
raise ValueError("percent must be between 0 and 100")
# BUG: divides by 100 twice -- the discount is 100x too small
return price - (price * percent / 100 / 100)
"""test_pricing.py -- 100% coverage, weak assertions, real bug survives"""
import pytest
import pricing
def test_apply_discount_runs_without_error():
result = pricing.apply_discount(100.0, 20.0)
assert isinstance(result, float) # never checks the actual number
def test_apply_discount_rejects_bad_percent():
with pytest.raises(ValueError):
pricing.apply_discount(100.0, 150.0)
$ uv run pytest test_pricing.py --cov=pricing --cov-report=term-missing -v
test_pricing.py::test_apply_discount_runs_without_error PASSED
test_pricing.py::test_apply_discount_rejects_bad_percent PASSED
Name Stmts Miss Cover Missing
------------------------------------------
pricing.py 4 0 100%
------------------------------------------
2 passed in 0.10s

coverage 100% เทสผ่านทั้งคู่ — แต่ apply_discount(100.0, 20.0) คืนค่าจริง 99.8 ไม่ใช่ 80.0 ที่ส่วนลด 20% ที่ถูกต้องควรได้ Coverage เป็นพื้นล่าง (โค้ดที่ไม่ถูกรันแน่นอนว่าไม่ถูกทดสอบ) ไม่ใช่เพดานบน (โค้ดที่ถูกรันไม่ได้แปลว่าถูกต้อง) — AI assistant ที่ถูกขอให้ “ทำ coverage ให้ถึง 100%” จะเขียน assertion ที่อ่อนแบบนี้อย่างเต็มใจ เว้นแต่จะถูกบอกว่า assertion ต้องตรวจค่าจริง

แทนที่จะเลือกตัวอย่างเองด้วยมือ hypothesis สร้างตัวอย่างให้และย่อ (shrink) ความล้มเหลวใด ๆ ให้เหลือ reproducer ที่เล็กที่สุด

"""clamp -- forgot to enforce the upper bound."""
from hypothesis import given, strategies as st
def clamp(value: int, low: int, high: int) -> int:
# BUG: only enforces the lower bound
return max(value, min(low, high))
@given(st.integers(), st.integers(), st.integers())
def test_clamp_result_is_always_within_bounds(value, low, high):
if low > high:
return # not a valid range, skip
result = clamp(value, low, high)
assert low <= result <= high

ความล้มเหลวจริง ถูกย่อลงจนเหลือกรณีที่เล็กที่สุดโดยอัตโนมัติ:

$ uv run pytest test_hypothesis_demo.py -v
FAILED test_hypothesis_demo.py::test_clamp_result_is_always_within_bounds
assert low <= result <= high
E assert 1 <= 0
E Failing test case: test_clamp_result_is_always_within_bounds(
E value=1, low=0, high=0,
E )
1 failed in 0.16s

การแก้ (min(max(value, low), high)) ทำให้ผ่านทั้ง 100 กรณีที่ hypothesis สร้างตาม default เทสแบบ example-based ที่เลือกตัวเลขเองไม่กี่ตัวพลาด value=1, low=0, high=0 ได้ง่าย — หน้าที่ของ hypothesis คือหา input ที่มนุษย์ไม่คิดจะลองโดยเฉพาะ

บรรทัด >>> ใน docstring คือตัวอย่างที่รันได้ซึ่ง pytest รันเป็นเทสได้ — มีประโยชน์ในการรักษาให้เอกสารตรงกับความจริง ไม่ใช่ตัวแทนของ test suite จริง

"""strings_util.py"""
def shout(text: str) -> str:
"""Uppercase and add three exclamation marks.
>>> shout("hello")
'HELLO!!!'
>>> shout("")
'!!!'
"""
return text.upper() + "!!!"
$ uv run pytest --doctest-modules strings_util.py -v
strings_util.py::strings_util.shout PASSED
1 passed in 0.09s

Fixture ใน conftest.py มองเห็นได้จากทุกไฟล์ทดสอบในไดเรกทอรีนั้นและไดเรกทอรีย่อย และ conftest.py ที่ซ้อนอยู่ข้างในสามารถต่อยอดจาก fixture ของ conftest ระดับบนได้โดยไม่ต้อง import

# conftest.py (project root)
import pytest
@pytest.fixture
def base_config():
return {"env": "test"}
subpkg/conftest.py
import pytest
@pytest.fixture
def extra_config(base_config): # layers on the ROOT conftest's fixture
return {**base_config, "region": "th"}
subpkg/test_layered.py
def test_uses_both_conftest_layers(extra_config):
assert extra_config == {"env": "test", "region": "th"}
$ uv run pytest subpkg/test_layered.py -v
subpkg/test_layered.py::test_uses_both_conftest_layers PASSED
1 passed in 0.09s
$ uv run pytest -x -q # stop at the first failure
$ uv run pytest -k withdraw # run only tests whose name matches "withdraw"
$ uv run pytest --lf # re-run only the tests that failed last time
$ uv run pytest -q # quiet: dots instead of one line per test

จริง: หลังเทส hypothesis ด้านบนล้มเหลว --lf รันซ้ำเฉพาะเทสตัวนั้น:

$ uv run pytest --lf -q
FAILED test_hypothesis_demo.py::test_clamp_result_is_always_within_bounds - assert 1 <= 0
1 failed in 0.12s

-k withdraw กับทั้ง suite เลือกที่ตรงกันหนึ่งตัวและ deselect ที่เหลือทั้งหมด:

$ uv run pytest -k withdraw -q
x. [100%]
1 passed, 10 deselected, 1 xfailed in 0.14s

-x หยุดทั้งรันที่ความล้มเหลวแรก — มีประโยชน์ในลูป edit-run ที่ไว แต่อันตรายใน CI ที่ปกติอยากเห็นทุกความล้มเหลวพร้อมกัน

flowchart TD
Collect["Collection: pytest scans test_*.py / *_test.py for test_* functions"] --> Order["Fixtures requested by parameter name are resolved and ordered by scope"]
Order --> Setup["Setup runs widest scope first: session -> module -> class -> function"]
Setup --> Run["Test function body runs with fixture values injected"]
Run --> Teardown["Teardown runs in reverse: function -> class -> module -> session"]
Teardown -->|"next test in same scope"| Order
วงจรชีวิตของ test discovery และ fixture ใน pytest

pytest ไม่เคยเรียกฟังก์ชันทดสอบของคุณโดยไม่มี argument ตรง ๆ — มันตรวจชื่อพารามิเตอร์ของฟังก์ชัน จับคู่แต่ละชื่อกับ fixture ที่ชื่อตรงกัน (ค้นหาในไฟล์ทดสอบก่อน แล้วไล่ทุก conftest.py จากไดเรกทอรีของเทสขึ้นไปจนถึง rootdir) แล้วสร้างกราฟ dependency fixture ที่หลายเทสใน scope เดียวกันต้องการจะถูกคำนวณครั้งเดียวแล้วใช้ซ้ำ scope ที่คุณประกาศบน @pytest.fixture คือตัวควบคุมว่าแชร์มากแค่ไหนและteardown เมื่อไร ไม่ใช่ลำดับที่ fixture ถูกเขียนในไฟล์

สิ่งที่ได้ประโยชน์ต้นทุน
ฟังก์ชันธรรมดา + assertไม่มี boilerplate ผลลัพธ์เมื่อล้มเหลวอ่านง่ายมีโครงสร้างน้อยกว่า unittest.TestCase สำหรับคนที่ต้องการมัน
fixture scope กว้างขึ้น (module/session)test suite เร็วขึ้นมากstate ที่เปลี่ยนแปลงได้ที่ถูกแชร์อาจรั่วไหลข้ามเทสถ้าไม่ระวัง
autospec=Trueจับการพิมพ์ผิดและ signature ที่ไม่ตรงsetup ช้ากว่าและมากกว่า MagicMock() เปล่า ๆ เล็กน้อย
pytest-covชี้บรรทัดที่ไม่ถูกทดสอบเลย100% ไม่บอกอะไรเกี่ยวกับคุณภาพของ assertion
hypothesisหา edge case ที่คุณไม่คิดจะลองพื้นที่ input ไม่แน่นอน; ความล้มเหลวต้องอ่านผ่านการ shrink แบบ min(max(...))
--lf / -k ในลูปพัฒนาวนซ้ำได้เร็ว-x/-k ใน CI อาจซ่อนความล้มเหลวที่การรันเต็มจะจับได้
  • “coverage 100% แปลว่าโค้ดถูกทดสอบดีแล้ว” Coverage พิสูจน์แค่ว่าบรรทัดถูกรัน — ตัวอย่าง pricing.py ด้านบนมี coverage 100% และมี bug จริง (99.8 แทนที่จะเป็น 80.0) เพราะ assertion ไม่เคยตรวจค่าที่คืนมาจริง ๆ
  • mock.patch("requests.get") ใช้ได้เสมอถ้าโค้ด import requests ขึ้นอยู่กับวิธี: import requests (lookup attribute ตอนเรียกจริง) patch แบบนี้ได้; from requests import get ผูกชื่อเข้ากับโมดูลของผู้เรียกเอง ต้อง patch ชื่อนั้นเท่านั้นถึงจะได้ผล
  • MagicMock() เปล่า ๆ เป็นตัวแทนที่ปลอดภัยสำหรับ object ใดก็ได้ มันรับ attribute หรือเมธอดชื่ออะไรก็ได้ รวมถึงที่พิมพ์ผิด — create_autospec/autospec=True จำกัดให้เหลือแค่ interface จริงของ object นั้น
  • caplog จับ output ของ print() หรือ capsys จับ record ของ logging ทั้งสองครอบคลุมคนละเรื่อง: capsys คือ stdout/stderr ส่วน caplog คือ record ของโมดูล logging
  • fixture scope ที่กว้างเป็นการเพิ่มความเร็วที่ปลอดภัยเสมอ fixture ระดับ session ที่คืน object ที่เปลี่ยนแปลงได้ (list, dict, fake DB) สามารถพา state จากเทสหนึ่งไปยังเทสถัดไปได้ถ้ามีเทสใด mutate มัน — scope ระดับ function คือค่าเริ่มต้นที่ปลอดภัย ขยายอย่างตั้งใจเท่านั้น

ก่อนเชื่อไฟล์เทสที่ถูกสร้างขึ้น ให้ตรวจ:

  • mock.patch("some.library.function") ในขณะที่โค้ดที่ถูกทดสอบทำ from some.library import function — เป้าหมาย patch ไม่ตรงกับวิธี import name นั้น mock จึงไม่เคย intercept อะไรเลย
  • เทสที่ไม่มี assert เลย หรือ assertion ที่อ่อนมากจนผ่านได้กับค่าที่คืนเกือบทุกแบบ (assert result is not None, assert isinstance(result, float))
  • MagicMock() ที่เป็นตัวแทนของคลาสจริงโดยไม่มี autospec — การเรียกเมธอดที่เปลี่ยนชื่อหรือพิมพ์ผิดบนมันจะไม่มีวันล้มเหลว
  • @pytest.mark.xfail หรือ @pytest.mark.skip ที่ถูกเพิ่มเพื่อทำให้ suite “ผ่าน” โดยไม่มี reason= อธิบายเหตุผล หรือไม่มี issue เชื่อมโยง
  • mark ที่กำหนดเองถูกใช้แต่ไม่เคยลงทะเบียนใน pyproject.toml — ผ่านในเครื่องวันนี้ แต่พังทันทีที่ --strict-markers ถูกเปิดใน CI

🐛 หา bug ในโค้ดนี้

from unittest.mock import MagicMock, patch
import requests
def fetch_price(item_id: str) -> float:
resp = requests.get(f"https://api.example.com/price/{item_id}")
return resp.json()["price"]
# elsewhere: from requests import get as http_get -- pricing.py imports it directly
import pricing
def test_fetch_price():
with patch("requests.get") as mock_get:
mock_get.return_value = MagicMock(json=lambda: {"price": 9.99})
assert pricing.fetch_price("abc") == 9.99
เฉลย

pricing.py ทำ from requests import get as http_get ดังนั้น pricing.http_get เป็นชื่อของตัวเองที่ผูกไว้ตอน import — patch requests.get ไม่มีวันแตะมันได้ เทสนี้ผ่านที่นี่เพียงเพราะมันเผลอเรียกฟังก์ชันอื่น (fetch_price ที่นิยามในไฟล์นี้เอง ซึ่งทำ import requests ตรง ๆ) แทนที่จะทดสอบ pricing.fetch_price เลย เป้าหมายที่ถูกต้องสำหรับ pricing.fetch_price จริง ๆ คือ patch("pricing.http_get") ยืนยันแล้ว: การ patch requests.get ขณะเรียกฟังก์ชันที่ใช้ from-import ที่ถูกทดสอบ จะ raise requests.exceptions.ConnectionError จริง แทนที่จะไปถึง mock เลย

ข้อตกลงการตั้งชื่อใดที่ไฟล์ทดสอบและฟังก์ชันทดสอบต้องปฏิบัติตามเพื่อให้ pytest ค้นพบ?
@pytest.fixture ทำอะไร?
@pytest.mark.parametrize ทำอะไรได้?
weather.py มี from requests import get as _get และเรียก _get(...) เกิดอะไรขึ้นจริง?
with patch("requests.get", return_value=fake):
    weather.get_temperature_wrong_target("bkk")
Account มีแค่ withdraw/deposit ไม่มี notify เทสนี้ผ่านไหม?
def test_plain_magicmock_hides_the_typo():
    fake_account = MagicMock()
    send_notification(fake_account, "hi")   # calls fake_account.notify(...)
    fake_account.notify.assert_called_once_with("hi")
test suite ได้ line coverage 100% ด้วย pytest-cov สิ่งนี้รับประกันอะไร?
hypothesis ทำอะไรเมื่อเทสนี้ล้มเหลว?
@given(st.integers(), st.integers(), st.integers())
def test_clamp_result_is_always_within_bounds(value, low, high):
    if low > high:
        return
    result = clamp(value, low, high)
    assert low <= result <= high