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 pytest
test_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: AssertionError

assert 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) == expected
test_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) == expected

7. ์˜ˆ์™ธ ํ…Œ์ŠคํŠธ โ€” 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 == 200

conftest.py๋Š” fixture ๊ณต์œ ๋ฟ ์•„๋‹ˆ๋ผ ํ”„๋กœ์ ํŠธ ์ „์—ญ ์„ค์ •, ์ปค์Šคํ…€ hook์„ ๋‘๋Š” ์ž๋ฆฌ์ด๊ธฐ๋„ ํ•˜๋‹ค. ์‚ฌ์‹ค์ƒ โ€œํ”„๋กœ์ ํŠธ ๋กœ์ปฌ ํ”Œ๋Ÿฌ๊ทธ์ธโ€ ์—ญํ• ์„ ํ•œ๋‹ค.


11. ์ปค๋ฒ„๋ฆฌ์ง€ โ€” pytest-cov

ํ…Œ์ŠคํŠธ๊ฐ€ ์ฝ”๋“œ์˜ ์–ด๋А ๋ถ€๋ถ„์„ ์‹ค์ œ๋กœ ์‹คํ–‰ํ–ˆ๋Š”์ง€ ์ธก์ •ํ•˜๋ ค๋ฉด pytest-cov๋ฅผ ์“ด๋‹ค.

uv add --dev pytest-cov
uv 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 True
uv run pytest -x
# ImportError: cannot import name 'is_valid_password'  โ† ์˜๋„๋œ ์‹คํŒจ

Green โ€” ํ†ต๊ณผํ•˜๋Š” ์ตœ์†Œ ์ฝ”๋“œ

ํ…Œ์ŠคํŠธ๋ฅผ ํ†ต๊ณผ์‹œํ‚ฌ ๋งŒํผ๋งŒ ๊ตฌํ˜„ํ•œ๋‹ค.

# validator.py
def is_valid_password(password):
    return len(password) >= 8
uv 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-mockmocking ํ†ตํ•ฉmocker fixture
pytest-xdist์—ฌ๋Ÿฌ ํ”„๋กœ์„ธ์Šค๋กœ ๋ณ‘๋ ฌ ์‹คํ–‰-n auto
pytest-randomly์‹คํ–‰ ์ˆœ์„œ ๋žœ๋คํ™” (์ˆจ์€ ์˜์กด์„ฑ ํƒ์ง€)์„ค์น˜ ์‹œ ์ž๋™
pytest-watcherํŒŒ์ผ ์ €์žฅ ์‹œ ์ž๋™ ์žฌ์‹คํ–‰ptw .
pytest-asyncioasync def ํ…Œ์ŠคํŠธ ์ง€์›asyncio_mode="auto"
pytest-djangoDjango ํ†ตํ•ฉ@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
ํ•ญ๋ชฉunittestpytest
์„ค์น˜๋ถˆํ•„์š” (ํ‘œ์ค€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ)uv add --dev pytest
๋‹จ์–ธassertEqual ๋“ฑ ๋ฉ”์„œ๋“œ ์•”๊ธฐassert ๋ฌธ ํ•˜๋‚˜
๊ตฌ์กฐํด๋ž˜์Šค ์ƒ์† ํ•„์ˆ˜ํ•จ์ˆ˜๋งŒ์œผ๋กœ ๊ฐ€๋Šฅ
์ค€๋น„/์ •๋ฆฌsetUp/tearDownfixture (์ฃผ์ž…ยท์กฐํ•ฉ ๊ฐ€๋Šฅ)
ํŒŒ๋ผ๋ฏธํ„ฐํ™”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)) == d
Falsifying 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 ๋””๋ฒ„๊น…, print ์ถœ๋ ฅ๊ณผ ์ถฉ๋Œํ•  ์ˆ˜ ์žˆ๋‹ค. ๋””๋ฒ„๊น… ์‹œ์—๋Š” uv run pytest -p no:xdist -p no:cov -x -s tests/test_target.py์ฒ˜๋Ÿผ ๊บผ์„œ ์‹คํ–‰ํ•œ๋‹ค.