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_ ไม่จำเป็นต้องมีคลาส — ฟังก์ชันระดับโมดูลก็เพียงพอ
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 / bfrom math_utils import add, divideimport 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)การรันการทดสอบ
หัวข้อที่มีชื่อว่า “การรันการทดสอบ”# run all testspython -m pytest
# verbose outputpython -m pytest -v
# run only tests matching a keywordpython -m pytest -k "divide"Fixtures
หัวข้อที่มีชื่อว่า “Fixtures”Fixture คือฟังก์ชัน setup (และ teardown) ที่นำกลับมาใช้ซ้ำได้ pytest จะส่งผ่านเข้าสู่ฟังก์ชันทดสอบตามชื่อพารามิเตอร์ — ไม่ต้องมีคลาสฐาน ไม่ต้องมีเมธอด setUp ใช้ yield เพื่อแยกส่วน setup ออกจาก teardown
import pytest
@pytest.fixturedef 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.fixturedef 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 TrueParametrize
หัวข้อที่มีชื่อว่า “Parametrize”@pytest.mark.parametrize รันฟังก์ชันทดสอบเดียวกันหลายครั้งด้วยชุดข้อมูล input ที่แตกต่างกัน ทำให้ test suite กระชับและเพิ่ม edge case ได้ง่าย
import pytestfrom 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) == expectedfixture ในตัวสำหรับงาน I/O ทั้งสี่: monkeypatch, tmp_path, capsys, caplog
หัวข้อที่มีชื่อว่า “fixture ในตัวสำหรับงาน I/O ทั้งสี่: monkeypatch, tmp_path, capsys, caplog”โค้ดจริงต้องแตะ environment variable, filesystem, stdout และ logger pytest มี fixture ที่ทำให้ทั้งสี่อย่างนี้ปลอดภัยและตรวจสอบได้ในเทส โดยไม่ต้อง cleanup เอง
"""app.py -- exercises an env var, a file, stdout, and a logger."""import loggingimport osfrom 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 loggingfrom 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 -vtest_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.12smonkeypatch คืนค่าทุกอย่างที่เปลี่ยน (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
Marks: skip, xfail และ mark ที่กำหนดเอง
หัวข้อที่มีชื่อว่า “Marks: skip, xfail และ mark ที่กำหนดเอง”import sysimport pytestimport 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.slowdef 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_000mark ที่กำหนดเองอย่าง slow ต้องลงทะเบียนก่อน ไม่งั้น pytest จะถือว่าเป็นการพิมพ์ผิด:
[tool.pytest.ini_options]markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')",]รันจริง mark ที่ไม่ได้ลงทะเบียนถูกปฏิเสธทันที:
$ uv run pytest --strict-markers -qERROR 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 -vtest_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" -qs.x. [100%]2 passed, 1 skipped, 1 deselected, 1 xfailed in 0.09sเทสที่ทำ xfail แล้วดันผ่านโดยไม่คาดคิดจะถูกรายงานเป็น XPASS — สัญญาณที่มีประโยชน์ว่า bug ที่รู้จักถูกแก้แล้ว และควรเอา mark ออก
ความล้มเหลวที่แม่นยำด้วย pytest.raises(match=)
หัวข้อที่มีชื่อว่า “ความล้มเหลวที่แม่นยำด้วย pytest.raises(match=)”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 ถูกต้อง แต่สาเหตุอาจไม่ใช่
Fixture scope: function → class → module → session
หัวข้อที่มีชื่อว่า “Fixture scope: function → class → module → session”โดย 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 -stest_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] sessionfixture ระดับ 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, patchimport pytestimport requestsimport 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 -vtest_patch_location.py::test_patching_requests_get_works_when_the_caller_did_import_requests PASSEDtest_patch_location.py::test_patching_requests_get_is_a_NO_OP_after_a_from_import PASSEDtest_patch_location.py::test_the_correct_target_is_the_importing_modules_own_name PASSED3 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 ธรรมดา
autospec=True — MagicMock ธรรมดาซ่อนการพิมพ์ผิด autospec จับได้
หัวข้อที่มีชื่อว่า “autospec=True — MagicMock ธรรมดาซ่อนการพิมพ์ผิด autospec จับได้”MagicMock() เปล่า ๆ จะสร้าง attribute อะไรก็ได้ที่คุณเรียก — รวมถึงชื่อที่พิมพ์ผิดจากเมธอดจริง autospec=True สร้าง mock จาก signature จริงของ object นั้น ทำให้ attribute หรือ signature ที่ผิดล้มเหลวดัง ๆ แทนที่จะผ่านเงียบ ๆ
from unittest.mock import MagicMock, create_autospec, patchimport 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 -vtest_autospec.py::test_plain_magicmock_hides_the_typo PASSEDtest_autospec.py::test_autospec_catches_the_typo PASSEDtest_autospec.py::test_patch_autospec_true_also_validates_call_signatures PASSED3 passed in 0.09sใช้ create_autospec/autospec=True เป็นค่าเริ่มต้นสำหรับ object ที่มีคลาสจริงอยู่เบื้องหลัง เก็บ MagicMock() เปล่า ๆ ไว้ใช้กับ object ใช้แล้วทิ้งที่ไม่มี interface ต้องปกป้อง
เทส async: ตัวอย่างเบื้องต้นของ pytest-asyncio
หัวข้อที่มีชื่อว่า “เทส async: ตัวอย่างเบื้องต้นของ pytest-asyncio”pytest ธรรมดาไม่ await coroutine ให้คุณ — async def test_... โดยไม่มีปลั๊กอินจะแค่คืน coroutine object ที่ไม่ได้ await และเทส “ผ่าน” เงียบ ๆ ทั้งที่ไม่ได้ทดสอบอะไรเลย @pytest.mark.asyncio ของ pytest-asyncio แก้ปัญหานี้
import asyncioimport pytest
async def fetch_value() -> int: await asyncio.sleep(0) return 42
@pytest.mark.asyncioasync def test_fetch_value(): assert await fetch_value() == 42$ uv add --dev pytest-asyncio$ uv run pytest test_asyncio_teaser.py -vtest_asyncio_teaser.py::test_fetch_value PASSED1 passed in 0.08sปลั๊กอิน pytest ของ anyio (anyio.pytest_plugin) ให้แนวคิดเดียวกันแต่รันแต่ละเทส async กับหลาย backend (asyncio, trio) — ใช้เมื่อไลบรารีต้องรองรับทั้งคู่ โมดูล Async & Concurrency สอน asyncio เองแบบลึกกว่านี้
pytest-cov — และทำไม coverage 100% จึงพิสูจน์อะไรได้น้อย
หัวข้อที่มีชื่อว่า “pytest-cov — และทำไม coverage 100% จึงพิสูจน์อะไรได้น้อย”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 pytestimport 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 -vtest_pricing.py::test_apply_discount_runs_without_error PASSEDtest_pricing.py::test_apply_discount_rejects_bad_percent PASSED
Name Stmts Miss Cover Missing------------------------------------------pricing.py 4 0 100%------------------------------------------2 passed in 0.10scoverage 100% เทสผ่านทั้งคู่ — แต่ apply_discount(100.0, 20.0) คืนค่าจริง 99.8 ไม่ใช่ 80.0 ที่ส่วนลด 20% ที่ถูกต้องควรได้ Coverage เป็นพื้นล่าง (โค้ดที่ไม่ถูกรันแน่นอนว่าไม่ถูกทดสอบ) ไม่ใช่เพดานบน (โค้ดที่ถูกรันไม่ได้แปลว่าถูกต้อง) — AI assistant ที่ถูกขอให้ “ทำ coverage ให้ถึง 100%” จะเขียน assertion ที่อ่อนแบบนี้อย่างเต็มใจ เว้นแต่จะถูกบอกว่า assertion ต้องตรวจค่าจริง
ตัวอย่าง hypothesis หนึ่งตัว: property-based testing
หัวข้อที่มีชื่อว่า “ตัวอย่าง hypothesis หนึ่งตัว: property-based testing”แทนที่จะเลือกตัวอย่างเองด้วยมือ 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 -vFAILED test_hypothesis_demo.py::test_clamp_result_is_always_within_bounds assert low <= result <= highE assert 1 <= 0E 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 ที่มนุษย์ไม่คิดจะลองโดยเฉพาะ
doctest: เทสที่อาศัยอยู่ใน docstring ของคุณเอง
หัวข้อที่มีชื่อว่า “doctest: เทสที่อาศัยอยู่ใน docstring ของคุณเอง”บรรทัด >>> ใน 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 -vstrings_util.py::strings_util.shout PASSED1 passed in 0.09sการซ้อนชั้นของ conftest.py
หัวข้อที่มีชื่อว่า “การซ้อนชั้นของ conftest.py”Fixture ใน conftest.py มองเห็นได้จากทุกไฟล์ทดสอบในไดเรกทอรีนั้นและไดเรกทอรีย่อย และ conftest.py ที่ซ้อนอยู่ข้างในสามารถต่อยอดจาก fixture ของ conftest ระดับบนได้โดยไม่ต้อง import
# conftest.py (project root)import pytest
@pytest.fixturedef base_config(): return {"env": "test"}import pytest
@pytest.fixturedef extra_config(base_config): # layers on the ROOT conftest's fixture return {**base_config, "region": "th"}def test_uses_both_conftest_layers(extra_config): assert extra_config == {"env": "test", "region": "th"}$ uv run pytest subpkg/test_layered.py -vsubpkg/test_layered.py::test_uses_both_conftest_layers PASSED1 passed in 0.09sflag ของ CLI ที่มีประโยชน์: -x, -k, --lf, -q
หัวข้อที่มีชื่อว่า “flag ของ CLI ที่มีประโยชน์: -x, -k, --lf, -q”$ 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 -qFAILED test_hypothesis_demo.py::test_clamp_result_is_always_within_bounds - assert 1 <= 01 failed in 0.12s-k withdraw กับทั้ง suite เลือกที่ตรงกันหนึ่งตัวและ deselect ที่เหลือทั้งหมด:
$ uv run pytest -k withdraw -qx. [100%]1 passed, 10 deselected, 1 xfailed in 0.14s-x หยุดทั้งรันที่ความล้มเหลวแรก — มีประโยชน์ในลูป edit-run ที่ไว แต่อันตรายใน CI ที่ปกติอยากเห็นทุกความล้มเหลวพร้อมกัน
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”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
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")ใช้ได้เสมอถ้าโค้ด importrequestsขึ้นอยู่กับวิธี: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 คือค่าเริ่มต้นที่ปลอดภัย ขยายอย่างตั้งใจเท่านั้น
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ก่อนเชื่อไฟล์เทสที่ถูกสร้างขึ้น ให้ตรวจ:
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 directlyimport 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 เลย