Alert
์ด ๊ธ์ Claude Code์ ๋์์ ๋ฐ์ ์์ฑ๋์์ต๋๋ค
TL;DR
- pytest๋ Python ํ ์คํธ ํ๋ ์์ํฌ.
assertํ ์ค๋ก ๊ฒ์ฆํ๊ณ ,pytest๋ช ๋ น์ผ๋ก ํ ์คํธ๋ฅผ ์๋์ผ๋ก ์ฐพ์ ์คํ- ํต์ฌ ๊ธฐ๋ฅ 4๊ฐ์ง: ํ ์คํธ ์๋ ํ์, fixture(์ค๋น ์์ ์ฌ์ฌ์ฉ), parametrize(์ ๋ ฅ๋ง ๋ฐ๊ฟ ๋ฐ๋ณต), ํ๋ถํ ์คํ ์ต์
- mocking์
pytest-mock, ์ปค๋ฒ๋ฆฌ์ง๋pytest-cov์ฒ๋ผ ๊ธฐ๋ฅ์ ํ๋ฌ๊ทธ์ธ์ผ๋ก ํ์ฅ- ๋ณธ์ฒด๋ โํ๋ฌ๊ทธ์ธ์ ๋ผ์ฐ๋ ํ๋ ์์ํฌโ์ด๊ณ ์ํ๊ณ๊ฐ ๋์ด์ ๋น๋๊ธฐยทDjangoยท๋ณ๋ ฌ ์คํ๊น์ง ์ ๋ถ ์ปค๋ฒ
- unittest๋ ํ์ค ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ผ๋ ์ ์ธ์ ์ฐ์๊ฐ ์๊ณ , hypothesis๋ ๊ฒฝ์์๊ฐ ์๋๋ผ pytest ์์ ์น๋ ๋ณด์์ฌ
pytest?
- Python์์ ๊ฐ์ฅ ๋๋ฆฌ ์ฐ์ด๋ ํ ์คํธ ํ๋ ์์ํฌ
assert๋ฌธ ํ๋๋ก ๊ฒ์ฆ, ํด๋์ค/์์ ์์ด ํจ์๋ง์ผ๋ก ํ ์คํธ ์์ฑpytest๋ช ๋ น ํ ๋ฒ์ผ๋ก ํ ์คํธ ํ์ผ์ ์๋์ผ๋ก ์ฐพ์ ์คํํ๊ณ ๊ฒฐ๊ณผ๋ฅผ ๋ณด๊ธฐ ์ข๊ฒ ์ถ๋ ฅ- fixture, parametrize, ๋ง์ปค ๋ฑ์ผ๋ก ๋ฐ๋ณต์ ์ค์ด๊ณ , ํ๋ฌ๊ทธ์ธ์ผ๋ก ๊ธฐ๋ฅ ํ์ฅ
- https://docs.pytest.org/
1. ์ค์น์ ์ฒซ ํ ์คํธ
ํจํค์ง ์ค์น๋ uv ๊ธฐ์ค์ผ๋ก ์์ฑํ๋ค. ํ
์คํธ ๋๊ตฌ๋ ๊ฐ๋ฐ์ฉ์ด๋ฏ๋ก --dev๋ก ์ค์นํ๋ค.
uv add --dev pytest๊ฒ์ฆํ ํจ์์ ํ ์คํธ๋ฅผ ๋ง๋ค์ด ๋ณธ๋ค. ํ์ผ ๋ ๊ฐ๋ฉด ์ถฉ๋ถํ๋ค.
# calculator.py
def add(a, b):
return a + b# test_calculator.py
from calculator import add
def test_add():
assert add(1, 2) == 3์คํ์ pytest ํ ์ค์ด๋ค. ์ธ์ ์์ด ์คํํ๋ฉด ํ์ฌ ํด๋ ์๋ ํ
์คํธ ํ์ผ์ ์์์ ์ฐพ์ ์คํํ๋ค.
uv run pytesttest_calculator.py . [100%]
========================= 1 passed in 0.01s =========================์ฌ๊ธฐ์ test_add ํจ์๋ฅผ ๋ด๊ฐ ์ง์ ํธ์ถํ ์ ์ด ์๋ค. pytest๊ฐ ํ์ผ์ ๋ค์ ธ test_๋ก ์์ํ๋ ํจ์๋ฅผ ์ฐพ์ ๋์ ์คํํ๋ค. ๊ทธ๋์ pytest๋ ๋จ์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๊ฐ ์๋๋ผ ๋ด ํ
์คํธ ์ฝ๋๋ฅผ ๊ฐ์ ธ๋ค ์คํํ๋ ํ๋ ์์ํฌ๋ค.
2. assert โ pytest์ ํต์ฌ
pytest์ ๊ฐ์ฅ ํฐ ์ฅ์ ์ ๋จ์ธ(assertion)์ ๊ทธ๋ฅ ํ์ด์ฌ assert ๋ฌธ์ผ๋ก ํ๋ค๋ ๋ฐ ์๋ค. ๋ณ๋ ๋ฉ์๋๋ฅผ ์ธ์ธ ํ์๊ฐ ์๋ค.
def test_assertions():
assert add(1, 2) == 3 # ๊ฐ์๊ฐ
assert add(1, 2) != 4 # ๋ค๋ฅธ๊ฐ
assert add(-1, 1) == 0
assert isinstance(add(1, 2), int)
assert "py" in "pytest" # ํฌํจ ์ฌ๋ถ
assert [1, 2] == [1, 2] # ๋ฆฌ์คํธ ๋น๊ต์คํจํ๋ฉด pytest๊ฐ ๊ฐ์ ํ์ด์ ๋ณด์ฌ์ค๋ค. ์ด๊ฑธ assertion introspection์ด๋ผ๊ณ ํ๋ค. ์ด๋์ ์ ํ๋ ธ๋์ง ๋ฐ๋ก ๋๋ฌ๋๋ค.
def test_fail_example():
assert add(1, 2) == 5 def test_fail_example():
> assert add(1, 2) == 5
E assert 3 == 5
E + where 3 = add(1, 2)
test_calculator.py:5: AssertionErrorassert 3 == 5, ๊ทธ๋ฆฌ๊ณ 3 = add(1, 2)๊น์ง ์๋์ผ๋ก ๋ณด์ฌ์ค๋ค. ์คํจ ๋ฉ์์ง๋ฅผ ์ง์ ์์ฑํ์ง ์์๋ ๋๋ค. ํ์ํ๋ฉด ๋ฉ์์ง๋ฅผ ๋ง๋ถ์ผ ์๋ ์๋ค.
def test_with_message():
result = add(1, 2)
assert result == 3, f"๊ธฐ๋๊ฐ 3, ์ค์ ๊ฐ {result}"๋ถ๋์์์ ๋น๊ต โ pytest.approx
๋ถ๋์์์ ์ ==๋ก ๋น๊ตํ๋ฉด ์ ๋๋ค. 0.1 + 0.2๋ ์ ํํ 0.3์ด ์๋๊ธฐ ๋๋ฌธ์ด๋ค. ์ด๋ด ๋ pytest.approx๋ฅผ ์ด๋ค.
import pytest
def test_float():
assert 0.1 + 0.2 == pytest.approx(0.3)3. ํ ์คํธ ํ์ ๊ท์น
pytest๊ฐ โ๋ฌด์์ ํ ์คํธ๋ก ์ธ์ํ๋๊ฐโ์๋ ๊ท์น์ด ์๋ค. ์ด ๊ท์น๋ง ์งํค๋ฉด ๋ฑ๋ก์ด๋ import ์์ด ์๋์ผ๋ก ์์ง๋๋ค.
| ๋์ | ๊ท์น |
|---|---|
| ํ์ผ | test_*.py ๋๋ *_test.py |
| ํจ์ | test_๋ก ์์ |
| ํด๋์ค | Test๋ก ์์ (__init__ ์์ด์ผ ํจ) |
| ํด๋์ค ๋ด ๋ฉ์๋ | test_๋ก ์์ |
๋ค ๊ท์น์ ํ๊บผ๋ฒ์ ๋ค ๋ง์กฑํด์ผ ํ๋ ๊ฑด ์๋๋ค. ํ์ผ ๊ท์น๋ง ํ์์ด๊ณ , ๊ทธ ์์์๋ ํจ์ ๋ฐฉ์์ด๋ ํด๋์ค ๋ฐฉ์์ด๋์ ๋ฐ๋ผ ๊ฐ๋ฆฐ๋ค.
# ๋ฐฉ์ 1) ํจ์ ์คํ์ผ - ๊ฐ์ฅ ํํจ. ํ์ผ + ํจ์, ๋ ๊ท์น์ด๋ฉด ๋
# test_calc.py
def test_add():
assert add(1, 2) == 3# ๋ฐฉ์ 2) ํด๋์ค๋ก ๋ฌถ๊ธฐ - ๊ด๋ จ ํ
์คํธ๋ฅผ ๊ทธ๋ฃนํํ๊ณ ์ถ์ ๋๋ง
# ์ด๋๋ ํด๋์ค + ๋ฉ์๋ ๊ท์น์ด ์ธํธ๋ก ์ ์ฉ๋๋ค
# test_calc.py
class TestCalculator:
def test_add(self):
assert add(1, 2) == 3
def test_add_negative(self):
assert add(-1, -1) == -2| ์์ฑ ๋ฐฉ์ | ๋ง์กฑํด์ผ ํ ๊ท์น |
|---|---|
| ํจ์ ๋ฐฉ์ | ํ์ผ + ํจ์ (2๊ฐ) |
| ํด๋์ค ๋ฐฉ์ | ํ์ผ + ํด๋์ค + ๋ฉ์๋ (3๊ฐ) |
๋๋ถ๋ถ ํจ์ ๋ฐฉ์์ผ๋ก ์ฐ๋ฉฐ, ์ด๋ ํ์ โํด๋์คโ ๊ด๋ จ ๋ ๊ท์น์ ํด๋น ์ฌํญ์ด ์๋ค. ๊ท์น์ ์ด๊ธด ํ์ผยทํจ์๋ ์๋ฌ ์์ด ๊ทธ๋ฅ ์์ง์์ ๋น ์ง๋ค. ์๋ฅผ ๋ค์ด ํ์ผ๋ช
์ด helper.py๋ฉด ๊ทธ ์์ test_๋ก ์์ํ๋ ํจ์๊ฐ ์์ด๋ ํต์งธ๋ก ๋ฌด์๋๋ค.
๊ท์น์ ๋ฐ๊ฟ ์๋ ์๋ค
์ด ๋ค์ด๋ฐ ๊ท์น์ ๊ธฐ๋ณธ๊ฐ์ผ ๋ฟ,
pyproject.toml์python_files,python_classes,python_functions์ต์ ์ผ๋ก ๋ณ๊ฒฝํ ์ ์๋ค. ๋ค๋ง ํ ๊ด๋ก์ ๊ธฐ๋ณธ๊ฐ์ ๊ทธ๋๋ก ์ฐ๋ ๊ฒฝ์ฐ๊ฐ ๋๋ถ๋ถ์ด๋ค.
์ค๋ฌด์์๋ ํ
์คํธ ์ฝ๋๋ฅผ tests/ ํด๋์ ๋ชจ์๋ค. ์ด๋ ๊ทธ๋ฅ uv run pytest๋ง ์น๋ฉด pytest๊ฐ ํ๋ก์ ํธ ๋ฃจํธ ์ ์ฒด๋ฅผ ๋ค์ง๋ฏ๋ก, ๋ฒ์๋ฅผ ์ขํ๋ ค๋ฉด ๋งค๋ฒ ๊ฒฝ๋ก๋ฅผ ๋ถ์ฌ uv run pytest tests/์ฒ๋ผ ์คํํด์ผ ํ๋ค. pyproject.toml์ ํ์ ๊ฒฝ๋ก๋ฅผ ํ ๋ฒ ์ง์ ํด๋๋ฉด ์ด ๊ฒฝ๋ก๋ฅผ ์๋ตํ ์ ์๋ค.
[tool.pytest.ini_options]
testpaths = ["tests"]์ด๋ ๊ฒ ๋๋ฉด ์ธ์ ์์ด uv run pytest๋ง ์ณ๋ tests/๋ง ํ์ํ๋ค.
4. ํ ์คํธ ์คํ โ ์์๋๋ฉด ์ข์ ์ต์
pytest๋ ์ต์
์ผ๋ก ์คํ ๋ฒ์์ ์ถ๋ ฅ์ ์ธ๋ฐํ๊ฒ ์กฐ์ ํ๋ค. ์ค๋ฌด์์ ์์ฃผ ์ฐ๋ ๊ฒ๋ค์ด๋ค.
uv run pytest # ์ ์ฒด ์คํ
uv run pytest tests/test_calc.py # ํน์ ํ์ผ๋ง
uv run pytest tests/test_calc.py::test_add # ํน์ ํจ์๋ง
uv run pytest -v # ํ
์คํธ ์ด๋ฆ๊น์ง ์์ธํ ์ถ๋ ฅ
uv run pytest -q # ๊ฐ๊ฒฐํ๊ฒ ์ถ๋ ฅ
uv run pytest -s # print() ์ถ๋ ฅ ๋ณด์ด๊ฒ (์บก์ฒ ๋๊ธฐ)
uv run pytest -x # ์ฒซ ์คํจ์์ ์ฆ์ ์ค๋จ
uv run pytest --maxfail=2 # 2๊ฐ ์คํจํ๋ฉด ์ค๋จ
uv run pytest -k "add and not negative" # ์ด๋ฆ์ผ๋ก ํํฐ๋ง
uv run pytest -m slow # ํน์ ๋ง์ปค๋ง ์คํ
uv run pytest --lf # ๋ง์ง๋ง์ ์คํจํ ๊ฒ๋ง (last-failed)
uv run pytest --ff # ์คํจํ ๊ฒ ๋จผ์ ์คํ (failed-first)-m์ โ๋ง์ปค(marker)โ๋ ํ
์คํธ์ ๋ถ์ด๋ ๊ผฌ๋ฆฌํ๋ค. slow, integration์ฒ๋ผ ๋ถ๋ฅํด๋๊ณ ๊ณจ๋ผ ์คํํ ๋ ์ฐ๋๋ฐ, ๋ค๋ ๋ฒ๊ณผ ํ์ฉ์ 9๋ฒ ์น์
์์ ๋ค๋ฃฌ๋ค.
ํนํ -k๋ ์์ฃผ ์ด๋ค. ํ
์คํธ ์ด๋ฆ์ ์ผ๋ถ ๋ฌธ์์ด๋ก ํํฐ๋งํ๋ฉฐ and, or, not์ ์กฐํฉํ ์ ์๋ค.
# ์ด๋ฆ์ 'user'๊ฐ ๋ค์ด๊ฐ๊ณ 'delete'๋ ์ ๋ค์ด๊ฐ ํ
์คํธ๋ง
uv run pytest -k "user and not delete"๊ฐ๋ฐ ์ค์๋ -x --lf ์กฐํฉ
์คํจํ๋ ํ ์คํธ๋ฅผ ๊ณ ์น ๋๋
pytest -x --lf๊ฐ ํธํ๋ค. ๋ง์ง๋ง์ ์คํจํ ํ ์คํธ๋ง, ๊ทธ๊ฒ๋ ์ฒซ ์คํจ์์ ๋ฉ์ถฐ ์คํํ๋ฏ๋ก ํ ๋ฒ์ ํ๋์ฉ ์ก์๋๊ฐ ์ ์๋ค. TDD ๋ ๋-๊ทธ๋ฆฐ ์ฌ์ดํด๊ณผ ์ ๋ง๋๋ค.
5. Fixture โ ์ค๋น ์์ ์ฌ์ฌ์ฉ
ํ ์คํธ๋ง๋ค ๋๊ฐ์ ์ค๋น ์์ (DB ์ฐ๊ฒฐ, ์์ ํ์ผ, ๊ฐ์ฒด ์์ฑ)์ ๋ฐ๋ณตํ๋ฉด ์ฝ๋๊ฐ ์ง์ ๋ถํด์ง๋ค.
fixture๋ฅผ ์ ์ฐ๋ฉด ํ ์คํธ๋ง๋ค ๊ฐ์ ์ค๋น ์ฝ๋๋ฅผ ์ง์ ๋ง๋ค์ด์ผ ํ๋ค.
# fixture ์์ด - ์ค๋น ์ฝ๋๊ฐ ํ
์คํธ๋ง๋ค ์ค๋ณต๋๋ค
def test_user_name():
user = {"name": "argus", "email": "argus@example.com"} # ๋งค๋ฒ ์ง์ ์์ฑ
assert user["name"] == "argus"
def test_user_email():
user = {"name": "argus", "email": "argus@example.com"} # ๋ ๋๊ฐ์ด ์์ฑ
assert "@" in user["email"]ํ ์คํธ๊ฐ 2๊ฐ๋ผ ๊ทธ๋๋ง ๊ฒฌ๋ ๋งํ์ง๋ง, ์ค๋น ์์ ์ด DB ์ฐ๊ฒฐ์ฒ๋ผ ๋ณต์กํด์ง๊ฑฐ๋ ํ ์คํธ๊ฐ ์์ญ ๊ฐ๋ก ๋๋ฉด ๊ฐ์ ์ฝ๋๊ฐ ๊ณ์ ๋ณต์ฌ๋๋ค. ๊ฒ๋ค๊ฐ ์ค๋น ๋ฐฉ์์ด ํ ๋ฒ ๋ฐ๋๋ฉด ๋ชจ๋ ํ ์คํธ๋ฅผ ์ผ์ผ์ด ๊ณ ์ณ์ผ ํ๋ค.
fixture๋ ์ด ์ค๋น ์์ ์ ํจ์๋ก ํ ๋ฒ๋ง ๋ถ๋ฆฌํด๋๊ณ , ํ ์คํธ๊ฐ ์ธ์ ์ด๋ฆ์ผ๋ก ์์ฒญํ๋ฉด pytest๊ฐ ์ฃผ์ ํด์ฃผ๋ ๊ตฌ์กฐ๋ค. ์์กด์ฑ ์ฃผ์ ๊ณผ ๊ฐ์ ๋ฐ์์ด๋ค.
import pytest
@pytest.fixture
def sample_user():
return {"name": "argus", "email": "argus@example.com"}
def test_user_name(sample_user): # ์ธ์ ์ด๋ฆ = fixture ์ด๋ฆ
assert sample_user["name"] == "argus"
def test_user_email(sample_user): # ๋ค๋ฅธ ํ
์คํธ์์๋ ์ฌ์ฌ์ฉ
assert "@" in sample_user["email"]test_user_name(sample_user)์ฒ๋ผ ์ธ์์ fixture ์ด๋ฆ์ ์ ๊ธฐ๋ง ํ๋ฉด, pytest๊ฐ sample_user()๋ฅผ ์คํํด ๊ทธ ๋ฐํ๊ฐ์ ๋๊ฒจ์ค๋ค. ํ
์คํธ๋ง๋ค ์๋ก ํธ์ถ๋๋ฏ๋ก ํ
์คํธ ๊ฐ ์ํ๊ฐ ์์ด์ง ์๋๋ค.
setup/teardown โ yield๋ก ๋ท์ ๋ฆฌ
์ค๋น๋ฟ ์๋๋ผ ์ ๋ฆฌ(ํ์ผ ์ญ์ , ์ฐ๊ฒฐ ์ข
๋ฃ)๊ฐ ํ์ํ๋ฉด yield๋ฅผ ์ด๋ค. yield ์์ ์ค๋น, ๋ค๋ ์ ๋ฆฌ๋ค.
@pytest.fixture
def temp_file(tmp_path):
path = tmp_path / "data.txt"
path.write_text("hello")
yield path # ์ฌ๊ธฐ์ ํ
์คํธ๋ก ๊ฐ์ด ๋์ด๊ฐ
path.unlink() # ํ
์คํธ๊ฐ ๋๋๋ฉด ์คํ (๋ท์ ๋ฆฌ)
def test_read_file(temp_file):
assert temp_file.read_text() == "hello"scope โ fixture ์์ฑ ๋น๋ ์กฐ์
๋น์ผ ์ค๋น ์์
(DB ์ฐ๊ฒฐ ๋ฑ)์ ๋งค ํ
์คํธ๋ง๋ค ์๋ก ๋ง๋ค๋ฉด ๋๋ฆฌ๋ค. scope๋ก ์์ฑ ๋น๋๋ฅผ ์กฐ์ ํ๋ค.
@pytest.fixture(scope="session") # ์ ์ฒด ํ
์คํธ์์ ํ ๋ฒ๋ง ์์ฑ
def db_connection():
conn = create_connection()
yield conn
conn.close()| scope | ์์ฑ ๋น๋ |
|---|---|
function (๊ธฐ๋ณธ) | ํ ์คํธ ํจ์๋ง๋ค |
class | ํด๋์ค๋ง๋ค ํ ๋ฒ |
module | ํ์ผ๋ง๋ค ํ ๋ฒ |
session | ์ ์ฒด ์คํ์์ ํ ๋ฒ |
๋ด์ฅ fixture
pytest๊ฐ ๊ธฐ๋ณธ ์ ๊ณตํ๋ fixture๋ ์๋ค. ์์ฃผ ์ฐ๋ ๊ฒ๋ง ์ ๋ฆฌํ๋ค.
def test_builtin(tmp_path, capsys, monkeypatch):
# tmp_path: ํ
์คํธ์ฉ ์์ ๋๋ ํฐ๋ฆฌ (Path ๊ฐ์ฒด)
(tmp_path / "a.txt").write_text("x")
# capsys: print ์ถ๋ ฅ ์บก์ฒ
print("hello")
assert capsys.readouterr().out == "hello\n"
# monkeypatch: ํ๊ฒฝ๋ณ์/์์ฑ ์์ ๋ณ๊ฒฝ (์๋ ๋ณต๊ตฌ)
monkeypatch.setenv("API_KEY", "test-key")6. parametrize โ ์ ๋ ฅ๋ง ๋ฐ๊ฟ ๋ฐ๋ณต ์คํ
๊ฐ์ ๋ก์ง์ ์ฌ๋ฌ ์
๋ ฅ์ผ๋ก ๊ฒ์ฆํ ๋๋ ํ
์คํธ ํจ์๋ฅผ ๋ณต์ฌํ์ง ๋ง๊ณ @pytest.mark.parametrize๋ก ์
๋ ฅ ๋ชฉ๋ก์ ๋๊ธฐ๋ฉด ๋๋ค. ์
๋ ฅ ๊ฐ์๋งํผ ํ
์คํธ๊ฐ ์๋ ์์ฑ๋๋ค.
import pytest
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
])
def test_add(a, b, expected):
assert add(a, b) == expectedtest_calc.py::test_add[1-2-3] PASSED
test_calc.py::test_add[0-0-0] PASSED
test_calc.py::test_add[-1-1-0] PASSED
test_calc.py::test_add[100-200-300] PASSEDํ ์คํธ 4๊ฐ๊ฐ ๋ฐ๋ก ์คํ๋ ๊ฒ์ผ๋ก ์ง๊ณ๋๋ค. ํ๋๊ฐ ์คํจํด๋ ๋๋จธ์ง๋ ๊ณ์ ๋์๊ฐ๋ฏ๋ก, ์ด๋ค ์ ๋ ฅ์์ ๊นจ์ง๋์ง ์ ํํ ์ ์ ์๋ค. ์ฃ์ง ์ผ์ด์ค๋ฅผ ์ถ๊ฐํ ๋ ์ค ํ๋๋ง ๋ฃ์ผ๋ฉด ๋๋ TDD์์ ํนํ ์ ์ฉํ๋ค.
id๋ฅผ ๋ถ์ด๋ฉด ๊ฒฐ๊ณผ ์ถ๋ ฅ์ด ์ฝ๊ธฐ ์ข์์ง๋ค.
@pytest.mark.parametrize("value, expected", [
("", False),
("hello", True),
], ids=["empty_string", "non_empty"])
def test_truthiness(value, expected):
assert bool(value) == expected7. ์์ธ ํ ์คํธ โ pytest.raises
โ์ด ์
๋ ฅ์์๋ ์๋ฌ๊ฐ ๋์ผ ํ๋คโ๋ฅผ ๊ฒ์ฆํ ๋๋ pytest.raises๋ฅผ with ๋ธ๋ก์ผ๋ก ์ด๋ค.
import pytest
def divide(a, b):
if b == 0:
raise ValueError("0์ผ๋ก ๋๋ ์ ์์")
return a / b
def test_divide_by_zero():
with pytest.raises(ValueError):
divide(10, 0)
def test_error_message():
# ์๋ฌ ๋ฉ์์ง๊น์ง ๊ฒ์ฆ (์ ๊ท์ ๋งค์นญ)
with pytest.raises(ValueError, match="๋๋ ์ ์์"):
divide(10, 0)๋ธ๋ก ์์์ ์ง์ ํ ์์ธ๊ฐ ๋ฐ์ํ๋ฉด ํต๊ณผ, ๋ฐ์ํ์ง ์์ผ๋ฉด ์คํจ๋ค.
8. Mocking โ pytest-mock
์ธ๋ถ API ํธ์ถ, DB, ํ์ฌ ์๊ฐ์ฒ๋ผ ํ
์คํธ์์ ์ง์ ์คํํ๊ธฐ ๊ณค๋ํ ๋ถ๋ถ์ ๊ฐ์ง(mock)๋ก ๋ฐ๊ฟ์น๊ธฐํ๋ค. ํ์ค ๋ผ์ด๋ธ๋ฌ๋ฆฌ unittest.mock์ ๊ทธ๋๋ก ์จ๋ ๋์ง๋ง, pytest-mock์ ์ค์นํ๋ฉด mocker fixture๋ก ํ๊ฒฐ ๊น๋ํด์ง๋ค.
uv add --dev pytest-mock์๋ ์์๋ ํ์ผ ๋ ๊ฐ๊ฐ ์ง์ ์ด๋ฃฌ๋ค. app.py๋ ์ค์ ๋ก ๋์ํ๋ ์ฝ๋(ํ
์คํธ ๋์)์ด๊ณ , test_app.py๋ ๊ทธ ์ฝ๋๋ฅผ ๊ฒ์ฆํ๋ ํ
์คํธ๋ค. ๋์ ์ฐ๊ฒฐ๋ผ ์๋ค โ ํ
์คํธ๊ฐ app.py์ fetch_user๋ฅผ ๊ทธ๋๋ก ๋ถ๋ฌ๋ค ์คํํ๋, ๊ทธ ์์์ ์ง์ง ๋คํธ์ํฌ๋ฅผ ํ๋ requests.get๋ง ๊ฐ์ง๋ก ๋ฐ๊ฟ์น๊ธฐํ๋ค.
# app.py - ์ค์ ์ฝ๋. ์ธ๋ถ API๋ฅผ ํธ์ถํ๋ค
import requests
def fetch_user(user_id):
resp = requests.get(f"https://api.example.com/users/{user_id}")
return resp.json()# test_app.py - ์ fetch_user๋ฅผ ํ
์คํธ. requests.get๋ง ๊ฐ์ง๋ก ๊ต์ฒด
from app import fetch_user
def test_fetch_user(mocker):
# requests.get์ ๊ฐ์ง๋ก ๊ต์ฒด
mock_get = mocker.patch("app.requests.get")
mock_get.return_value.json.return_value = {"id": 1, "name": "argus"}
user = fetch_user(1)
assert user["name"] == "argus"
mock_get.assert_called_once() # ์ ํํ ํ ๋ฒ ํธ์ถ๋๋์ง ๊ฒ์ฆ์ค์ ๋คํธ์ํฌ๋ฅผ ํ์ง ์๊ณ ๋ fetch_user์ ๋ก์ง๋ง ๊ฒฉ๋ฆฌํด์ ๊ฒ์ฆํ ์ ์๋ค. mocker๋ ํ
์คํธ๊ฐ ๋๋๋ฉด patch๋ฅผ ์๋์ผ๋ก ์์ ๋ณต๊ตฌํ๋ฏ๋ก ๋ท์ ๋ฆฌ๋ฅผ ์ ๊ฒฝ ์ธ ํ์๊ฐ ์๋ค.
patch๊ฐ ๋์ํ๋ ๋ฐฉ์
mocker.patch("app.requests.get")์ pytest๊ฐ ์ฝ๋๋ฅผ ๋ค์ ธ ์์์ ์ฐพ์์ฃผ๋ ๊ฒ ์๋๋ค. ์ (.)์ผ๋ก ์ด์ด์ง ๊ฒฝ๋ก๋ฅผ ๋จ๊ณ๋ณ๋ก ๋ฐ๋ผ๊ฐ ๋ง์ง๋ง ์์ฑ ํ๋๋ฅผ ๊ฐ์ง๋ก ๊ต์ฒดํ ๋ฟ์ด๋ค.
"app.requests.get"
app โ app ๋ชจ๋์ ์ฐพ๋๋ค
.requests โ ๊ทธ ๋ชจ๋ ์์ requests ๋ฅผ ์ฐพ๋๋ค
.get โ ๊ทธ requests ์ get ์์ฑ์ ๊ฐ์ง(Mock)๋ก ๊ฐ์๋ผ์ด๋ค์ฌ๊ธฐ์ ์ค์ํ ์ ์ app.requests๊ฐ ์ ์ญ์ ํ๋๋ฟ์ธ requests ๋ชจ๋ ๊ฐ์ฒด๋ผ๋ ๊ฒ์ด๋ค. ๋ฐ๋ผ์ get ์์ฑ์ ๋ฐ๊พธ๋ฉด, ํ
์คํธ๊ฐ ๋๋ ๋์ requests.get์ ํธ์ถํ๋ app ์์ ๋ชจ๋ ํจ์๊ฐ ๊ฐ์ง๋ฅผ ๋ฐ๋๋ค. ํจ์๋ง๋ค ๋ฐ๋ก ๊ฑฐ๋ ๊ฒ ์๋๋ผ get์ด๋ผ๋ ์์ฑ ์์ฒด๋ฅผ ๊ต์ฒดํ๊ธฐ ๋๋ฌธ์ด๋ค.
# app.py - requests.get์ ์ฐ๋ ํจ์๊ฐ ๋
def fetch_user(uid):
return requests.get(f".../users/{uid}").json()
def fetch_posts(uid):
return requests.get(f".../users/{uid}/posts").json()def test_fetch_user(mocker):
mocker.patch("app.requests.get") # get ํ๋ ๊ต์ฒด
# โ ์ด ํ
์คํธ ๋์ fetch_user, fetch_posts ๋ ๋ค ๊ฐ์ง requests.get์ ๋ณธ๋ค
...๋์ patch์ ์ ํจ ์๊ฐ์ ๊ทธ ํ
์คํธ ํจ์ ํ๋๋ค. mocker๋ก ๊ฑธ๋ฉด ํ
์คํธ๊ฐ ๋๋๋ ์๊ฐ ์๋ ๋ณต๊ตฌ๋๋ฏ๋ก ๋ค๋ฅธ ํ
์คํธ์๋ ์ํฅ์ด ์๋ค. ๊ทธ๋์ ๋ณดํต์ โํ ํ
์คํธ = ํ ํจ์ ๊ฒ์ฆโ์ผ๋ก ์ง๊ณ , ๊ทธ ํ
์คํธ ์์์ ๋์ ํจ์๋ง ํธ์ถํ๋ฉด ์ฌ์ค์ ๊ทธ ํจ์๋ง ๊ฐ์ง๋ฅผ ์ฐ๋ ํจ๊ณผ๊ฐ ๋๋ค. ํ ํ
์คํธ์์ ์ฌ๋ฌ ํจ์๋ฅผ ํธ์ถํด ์๋ต์ ๋ค๋ฅด๊ฒ ์ฃผ๊ณ ์ถ์ผ๋ฉด side_effect๋ฅผ ์ฐ๊ฑฐ๋ ํ
์คํธ๋ฅผ ๋๋๋ค.
import ๋ฐฉ์์ ๋ฐ๋ผ patch ๊ฒฝ๋ก๊ฐ ๋ฌ๋ผ์ง๋ค
๊ฐ์ง๋ก ๋ฐ๊ฟ ๋์์ โ์ ์๋ ๊ณณโ์ด ์๋๋ผ **โ์ฌ์ฉ๋๋ ๊ณณโ**์ ๊ฒฝ๋ก๋ฅผ ์ ์ด์ผ ํ๋ค. ์ด ์ฐจ์ด๋ import ๋ฐฉ์์ด ๋ค๋ฅผ ๋ ๋ถ๋ช ํด์ง๋ค.
import requestsโrequests.get()ํธ์ถ โmocker.patch("app.requests.get")from requests import getโget()ํธ์ถ โmocker.patch("app.get")๋ ๋ฒ์งธ ๊ฒฝ์ฐ
app์get์ด๋ผ๋ ์๊ธฐ ์ด๋ฆ์ ๋ฐ๋ก ๊ฐ๊ฒ ๋๋ฏ๋ก,app.requests.get์ patchํ๋ฉด ์ ๋จน๋๋ค.app์ด ๋ถ๋ฅด๋ ๊ทธ ์ด๋ฆ(app.get)์ ๊ฐ์๋ผ์์ผ ํ๋ค.
9. ๋ง์ปค โ ํ ์คํธ์ ํ์ ๋ฌ๊ธฐ
๋ง์ปค(marker)๋ ํ
์คํธ์ ๊ผฌ๋ฆฌํ๋ฅผ ๋ถ์ฌ ๋ถ๋ฅํ๊ฑฐ๋ ๋์์ ๋ฐ๊พผ๋ค. @pytest.mark.์ด๋ฆ ํํ๋ค.
import pytest
@pytest.mark.skip(reason="์์ง ๊ตฌํ ์ ๋จ")
def test_future_feature():
...
@pytest.mark.skipif(sys.platform == "win32", reason="๋ฆฌ๋
์ค ์ ์ฉ")
def test_linux_only():
...
@pytest.mark.xfail(reason="์๋ ค์ง ๋ฒ๊ทธ, ์์ ์์ ")
def test_known_bug():
assert buggy_function() == "expected"skip: ๋ฌด์กฐ๊ฑด ๊ฑด๋๋skipif: ์กฐ๊ฑด์ด ์ฐธ์ผ ๋๋ง ๊ฑด๋๋ (OS, ๋ฒ์ ๋ถ๊ธฐ)xfail: ์คํจํ ๊ฒ์ผ๋ก ์์ (์คํจํด๋ ๋นจ๊ฐ๋ถ์ด ์ ๋ธ, ์๋ ค์ง ๋ฒ๊ทธ ํ์์ฉ)
์ปค์คํ ๋ง์ปค๋ก ๋ถ๋ฅ
์ง์ ๋ง์ปค๋ฅผ ๋ง๋ค์ด โ๋๋ฆฐ ํ
์คํธโ๋ฅผ ๋ถ๋ฅํ๊ณ ์ ํ ์คํํ ์ ์๋ค. ๋จผ์ pyproject.toml์ ๋ฑ๋กํ๋ค.
[tool.pytest.ini_options]
markers = [
"slow: ์คํ์ด ์ค๋ ๊ฑธ๋ฆฌ๋ ํ
์คํธ",
]@pytest.mark.slow
def test_heavy_computation():
...uv run pytest -m slow # slow ํ
์คํธ๋ง ์คํ
uv run pytest -m "not slow" # slow ๋นผ๊ณ ์คํ (ํ์ ๋น ๋ฅธ ์คํ์ฉ)10. conftest.py โ fixture ๊ณต์
์ฌ๋ฌ ํ
์คํธ ํ์ผ์์ ๊ฐ์ fixture๋ฅผ ์ฐ๊ณ ์ถ์ผ๋ฉด conftest.py๋ผ๋ ํน๋ณํ ํ์ผ์ ์ ์ํ๋ค. ์ด ํ์ผ์ fixture๋ import ์์ด ๊ฐ์ ํด๋์ ํ์ ํด๋์ ๋ชจ๋ ํ
์คํธ์์ ์๋์ผ๋ก ์ธ ์ ์๋ค.
# tests/conftest.py
import pytest
@pytest.fixture
def api_client():
client = APIClient(base_url="http://test")
yield client
client.close()# tests/test_users.py - import ์์ด ๋ฐ๋ก ์ฌ์ฉ
def test_get_user(api_client):
assert api_client.get("/users/1").status_code == 200conftest.py๋ fixture ๊ณต์ ๋ฟ ์๋๋ผ ํ๋ก์ ํธ ์ ์ญ ์ค์ , ์ปค์คํ
hook์ ๋๋ ์๋ฆฌ์ด๊ธฐ๋ ํ๋ค. ์ฌ์ค์ โํ๋ก์ ํธ ๋ก์ปฌ ํ๋ฌ๊ทธ์ธโ ์ญํ ์ ํ๋ค.
11. ์ปค๋ฒ๋ฆฌ์ง โ pytest-cov
ํ
์คํธ๊ฐ ์ฝ๋์ ์ด๋ ๋ถ๋ถ์ ์ค์ ๋ก ์คํํ๋์ง ์ธก์ ํ๋ ค๋ฉด pytest-cov๋ฅผ ์ด๋ค.
uv add --dev pytest-covuv run pytest --cov=src --cov-report=term-missing์ฌ๊ธฐ์ src๋ ๊ณ ์ ๋ ํค์๋๊ฐ ์๋๋ผ **์ธก์ ๋์์ด ๋๋ ๋ด ์์ค ์ฝ๋์ ๊ฒฝ๋ก(๋๋ ํจํค์ง๋ช
)**๋ค. ํ๋ก์ ํธ ๊ตฌ์กฐ์ ๋ง๊ฒ ๋ฐ๊ฟ์ผ ํ๋ค. ์์ค๊ฐ src/ ํด๋์ ์์ผ๋ฉด --cov=src, ํจํค์ง๋ช
์ด myapp์ด๋ฉด --cov=myapp์ฒ๋ผ ์ด๋ค.
Name Stmts Miss Cover Missing
-------------------------------------------------
src/calculator.py 24 3 88% 45-47
-------------------------------------------------
TOTAL 24 3 88%Missing ์ด์ 45-47์ ํ
์คํธ๊ฐ ๊ฑฐ์น์ง ์์ ์ค ๋ฒํธ๋ค. ์ด๋์ ํ
์คํธ๋ฅผ ๋ ์จ์ผ ํ ์ง ๋ฐ๋ก ๋ณด์ธ๋ค.
์ปค๋ฒ๋ฆฌ์ง๋ ์ด๋ป๊ฒ โ์ด๋ ์ค์ด ํ
์คํธ๋๋์งโ๋ฅผ ์๊น? pytest-cov๊ฐ ํ
์คํธ๋ฅผ ๋๋ฆฌ๋ ๋์ ํ์ด์ฌ ์ธํฐํ๋ฆฌํฐ์ ๋ถ์ด ์ค์ ๋ก ์คํ๋ ์ค์ ์ ๋ถ ๊ธฐ๋กํ๋ค(ํ์ด์ฌ์ trace ๊ธฐ๋ฅ์ ํ์ฉํ๋ค). ์คํ์ด ๋๋๋ฉด ์์ค ํ์ผ์ ์ ์ฒด ์ค๊ณผ ๋์กฐํด, ํ ๋ฒ๋ ์คํ๋์ง ์์ ์ค์ Missing์ผ๋ก ๋ณด๊ณ ํ๋ค. ์ฆ ํ
์คํธ๊ฐ ์ง์ ํธ์ถํ ๊ฒฝ๋ก๋ง โ์คํ๋จโ์ผ๋ก ์ง๊ณ๋๊ณ , ํ
์คํธ๊ฐ ๊ฑด๋๋ฆฌ์ง ์์ ๋ถ๊ธฐ๋ ๊ทธ๋๋ก ๋๋ฌ๋๋ค.
CI์์๋ ์ต์ ์ปค๋ฒ๋ฆฌ์ง๋ฅผ ๊ฐ์ ํ ์ ์๋ค.
uv run pytest --cov=src --cov-fail-under=80 # 80% ๋ฏธ๋ง์ด๋ฉด ์คํจ ์ฒ๋ฆฌ์ปค๋ฒ๋ฆฌ์ง๋ ์์ด์ง ์ง์ด ์๋๋ค
์ปค๋ฒ๋ฆฌ์ง 100%๊ฐ ๋ฒ๊ทธ ์์์ ๋ปํ์ง ์๋๋ค. โ์ค์ ์คํํ๋คโ์ โ์ฌ๋ฐ๋ฅธ์ง ๊ฒ์ฆํ๋คโ๋ ๋ค๋ฅด๋ค. ์ซ์๋ฅผ ์ฑ์ฐ๋ ๊ฒ๋ณด๋ค ํต์ฌ ๋ก์ง์ ์ฃ์ง ์ผ์ด์ค๋ฅผ ์ ๋๋ก ๊ฒ์ฆํ๋ ๊ฒ์ด ์ฐ์ ์ด๋ค.
12. TDD ์ฌ์ดํด ์ค์
์ด ๊ธ์ ์ถ๋ฐ์ ์ด์๋ TDD๋ฅผ pytest๋ก ์ด๋ป๊ฒ ๋๋์ง ์ ๋ฆฌํ๋ค. TDD๋ ์คํจํ๋ ํ ์คํธ ๋จผ์ โ ํต๊ณผํ๋ ์ต์ ์ฝ๋ โ ์ ๋ฆฌ์ ๋ฐ๋ณต์ด๋ค.
Red โ ์คํจํ๋ ํ ์คํธ ๋จผ์
์์ง ์๋ ํจ์์ ๋ํ ํ ์คํธ๋ถํฐ ์ด๋ค.
# test_password.py
from validator import is_valid_password
def test_rejects_short_password():
assert is_valid_password("abc") is False
def test_accepts_long_password():
assert is_valid_password("abcdefgh") is Trueuv run pytest -x
# ImportError: cannot import name 'is_valid_password' โ ์๋๋ ์คํจGreen โ ํต๊ณผํ๋ ์ต์ ์ฝ๋
ํ ์คํธ๋ฅผ ํต๊ณผ์ํฌ ๋งํผ๋ง ๊ตฌํํ๋ค.
# validator.py
def is_valid_password(password):
return len(password) >= 8uv run pytest -x
# 2 passed โ ์ด๋ก๋ถRefactor โ ์ ๋ฆฌ
ํ ์คํธ๊ฐ ์ง์ผ์ฃผ๋ ์ํ์์ ์์ฌํ๊ณ ์ฝ๋๋ฅผ ๋ค๋ฌ๋๋ค. ๊ทธ๋ฆฌ๊ณ ๋ค์ ์๊ตฌ์ฌํญ์ ๋ค์ Red๋ถํฐ ์์ํ๋ค.
์๋ ์ฌ์คํ โ pytest-watcher
์ ์ฅํ ๋๋ง๋ค ์๋์ผ๋ก pytest๋ฅผ ์น๋ ๊ฑด ๋ฒ๊ฑฐ๋กญ๋ค. pytest-watcher๋ฅผ ๊น๋ฉด ํ์ผ์ ์ ์ฅํ ๋๋ง๋ค ์๋์ผ๋ก ํ
์คํธ๊ฐ ๋์๊ฐ๋ค. ๋ ๋-๊ทธ๋ฆฐ ์ฌ์ดํด์ ํ์ ๋๊ตฌ๋ค.
uv add --dev pytest-watcher
uv run ptw . # ํ์ผ ๋ณ๊ฒฝ ๊ฐ์งํด์ ์๋ ์ฌ์คํ13. ํ๋ฌ๊ทธ์ธ ์ํ๊ณ
pytest๊ฐ ํ์ค์ด ๋ ๊ฒฐ์ ์ ์ด์ ๋ ๋ณธ์ฒด๊ฐ ์๋๋ผ ํ๋ฌ๊ทธ์ธ ์ํ๊ณ์ ์๋ค. pytest๋ ํ ์คํธ ์์งยท์คํยท๋ฆฌํฌํ ๋ชจ๋ ๋จ๊ณ์ hook์ ์ด์ด๋ ํ๋ ์์ํฌ๋ค. ํ๋ฌ๊ทธ์ธ์ ๊ทธ hook์ ๋ผ์ด๋ค์ด ๊ธฐ๋ฅ์ ๋ํ๋ค. ๋ธ๋ผ์ฐ์ ์ ํ์ฅ ํ๋ก๊ทธ๋จ์ ๊ด๊ณ์ ๊ฐ๋ค.
์ค์น๋ง ํ๋ฉด ์๋ ํ์ฑํ๋๋ค(๋ณ๋ import ๋ถํ์). ์ง๊ธ๊น์ง ์ด pytest-mock, pytest-cov, pytest-watcher๊ฐ ์ ๋ถ ํ๋ฌ๊ทธ์ธ์ด๋ค. ์ํฉ๋ณ๋ก ์์ฃผ ์ฐ๋ ๊ฒ๋ค์ ์ ๋ฆฌํ๋ค.
| ํ๋ฌ๊ทธ์ธ | ์ฉ๋ | ํต์ฌ ์ฌ์ฉ๋ฒ |
|---|---|---|
| pytest-cov | ์ปค๋ฒ๋ฆฌ์ง ์ธก์ | --cov=src |
| pytest-mock | mocking ํตํฉ | mocker fixture |
| pytest-xdist | ์ฌ๋ฌ ํ๋ก์ธ์ค๋ก ๋ณ๋ ฌ ์คํ | -n auto |
| pytest-randomly | ์คํ ์์ ๋๋คํ (์จ์ ์์กด์ฑ ํ์ง) | ์ค์น ์ ์๋ |
| pytest-watcher | ํ์ผ ์ ์ฅ ์ ์๋ ์ฌ์คํ | ptw . |
| pytest-asyncio | async def ํ
์คํธ ์ง์ | asyncio_mode="auto" |
| pytest-django | Django ํตํฉ | @pytest.mark.django_db |
| pytest-timeout | ๋ฌดํ ๋ฃจํ ๊ฐ์ ์ข ๋ฃ | --timeout=60 |
| pytest-sugar | ์งํ๋ฅ ๋ฐ, ์ถ๋ ฅ ๋ฏธํ | ์ค์น ์ ์๋ |
ํ
์คํธ๊ฐ ์๋ฐฑ ๊ฐ๋ฅผ ๋์ด๊ฐ๋ฉด pytest-xdist์ ๋ณ๋ ฌ ์คํ์ด ์ฒด๊ฐ๋๋ค.
uv add --dev pytest-xdist
uv run pytest -n auto # CPU ์ฝ์ด ์๋งํผ ๋ถ์ฐ ์คํ๋ณ๋ ฌ ์คํ์ ์ ์
ํ ์คํธ๋ผ๋ฆฌ ์ ์ญ ์ํ(ํ์ผ, DB, ํ๊ฒฝ๋ณ์)๋ฅผ ๊ณต์ ํ๋ฉด ๋ณ๋ ฌ ์คํ์์ ๊นจ์ง๋ค.
pytest-randomly๋ก ์คํ ์์๋ฅผ ์์ด ์จ์ ์์กด์ฑ์ ๋จผ์ ์ก์๋ธ ๋ค ๋ณ๋ ฌํํ๋ ๊ฒ์ด ์์ ํ๋ค.
๋น๋๊ธฐ ํ ์คํธ โ pytest-asyncio
pytest๋ ๊ธฐ๋ณธ์ ์ผ๋ก async def ํ
์คํธ๋ฅผ ์คํํ์ง ๋ชปํ๋ค. pytest-asyncio๊ฐ ์ด๋ฒคํธ ๋ฃจํ ๊ด๋ฆฌ๋ฅผ ๋งก๋๋ค.
[tool.pytest.ini_options]
asyncio_mode = "auto"async def test_fetch_data():
result = await fetch_data()
assert result["status"] == "ok"์์ ํ๋ฌ๊ทธ์ธ์ conftest.py์์ ์์
conftest.py์ ์ ํด์ง ์ด๋ฆ์ hook ํจ์๋ฅผ ์ ์ํ๋ฉด ๊ทธ๊ฒ์ด ๊ณง ํ๋ฌ๊ทธ์ธ์ด๋ค. ๋๋ฆฐ ํ
์คํธ๋ฅผ ์๋ ๋ฆฌํฌํธํ๋ ์์๋ค.
# conftest.py
def pytest_terminal_summary(terminalreporter, config):
slow = [r for r in terminalreporter.stats.get("passed", []) if r.duration > 1.0]
if slow:
terminalreporter.section("slow tests (> 1s)")
for r in slow:
terminalreporter.write_line(f"{r.duration:.2f}s {r.nodeid}")hook ํจ์ ์ด๋ฆ(pytest_terminal_summary ๋ฑ)์ pytest๊ฐ ์ ํด๋ ๊ท์ฝ์ด๋ฉฐ, ์ ์ฒด ๋ชฉ๋ก์ ๊ณต์ ๋ฌธ์์ hook reference์ ์๋ค.
14. unittest์์ ๋น๊ต
unittest๋ Python ํ์ค ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ ๋ด์ฅ๋ ํ ์คํธ ํ๋ ์์ํฌ๋ค. ๋ณ๋ ์ค์น๊ฐ ํ์ ์๋ค๋ ์ ์ด ๊ฑฐ์ ์ ์ผํ ์ฅ์ ์ด๋ค. ๊ฐ์ ํ ์คํธ๋ฅผ ๋ ๋ฐฉ์์ผ๋ก ์ฐ๋ฉด ์ฐจ์ด๊ฐ ๋ถ๋ช ํ๋ค.
# unittest - ํด๋์ค ์์ + ์ ์ฉ ๋ฉ์๋
import unittest
class TestCalculator(unittest.TestCase):
def setUp(self):
self.calc = Calculator()
def test_add(self):
self.assertEqual(self.calc.add(1, 2), 3)# pytest - ํจ์ + assert ๋ฌธ
import pytest
@pytest.fixture
def calc():
return Calculator()
def test_add(calc):
assert calc.add(1, 2) == 3| ํญ๋ชฉ | unittest | pytest |
|---|---|---|
| ์ค์น | ๋ถํ์ (ํ์ค ๋ผ์ด๋ธ๋ฌ๋ฆฌ) | uv add --dev pytest |
| ๋จ์ธ | assertEqual ๋ฑ ๋ฉ์๋ ์๊ธฐ | assert ๋ฌธ ํ๋ |
| ๊ตฌ์กฐ | ํด๋์ค ์์ ํ์ | ํจ์๋ง์ผ๋ก ๊ฐ๋ฅ |
| ์ค๋น/์ ๋ฆฌ | setUp/tearDown | fixture (์ฃผ์ ยท์กฐํฉ ๊ฐ๋ฅ) |
| ํ๋ผ๋ฏธํฐํ | subTest (์ ํ์ ) | parametrize ๋ง์ปค |
| ํ๋ฌ๊ทธ์ธ | ์ฌ์ค์ ์์ | ๋์ ์ํ๊ณ |
์์กด์ฑ ์ถ๊ฐ๊ฐ ๋ถ๊ฐ๋ฅํ ํ๊ฒฝ(์ต์ ์์กด์ฑ ๋ผ์ด๋ธ๋ฌ๋ฆฌ, ์ผ๋ถ ์ฌ๋ด ์ ์ฑ )์ด ์๋๋ผ๋ฉด pytest๋ฅผ ์ ํํ๋ค. pytest๋ unittest๋ก ์์ฑ๋ ๊ธฐ์กด ํ ์คํธ๋ ๊ทธ๋๋ก ๋๋ฆฌ๋ฏ๋ก ์ ์ง์ ์ผ๋ก ์ฎ๊ฒจ๊ฐ๋ฉด ๋๊ณ , ๊ทธ๋์ ์ ํ ๋ถ๋ด์ด ์ ๋ค.
15. hypothesis โ ๊ฒฝ์์๊ฐ ์๋ ๋ณด์์ฌ
hypothesis๋ pytest๋ฅผ ๋์ฒดํ๋ ๋๊ตฌ๊ฐ ์๋๋ผ pytest ์์์ ๋๋ property-based testing ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ค. ๋จผ์ ์์งํ ๋งํ๋ฉด pytest๋งํผ ๋ชจ๋๊ฐ ์ฐ๋ ๋๊ตฌ๋ ์๋๋ค. ๋ผ์ด๋ธ๋ฌ๋ฆฌ(ํ์ยท์ง๋ ฌํยท์๋ฃ๊ตฌ์กฐ), ๊ธ์ตยท๊ณผํ ๊ณ์ฐ์ฒ๋ผ ์ ๋ ฅ์ด ๋ค์ํ๊ณ ๊ท์น์ด ๋ช ํํ ์ฝ๋์์ ์ฃผ๋ก ์ฐ๊ณ , ์ผ๋ฐ์ ์ธ ์น CRUD ๋ก์ง์๋ ๊ตณ์ด ์ ์ฐ๋ ๊ฒฝ์ฐ๊ฐ ๋ง๋ค. ๊ทธ๋๋ ์์๋๋ฉด ์ข์ ์ด์ ๋ฅผ ์์๋ก ๋ณธ๋ค.
uv add --dev hypothesis์ ํ์ํ๊ฐ โ ์์ ํ ์คํธ์ ํ๊ณ
๋ฒ๊ทธ๋ ๋ณดํต ๋ด๊ฐ ์๊ฐ ๋ชป ํ ์
๋ ฅ์ ์จ์ด ์๋ค. ์์ ํ
์คํธ(parametrize ํฌํจ)๋ ๋ด๊ฐ ๋ ์ฌ๋ฆฐ ์ผ์ด์ค๋ง ๋ง๋๋ฐ, hypothesis๋ ์
๋ ฅ์ ์๋์ผ๋ก ๋ง๋ค์ด๋ด๋ฉฐ ๋ด๊ฐ ์ ๋ ์ฌ๋ฆฐ ๊ฑธ ๊ณต๊ฒฉํ๋ค. ์ฌ์ฉ๋ฒ์ โ์ด ์ฑ์ง์ ์ด๋ค ์
๋ ฅ์๋ ์ฑ๋ฆฝํด์ผ ํ๋คโ๋ ๊ท์น์ @given์ผ๋ก ์ ์ธํ๋ ๊ฒ์ด๋ค.
์์ 1 โ ๋ด๊ฐ ๋น ๋จ๋ฆฐ ์ ๋ ฅ์ ์ฐพ์์ค๋ค
ํ๊ท ๊ตฌํ๋ ํจ์๋ค. ๋ฉ์ฉกํด ๋ณด์ด๊ณ ์์ ํ ์คํธ๋ ํต๊ณผํ๋ค.
def average(nums):
return sum(nums) / len(nums)
def test_average():
assert average([1, 2, 3]) == 2 # ํต๊ณผ
assert average([10, 20]) == 15 # ํต๊ณผ์ด์ โํ๊ท ์ ํญ์ ์ต์๊ฐ๊ณผ ์ต๋๊ฐ ์ฌ์ด์ ์์ด์ผ ํ๋คโ๋ ์ฑ์ง์ hypothesis๋ก ๊ฒ์ฆํ๋ค.
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_average_in_range(nums):
avg = average(nums)
assert min(nums) <= avg <= max(nums)์คํํ๋ฉด ๋ฐ๋ก ๋ฐ๋ก๋ฅผ ๋์ง๋ค.
Falsifying example: test_average_in_range(nums=[])
ZeroDivisionError: division by zero๋น ๋ฆฌ์คํธ []. ํ์ ํ
์คํธ์ average([])๋ฅผ ๋ฃ์ ์๊ฐ์ ์ ์ ํ๋ค. hypothesis๋ ์ด๋ฐ ๊ฒฝ๊ณ๊ฐ์ ์์์ ์๋ํด โ๋น ์
๋ ฅ ์ฒ๋ฆฌ๋ฅผ ์ ํ๋คโ๋ฅผ ๋๋ฌ๋ธ๋ค.
์์ 2 โ ๋ด๊ฐ ๋ชฐ๋๋ ๋ฒ๊ทธ๋ฅผ ์ฐพ์์ค๋ค
์ง์ง ๊ฐ๋ ฅํ ๊ฑด ์๋ณต(round-trip) ์ฑ์ง ๊ฒ์ฆ์ด๋ค. โ์ ์ฅํ๋ค๊ฐ ๋ค์ ์ฝ์ผ๋ฉด ์๋ณธ๊ณผ ๊ฐ์์ผ ํ๋คโ ๊ฐ์ ๊ท์น์ด๋ค.
import json
def save(data):
return json.dumps(data)
def load(s):
return json.loads(s)
def test_roundtrip():
d = {"name": "argus", "age": 30}
assert load(save(d)) == d # ํต๊ณผโ์ด๋ค dict๋ save ํ loadํ๋ฉด ์๋ณธ๊ณผ ๊ฐ์์ผ ํ๋คโ๋ฅผ hypothesis๋ก ๊ฒ์ฆํ๋ค.
@given(st.dictionaries(st.integers(), st.text()))
def test_json_roundtrip(d):
assert load(save(d)) == dFalsifying example: test_json_roundtrip(d={0: ''})
assert {'0': ''} == {0: ''}{0: ''}์์ ๊นจ์ง๋ค. JSON์ dict์ ํค๋ฅผ ๋ฌด์กฐ๊ฑด ๋ฌธ์์ด๋ก ๋ฐ๊พธ๊ธฐ ๋๋ฌธ์ด๋ค. {0: ''}์ ์ ์ฅํ๋ฉด '{"0": ""}'๊ฐ ๋๊ณ , ๋ค์ ์ฝ์ผ๋ฉด ํค๊ฐ ์ ์ 0์ด ์๋๋ผ ๋ฌธ์์ด "0"์ด ๋๋ค. ๋ชจ๋ฅด๋ฉด ์์ ํ
์คํธ๋ก๋ ์ ๋ ๋ชป ์ก๋ ํจ์ ์ธ๋ฐ, ์ ์ ํค dict๋ฅผ ์ผ๋ถ๋ฌ ํ
์คํธ์ ๋ฃ์ ์ด์ ๊ฐ ์๊ธฐ ๋๋ฌธ์ด๋ค. hypothesis๋ ๊ทธ๊ฑธ ์์์ ์ฐพ์๋ธ๋ค.
shrinking โ ๋ฐ๋ก๋ฅผ ์ต์ ํํ๋ก ์ค์ฌ์ค๋ค
์์์ hypothesis๊ฐ ๋ณด์ฌ์ค ๊ฒ {12345: "qwerty"} ๊ฐ์ ๋ณต์กํ ์
๋ ฅ์ด ์๋๋ผ {0: ''}์๋ ์ ์ ์ฃผ๋ชฉํ์. hypothesis๋ ์คํจ๋ฅผ ์ฐพ์ผ๋ฉด ์์ธ์ ๊ฐ์ฅ ๋จ์ํ ํํ๋ก ๊น์์(shrinking) ๋ณด์ฌ์ค๋ค. โ์ ์ ํค ํ๋๋ฉด ๊นจ์ง๋๊ตฌ๋โ๊ฐ ๋ฐ๋ก ๋ณด์ด๋ ๋๋ฒ๊น
์ด ์ฝ๋ค.
์ธ์ ์ธ ๊ฐ์น๊ฐ ์๋
| ์ ๋ง๋ ๊ฒฝ์ฐ | ๊ตณ์ด ์ ์จ๋ ๋๋ ๊ฒฝ์ฐ |
|---|---|
| ์๋ณต ์ฑ์ง (์ ์ฅ/๋ณต์, ์ธ์ฝ๋ฉ/๋์ฝ๋ฉ, ํ์ฑ/ํฌ๋งท) | ๋จ์ CRUD, โDB์ ์ ๋ค์ด๊ฐ๋โ |
| ์ ๋ ฅ ๊ณต๊ฐ์ด ๋์ ์์นยท๋ฌธ์์ด ์ฒ๋ฆฌ | ์ ๋ ฅ์ด ๋ปํ ์งง์ ๋ก์ง |
| โ์ด๋ค ์ ๋ ฅ์๋ ์ฑ๋ฆฝํด์ผ ํ๋ ๊ท์นโ์ด ๋ช ํํ ๋ | ์ธ๋ถ ์์กด์ฑ์ด ๋ง์ mock ์์ฃผ์ธ ์ฝ๋ |
ํ์ ํ ์คํธ๋ pytest๋ก ์ฐ๊ณ , โ์์๋ฅผ ๋์ดํ๊ธฐ์ ์ ๋ ฅ์ด ๋๋ฌด ๋ง๊ณ ์ง์ผ์ผ ํ ๊ท์น์ ๋ช ํํโ ํต์ฌ ๋ก์ง์๋ง hypothesis๋ฅผ ์น์ผ๋ฉด ๊ฐ์ฑ๋น๊ฐ ์ข๋ค.
16. ์ ๋ฆฌ
- pytest๋
assertํ ์ค๋ก ๊ฒ์ฆํ๊ณ ํ ์คํธ๋ฅผ ์๋์ผ๋ก ์ฐพ์ ์คํํ๋ ํ๋ ์์ํฌ๋ค. ํด๋์ค๋ ์์๋ ํ์ ์๋ค. - ๋ฐ๋ณต์ ์ค์ด๋ 3๋ ๊ธฐ๋ฅ: fixture(์ค๋น ์์ ์ฌ์ฌ์ฉ), parametrize(์ ๋ ฅ๋ง ๋ฐ๊ฟ ๋ฐ๋ณต), ๋ง์ปค(๋ถ๋ฅยท์ ํ ์คํ).
- mocking์
pytest-mock, ์ปค๋ฒ๋ฆฌ์ง๋pytest-cov์ฒ๋ผ ๊ธฐ๋ฅ์ ํ๋ฌ๊ทธ์ธ์ผ๋ก ํ์ฅํ๋ค. ๋น๋๊ธฐยทDjangoยท๋ณ๋ ฌ ์คํ๋ ์ ์ฉ ํ๋ฌ๊ทธ์ธ์ด ํ์ค ๊ฒฝ๋ก๋ค. - ์ค์ ์
pyproject.toml์[tool.pytest.ini_options]์ ๋ชจ์ ํ ์ ์ฒด๊ฐ ๊ฐ์ ์กฐ๊ฑด์ผ๋ก ์คํํ๊ฒ ๋ง๋ ๋ค. - unittest๋ ์ ์ฝ ํ๊ฒฝ์ฉ, hypothesis๋ pytest ์์ ์น๋ ๋ณด์์ฌ๋ก ์ ๋ฆฌํ๋ฉด ๋๊ตฌ ์ ํ ๊ณ ๋ฏผ์ด ๋๋๋ค.
์ถ์ฒ ์์ ์กฐํฉ
uv add --dev pytest pytest-cov pytest-mock pytest-xdist pytest-randomly pytest-watcher[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra -q --strict-markers"
markers = [
"slow: ์คํ์ด ์ค๋ ๊ฑธ๋ฆฌ๋ ํ
์คํธ",
]๋๋ฒ๊น ํ ๋๋ ๋ณ๋ ฌ๊ณผ ์ปค๋ฒ๋ฆฌ์ง๋ฅผ ๋๋ค
-n auto(๋ณ๋ ฌ)์--cov(์ปค๋ฒ๋ฆฌ์ง)๋pdb๋๋ฒ๊น ,uv run pytest -p no:xdist -p no:cov -x -s tests/test_target.py์ฒ๋ผ ๊บผ์ ์คํํ๋ค.