pytest in python

๐Ÿ—‚๏ธ๋ชฉ์ฐจ


๐ŸŽฏAbstract

์ด๋ฒˆ ํฌ์ŠคํŒ…์—์„œ๋Š” pytest ๊ตฌ์„ฑ ๋ฐ ํ™œ์šฉ ๋ฐฉ๋ฒ•์„ ์†Œ๊ฐœํ•ฉ๋‹ˆ๋‹ค.

ํŠนํžˆ, ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์™€ ์—ฐ๊ด€๋œ ํ…Œ์ŠคํŠธ ์ž๋™ํ™”๊ฐ€ ์–ด๋–ค ์˜ํ–ฅ์„ ๋ฏธ์น˜๋Š”์ง€, ์ด๋ฅผ ์–ด๋–ป๊ฒŒ ์„ค๊ณ„ํ•˜๊ณ  ์‹คํ–‰ํ•˜๋Š”์ง€์— ๋Œ€ํ•ด ๋‹ค๋ฃน๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ ์‹คํ–‰ ๋ฐฉ๋ฒ•๊ณผ ์ปค์Šคํ„ฐ๋งˆ์ด์ง• ๋ฐฉ์‹์— ๋Œ€ํ•ด์„œ๋„ ์„ค๋ช…ํ•˜๋ฉฐ, ์ด๋ฏธ ๊ตฌํ˜„๋œ ์ปค๋ฒ„๋ฆฌ์ง€ ํ…Œ์ŠคํŠธ์˜ ์‹คํ–‰ ๋ฐฉ๋ฒ•๊ณผ ๊ด€๋ จ๋œ ์•„์ด๋””์–ด๋ฅผ ๊ณต์œ ํ•ฉ๋‹ˆ๋‹ค.

๋˜ํ•œ, Github Actions๋ฅผ ํ™œ์šฉํ•œ CI ํ…Œ์ŠคํŠธ ์ž๋™ํ™” ์Šคํฌ๋ฆฝํŠธ ๊ตฌํ˜„ ๊ณผ์ •์„ ์†Œ๊ฐœํ•ฉ๋‹ˆ๋‹ค. PR ์ƒ์„ฑ ์‹œ ํ•„์ˆ˜ ๋จธ์ง€ ์กฐ๊ฑด ์„ค์ • ๋ฐฉ๋ฒ•๊ณผ ์Šคํฌ๋ฆฝํŠธ ์ˆ˜์ • ๊ฐ€๋Šฅ์„ฑ์— ๋Œ€ํ•ด์„œ๋„ ์„ค๋ช…ํ•ฉ๋‹ˆ๋‹ค.

โš™๏ธPyTest

pytest๋ฅผ ์„ ํƒํ•œ ์ด์œ 

Python์—์„œ ํ…Œ์ŠคํŠธ๋ฅผ ์ž‘์„ฑํ•  ๋•Œ ์ฃผ๋กœ ์‚ฌ์šฉํ•˜๋Š” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋กœ๋Š” pytest์™€ unittest๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.

pytest๋Š” 3rd-party ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋กœ, ๋‹ค์–‘ํ•œ ๊ธฐ๋Šฅ๊ณผ ์ตœ์‹  IDE์™€์˜ ๋›ฐ์–ด๋‚œ ํ˜ธํ™˜์„ฑ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. unittest๋Š” Python ํ‘œ์ค€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์˜ ์ผ๋ถ€๋กœ, JUnit(Java unit-test) ์Šคํƒ€์ผ์˜ ๊ตฌ์กฐ๋ฅผ ๋”ฐ๋ฆ…๋‹ˆ๋‹ค. ์ง๊ด€์ ์ด๊ณ  ๊ฐ„๋‹จํ•œ ์‚ฌ์šฉ๋ฒ•์ด ์žฅ์ ์ž…๋‹ˆ๋‹ค. ๋‘ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์œ ์‚ฌํ•œ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•˜์ง€๋งŒ, pytest๋Š” hook์ด๋‚˜ scope fixture ๋“ฑ ๋” ๋ณต์žกํ•˜๊ณ  ์œ ์—ฐํ•œ ๊ธฐ๋Šฅ์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.

์ œ๊ฐ€ pytest๋ฅผ ์„ ํƒํ•œ ์ด์œ ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค:

๋งŽ์€ Python ์˜คํ”ˆ์†Œ์Šค ์ปค๋ฎค๋‹ˆํ‹ฐ๊ฐ€ pytest๋ฅผ ํ‘œ์ค€์œผ๋กœ ์ฑ„ํƒํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ๋ฏธ๋ž˜์˜ ํ™•์žฅ์„ฑ๊ณผ ์œ ์ง€๋ณด์ˆ˜๋ฅผ ๊ณ ๋ คํ•œ future-proof ์„ ํƒ์ด๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

framework or vanilla approach

ํ…Œ์ŠคํŠธ๋ฅผ ์„ค๊ณ„ํ•  ๋•Œ, ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๋งž์ถคํ˜• ํ…Œ์ŠคํŠธ framework๋ฅผ ๊ตฌ์ถ•ํ•˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ์ข…์ข… ์žˆ์Šต๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, Win32 API๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ MFC๋ฅผ ๊ตฌ์ถ•ํ•˜๋Š” ๊ฒƒ์ฒ˜๋Ÿผ, pytest๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์„œ๋น„์Šค์— ์ ํ•ฉํ•œ ๋งž์ถคํ˜• framework๋ฅผ ๋งŒ๋“ค์–ด ์žฌํ™œ์šฉ์„ฑ๊ณผ ์ถ”์ƒํ™”๋ฅผ ํ†ตํ•ด ์ƒ์‚ฐ์„ฑ์„ ๋†’์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํ•˜์ง€๋งŒ ์ด๋Ÿฌํ•œ framework ์ ‘๊ทผ๋ฒ•์—๋Š” ๋ช‡ ๊ฐ€์ง€ ํ•œ๊ณ„๊ฐ€ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค:

Framework ์ž์ฒด ํ…Œ์ŠคํŠธ ํ•„์š”์„ฑ Framework๋ฅผ ๋งŒ๋“ค๋‹ค ๋ณด๋ฉด ๊ทธ ์ž์ฒด๋ฅผ ํ…Œ์ŠคํŠธํ•ด์•ผ ํ•˜๋Š” ์ƒํ™ฉ์ด ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค. ์ด ๊ณผ์ •์—์„œ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์— ๋ฒ„๊ทธ๊ฐ€ ์ˆจ์–ด ์žˆ์–ด๋„ ์ด๋ฅผ ๋ฐœ๊ฒฌํ•˜๊ธฐ ์–ด๋ ค์šธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋ฌธ์„œํ™”์˜ ๋ถ€์กฑ ํŠนํžˆ ์ฒ˜์Œ framework๋ฅผ ์ ‘ํ•˜๋Š” ๊ฐœ๋ฐœ์ž๋“ค์—๊ฒŒ, ๋ฌธ์„œํ™”๊ฐ€ ๋ถ€์กฑํ•œ framework๋Š” ์ ์šฉ ๋ฐ ํ•™์Šต์˜ ํฐ ์žฅ๋ฒฝ์ด ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋Ÿฌํ•œ ๋ฌธ์ œ๋Š” ์ข…์ข… ๋งž์ถคํ˜• ํ…Œ์ŠคํŠธ framework์—์„œ๋„ ๋ฐœ๊ฒฌ๋ฉ๋‹ˆ๋‹ค.

๊ทธ๋ž˜์„œ ์„ ํƒํ•œ Vanilla Approach ์ด๋Ÿฐ ์ด์œ ๋กœ, ๊ฐ€๋Šฅํ•œ ํ•œ pytest์˜ ๊ธฐ๋ณธ ๊ธฐ๋Šฅ์„ ์ตœ๋Œ€ํ•œ ํ™œ์šฉํ•˜๊ณ , ๋ฐ˜๋ณต์ ์œผ๋กœ ์‚ฌ์šฉ๋˜๋Š” boilerplate ์ฝ”๋“œ๋Š” helper๋กœ ๋ถ„๋ฆฌํ•˜๋Š” vanilla approach๋ฅผ ์ฑ„ํƒํ–ˆ์Šต๋‹ˆ๋‹ค.

์ด ์ ‘๊ทผ๋ฒ•์€ ๊ฐ„๋‹จํ•˜๊ณ  ์ง๊ด€์ ์ด๋ฉฐ, ์ถ”๊ฐ€์ ์ธ ํ•™์Šต ์—†์ด๋„ pytest์˜ ๊ธฐ๋ณธ ๋ฌธ์„œ๋งŒ์œผ๋กœ ํ…Œ์ŠคํŠธ๋ฅผ ์ดํ•ดํ•˜๊ณ  ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ๋Š” ์žฅ์ ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ๋ฏธ๋ž˜์˜ ํ™•์žฅ์„ฑ ํ…Œ์ŠคํŠธ๊ฐ€ ์ ์  ๋ณต์žกํ•ด์ง€๊ณ  ์–‘์ด ์ฆ๊ฐ€ํ•˜๋ฉด์„œ ๋ฐœ์ƒํ•˜๋Š” ๋ฌธ์ œ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๋ฐฉ๋ฒ•์„ ํ†ตํ•ด ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

MSA(Microservices Architecture)๋กœ ๋„๋ฉ”์ธ ๋ถ„๋ฆฌ ํ…Œ์ŠคํŠธ ๋ฒ”์œ„๋ฅผ ์ค„์ด๊ณ  ๋…๋ฆฝ์„ฑ์„ ๊ฐ•ํ™”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ธฐํƒ€ ์ƒˆ๋กœ์šด ๋ฐฉ๋ฒ•๋ก  ๋„์ž… ํ”„๋กœ์ ํŠธ์˜ ์„ฑ์žฅ๊ณผ ํ•จ๊ป˜ ๋ณด๋‹ค ์ ํ•ฉํ•œ ๋„๊ตฌ๋‚˜ ์„ค๊ณ„๋ฅผ ๊ณ ๋ คํ•  ํ•„์š”๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.

fixture and scope

pytest fixture๋Š” ํ…Œ์ŠคํŠธ ๊ฐ„ ๊ณต์œ ๋˜๋Š” setup์ด๋ผ๊ณ  ์ƒ๊ฐํ•˜์‹œ๋ฉด ๋ฉ๋‹ˆ๋‹ค. constructor์™€ destructor๊ฐ€ ์žˆ์–ด, ํ•„์š” ์‹œ ์‹คํ–‰ ์‹œ ์ƒ์„ฑ๋˜๋Š” ์ฝ”๋“œ, ์ข…๋ฃŒ ์‹œ ์ œ๊ฑฐ๋˜๋Š” ์ฝ”๋“œ๋ฅผ ์ •์˜ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

@pytest.fixture
def Senior():
    return Student(type=STUDENT.SENIOR)
@pytest.fixture
def foo():
    # constructor
    util_obj = Uitls()

    # yield return
    yield util_obj

    # destructor
    util_obj.delete()

fixture์—๋Š” scope์ด๋ž€ ์šฉ์–ด๋กœ ํ…Œ์ŠคํŠธ๊ฐ„ ๊ณต์œ ๋˜๋Š” ๋ฒ”์œ„๋ฅผ ์ง€์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

scope์— ๋”ฐ๋ผ ๋ฒ”์œ„๊ธฐ์ค€์œผ๋กœ fixture๊ฐ€ ์žฌํ™œ์šฉ๋ ์ง€, ๋‹ค์‹œ ์ƒ์„ฑํ•  ์ง€ ๊ฒฐ์ •๋ฉ๋‹ˆ๋‹ค.

scope:

@pytest.fixture(scope='session')
def sess():
    # global initialization here
    ...
import pytest

@pytest.fixture(scope='class')
def setup():
    # setup class here
    yeild
    # tear down class here

# fixture๋ฅผ parameter์‚ฌ์šฉํ•˜์ง€ ์•Š๊ณ  ์ ์šฉํ•˜๋Š” ๋ฐฉ๋ฒ•
@pytest.mark.usefixtures('setup')
class TestClass1:
    # ๋ชจ๋“  test method๊ฐ€ ์‹คํ–‰ ์ „์— setup ์‹คํ–‰
    # ๋ชจ๋“  test method ์ข…๋ฃŒ ํ›„ tear down ๋ถ€๋ถ„ ์‹คํ–‰

    def test_case1(self):
        pass

    def test_case2(self):
        pass

reference:

ํ…Œ์ŠคํŠธ๊ฐ€ fixture๋ฅผ ์‚ฌ์šฉํ•˜๋Š”๋ฒ•์€ ์•„๋ž˜์™€ ๊ฐ™์Šต๋‹ˆ๋‹ค.

@pytest.fixture
def foo():
    return 'Hello'

def test_baa(foo):
    print(foo)
    # 'Hello' ์ถœ๋ ฅ

fixture๋Š” ๊ผญ ๊ฐ™์€ module์„œ ์ •์˜๋  ํ•„์š” ์—†์Šต๋‹ˆ๋‹ค.

๋‹ค๋ฅธ ๋ชจ๋“ˆ์—์„œ ์ •์˜ ํ›„ import ํ›„ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

# ์œ„์น˜: foo.py
@pytest.fixture
def foo():
    return 'Hello'
# ์œ„์น˜: bar.py
from foo impor foo

def test_bar(foo):
    print(foo)
    # 'Hello' ์ถœ๋ ฅ

pytest๋Š” hook์ด๋‚˜ ๊ณต์šฉ fixture๋ฅผ ๋ช…์‹œ ํ•  ์ˆ˜ ์žˆ๋Š” ํŠน๋ณ„ ๋ชจ๋“ˆ์ด ์žˆ์Šต๋‹ˆ๋‹ค.

conftest.py์ด๋ฆ„์˜ ํŒŒ์ผ ์•„๋ž˜ ๋ชจ๋“  ๋ชจ๋“ˆ์€ import์—†์ด ํ•ด๋‹น ํŒŒ์ผ ๋‚ด ๋ชจ๋“  fixture ์ ‘๊ทผ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ fixture discovery๋ผ๊ณ  ํ•ฉ๋‹ˆ๋‹ค.

# ์œ„์น˜: tests/conftest.py
import pytest

@pytest.fixture
def foo():
    return 'foo'
# ์œ„์น˜: tests/bar.py
# import ๋ถˆํ•„์š”

def test_bar(foo):
    print(foo)
    # 'foo' ์ถœ๋ ฅ

conftest.pyํŒŒ์ผ์„ root directory์— ์ง€์ • ์‹œ ํ•ด๋‹น ํŒŒ์ผ ๋‚ด ๋ชจ๋“  hook์ด๋‚˜ fixture๋Š” ๋ชจ๋“  ํ…Œ์ŠคํŠธ์— ์ ์šฉ๋ฉ๋‹ˆ๋‹ค. hook function์— ๊ด€ํ•œ ๋‚ด์šฉ์€ ์•„๋ž˜ ์„ค๋ช… ์ฐธ๊ณ  ๋ฐ”๋ž๋‹ˆ๋‹ค.

autouse:

autouse๋Š” fixture๋ฅผ referenceํ•  ์ˆ˜ ์žˆ๋Š” ๋ชจ๋“  test์— ๋ณ„๋„ ๊ฐ๊ฐ ๋ช…์‹œํ•  ํ•„์š” ์—†์ด ์ž๋™์œผ๋กœ ์ ์šฉ๋ฉ๋‹ˆ๋‹ค.

conftest.py์— ๋ช…์‹œ๋œ fixture์— autouse=Trueํ•  ์‹œ ํ•ด๋‹น ๋””๋ ‰ํ† ๋ฆฌ ๋‚ด ๋ชจ๋“  ํ…Œ์ŠคํŠธ์—๋Š” ๊ทธ fixture๊ฐ€ ์ž๋™ ์ ์šฉ๋ฉ๋‹ˆ๋‹ค.

# ์œ„์น˜: tests/conftest.py

import pytest

@pytest.fixture(autouse=True)
def foo():
    print('foo')
# ์œ„์น˜: tests/app/test_app.py

def test_bar():
    # ์—ฌ๊ธฐ์„œ 'foo' ์ถœ๋ ฅ
    print('bar')
    # 'bar' ์ถœ๋ ฅ

scope์ด session์ด๊ณ  autouse=True ์ผ์‹œ ๋ชจ๋“  ์ ‘๊ทผ๊ฐ€๋Šฅํ•œ ํ…Œ์ŠคํŠธ์— ํ•ด๋‹น fixture๊ฐ€ ์ž๋™ ์ ์šฉ๋˜๋ฉด์„œ ๊ณต์œ ๋˜์–ด singleton ๊ฐ™์€ ํšจ๊ณผ๋ฅผ ์–ป์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋‹จ database๊ด€๋ จ singletonํ™”๋Š” transaction์— ์˜ํ•ด ์˜ํ–ฅ์„ ๋ฐ›์•„ ์ด์Šˆ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค(test์™€ transaction๊ด€๋ จ์€ ์•„๋ž˜ ์ฐธ๊ณ ).

mark

pytest.mark๊ธฐ๋Šฅ์€ ํ…Œ์ŠคํŠธ์— ์ปค์Šคํ…€ ํŠน์ง•์„ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

pytest.mark.usefixtures:

parameter์‚ฌ์šฉํ•˜์ง€ ์•Š๊ณ  fixture๋ฅผ ํ…Œ์ŠคํŠธ์— ์ ์šฉ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

@pytest.mark.usefixtures('foo')
def test_bar():
    # foo fixture starts here
    ...

pytest.mark.skip:

ํ…Œ์ŠคํŠธ์ฝ”๋“œ๋ฅผ ์œ ์ง€ํ•œ์ฒด ์‹คํ–‰ ์‹œ ํ…Œ์ŠคํŠธ๋ฅผ ์ƒ๋žตํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค

@pytest.mark.skip(reason='ํ˜ธํ™˜์„ฑ ์ด์Šˆ')
def test_foo():
    # skipped during test run
    ...

pytest.mark.parametrize:

๋™์ผ ํ…Œ์ŠคํŠธ๋ฅผ ๋‹ค๋ฅธ ํŒŒ๋ผ๋ฏธํ„ฐ ์กฐ๊ฑด์œผ๋กœ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค ์ƒ์„ฑ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค

@pytest.mark.parametrize(
        ('cond', 'expected'),
        [(True, True), (False, False)])
def test_always_true(cond, expected):
    # True == True ์™€ False == False ๋‘ ํ…Œ์ŠคํŠธ๊ฐ€ ์‹คํ–‰ ๋จ
    assert cond == expected

pytest.mark.django_db:

django ORM์„ ์ ‘๊ทผํ•˜๊ธฐ ์œ„ํ•ด ํ•„์š”ํ•œ mark์ž…๋‹ˆ๋‹ค. pytest-django pakcage์˜ ์ปค์Šคํ…€ mark์ž…๋‹ˆ๋‹ค.

pytest์™€ database๊ด€๋ จํ•ด์„œ๋Š” ์•„๋ž˜ ์ฐธ๊ณ  ๋ฐ”๋žŒ

@pytest.mark.django_db
def test_user():
    user = MedicalStaffFactory()
    user.delete()

assert

ํ…Œ์ŠคํŠธ ํ•จ์ˆ˜ ๋‚ด ๋™์ž‘๊ฒฐ๊ณผ๊ฐ€ ์›ํ•˜๊ฒŒ ๋™์ž‘ํ–ˆ๋Š”์ง€ ์‹คํŒจ์ฒ˜๋ฆฌํ•ด์•ผํ•  ์ง€๋ฅผ ๋ช…์‹œํ•˜๋Š” ๋ฐฉ๋ฒ•์ž…๋‹ˆ๋‹ค.

๊ธฐ๋ณธ์‚ฌ์šฉ๋ฒ•

def test_foo():
    assert True is True    # ์„ฑ๊ณต
    assert True is False   # ์‹คํŒจ, test run์—์„œ ์‹คํŒจ ๋ฐ error message
    assert True is False, 'reason here'

exception

import pytest

def test_exception():
    d = {}
    with pytest.raises(KeyError):  # ์„ฑ๊ณต
        a = d['a']

mocker

mocker๋Š” ํ…Œ์ŠคํŠธ ์‹œ ์ผ๋ถ€ ๋ชจ๋“ˆ์ด๋‚˜ 3rd party๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์˜ ํ–‰์œ„๋ฅผ ์กฐ์ข…ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ฉ๋‹ˆ๋‹ค.

๋ณต์žกํ•œ ๋‚ด๋ถ€ ๋กœ์ง์„ ์ƒ๋žตํ•˜๊ฑฐ๋‚˜, ํ˜ธ์ถœ๋˜๋ฉด ์•ˆ๋˜๋Š”(์˜ˆ์‹œ: twilo API ํ˜ธ์ถœ) 3rd party ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์‹ค์ œ๋กœ ํ˜ธ์ถœํ•˜์ง€ ์•Š๊ณ  ํ˜ธ์ถœ ๋œ๊ฑธ๋กœ ๊ฐ„์ฃผํ•˜๊ฒŒ ํ•˜์—ฌ ๊ธฐ์กด ์ฝ”๋“œ ์ˆ˜์ •์—†์ด ํ•ด๋‹น ๋กœ์ง์„ ํ…Œ์ŠคํŠธ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

mocker๋Š” ๋ฐ˜ํ™˜๊ฐ’์„ ๋ณ€ํ™˜์‹œํ‚ค๊ฑฐ๋‚˜, ์ž…๋ ฅ๊ฐ’์ด ๋ฌด์—‡์ธ์ง€ ์•Œ ์ˆ˜ ์žˆ๊ณ , ํ˜ธ์ถœ์—ฌ๋ถ€, ํ˜ธ์ถœ๊ฐœ์ˆ˜ ๋„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

pytest์˜ ๊ธฐ๋ณธ mock๊ธฐ๋Šฅ ๋„ ์žˆ์ง€๋งŒ ์—ฌ๊ธฐ์„œ๋Š” pytest-mocker ํŒจํ‚ค์ง€์˜ mocker๋ผ๋Š” fixture๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

return ๊ฐ’ ๋ณ€๊ฒฝ:

์•„๋ž˜์ฒ˜๋Ÿผ ์ง€์ • ๋ชจ๋“ˆ์˜ ํด๋ผ์Šค, ํ•จ์ˆ˜๊นŒ์ง€ ๋ฐ˜ํ™˜๊ฐ’์„ ๋ณ€ํ™˜ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

# ์œ„์น˜: myapp/tests/test_serializers.py
def test_check_token_fail(self, user, mocker):
    uid_b64 = base64.urlsafe_b64encode(str(user.id).encode()).decode('utf-8')
    token = TokenGenerator().make_token(user)
    serializer = PasswordResetSerializer(data={
        'password': 'newpassword',
        'token': token,
        'uid_b64': uid_b64,
    })

    mocker.patch('myapp.serializers.TokenGenerator.check_token', return_value=False)
    with pytest.raises(AuthenticationError) as e:
        serializer.is_valid(raise_exception=True)
    assert e.value.args[0] == 'The reset link is invalid'
    assert e.value.args[1] == 401

# ์œ„์น˜: myapp/serializers.py
from some_library.tokens import TokenGenerator  # ์ด๊ฑฐ๋ฅผ mock
...
class PasswordResetSerializer(serializers.Serializer):

    def validate(self, attr):
        ...
        if not TokenGenerator().check_token(user, token):
            raise AuthenticationError('The reset link is invalid', 401)
        ...

ํ•จ์ˆ˜ ํ˜ธ์ถœ์ •๋ณด ํ™•์ธ:

์•„๋ž˜ ์ฒ˜๋Ÿผ ์ง€์ • ํ•จ์ˆ˜์˜ ํ˜ธ์ถœ์—ฌ๋ถ€์™€ ์–ด๋–ค ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ์ž…๋ ฅ ๋๋Š”์ง€๋„ ํ™•์ธ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

# ์œ„์น˜: myapp/tests/test_views.py
@pytest.mark.django_db()
@pytest.mark.usefixtures('auth_token')
class TestResetEmailWithTokenView:

    def test_reset_email_with_token(self, user, api_client, mocker):

        # ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์„ค์ •
        url = reverse('reset-email-with-token')
        acc_token, _ = generate_new_tokens('web-client', user.id)
        headers = {'Authorization': f'Bearer {acc_token}'}

        mock_serializer = mocker.patch('myapp.views.ResetEmailWithTokenView.serializer_class')
        mock_serializer.is_valid.return_value = True
        mock_serializer.data.return_value = {}

        # ์š”์ฒญ ์ „์†ก
        resp = api_client.post(url, {}, **headers)
        assert resp.status_code == status.HTTP_200_OK

        # is_valid ํŒŒ๋ผ๋ฏธํ„ฐ ํ™•์ธ
        assert mock_serializer.call_count == 1
        assert mock_serializer.call_args[1]['context']['request'].user == user

# ์œ„์น˜: myapp/views.py
class ResetEmailWithTokenView(generics.GenericAPIView):
    serializer_class = ResetEmailWithTokenSerializer
    renderer_classes = (CustomResultRenderer, )

    def post(self, request):
        serializer = self.serializer_class(data=request.data, context={"request": request})
        serializer.is_valid(raise_exception=True)
        return Response(serializer.data, status=status.HTTP_200_OK)

unittest์™€์˜ ํ˜ธํ™˜์„ฑ

pytest๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ python unittest, nose ํ…Œ์ŠคํŠธ๊ฐ€ 100% ํ˜ธํ™˜๋ฉ๋‹ˆ๋‹ค. ํ…Œ์ŠคํŠธ ์ž‘์„ฑ์„ unittest ์‚ฌ์šฉํ•˜๊ณ  ์‹ถ์œผ์‹œ๋ฉด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ discovery

pytest๋Š” ์„ค์ •์œผ๋กœ ํ…Œ์ŠคํŠธ ํ•จ์ˆ˜๋ฅผ ์ž๋™์œผ๋กœ ์ฐพ๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

์—ฌ๊ธฐ์„œ๋Š” pytest.iniํŒŒ์ผ์— ๊ธฐ๋ณธ์„ค์ •์ด ์žˆ๊ณ  pytest๋„ ์ฐธ๊ณ ํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

[pytest]
DJANGO_SETTINGS_MODULE=myapp.settings
addopts = --reuse-db --disable-warnings --nomigrations
python_files = test*.py
python_functions = test*

๊ธฐ๋ณธ ์„ค์ •์œผ๋กœ๋Š” test*.py ํŒจํ„ด์˜ ํŒŒ์ผ๋ช… ๋ชจ๋“ˆ์„ ์ž๋™์œผ๋กœ ์ฐพ์Šต๋‹ˆ๋‹ค.

pytest ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ Test* ํŒจํ„ด์˜ class๋ฅผ ์ฐพ๊ฑฐ๋‚˜ ์„ค์ •์œผ๋กœ test* ํŒจํ„ด์˜ ํ•จ์ˆ˜๋ฅผ ์ฐพ์Šต๋‹ˆ๋‹ค.

# ์œ„์น˜: myapp/tests/test_serializers.py
class TestTokenAuthenticationSerializer:
    def test_validate(self, user):
        ...

reference

์ตœ๋Œ€ํ•œ ๊ตฌํ˜„๋œ ํ…Œ์ŠคํŠธ ๋ฒ”์œ„๋กœ ์„ค๋ช…์„ ๊ตญํ•œํ–ˆ์Šต๋‹ˆ๋‹ค.

๋” ์ž์„ธํ•œ ์‚ฌ์šฉ๋ฒ•์€ ๊ณต์‹๋ฌธ์„œ ์ฐธ๊ณ  ๋ถ€ํƒ๋“œ๋ฆฝ๋‹ˆ๋‹ค.

๐Ÿ—„๏ธDjango์™€ database๊ด€๋ จ

django์˜ ๊ธฐ๋Šฅ์„ pytest์„œ ์‚ฌ์šฉํ•˜์—ฌ ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์œ„ํ•ด pytest-django ํŒจํ‚ค์ง€๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

django ORM์„ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” django test์—์„œ๋Š” ๊ธฐ๋ณธ์œผ๋กœ test database๋ฅผ ์ƒ์„ฑํ•˜์—ฌ default database์™€ ๋ถ„๋ฆฌํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

pytest์„œ ์ƒ์„ฑ๋œ test_*****_local schema

pytest์„œ ์ƒ์„ฑ๋œ test_*_local schema

๋ชจ๋“  django database๊ด€๋ จ ํ…Œ์ŠคํŠธ๋Š” transaction์ด ๊ฑธ๋ฆฌ๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

test function ํ˜น์€ class, module ๋งˆ๋‹ค transaction์ด rollback(ํ˜น์€ trauncate) ๋˜๋ฉฐ ๋‹ค๋ฅธ ํ…Œ์ŠคํŠธ์™€ isolation์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.

django ORM์‚ฌ์šฉ ํ…Œ์ŠคํŠธ๋Š” ์•„๋ž˜ mark๋ฅผ ์ ์šฉํ•ด์•ผ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค.

# ํ•จ์ˆ˜์— django_db ์ ์šฉ
@pytest.mark.django_db
def test_foo():
    # some database access here
  ...

# class์— django_db ์ ์šฉ
@pytest.mark.django_db
class TestCase1:
        def test_foo(self):
                passs
# ๋ชจ๋“ˆ ์ „์ฒด django_db ์ ์šฉ
pytestmark = pytest.mark.django_db

# test code here
...

๊ธฐ๋ณธ ์‹คํ–‰ ์„ค์ •

์•„๋ž˜ ์ฒ˜๋Ÿผ ๋””๋น„๊ด€๋ จ ๊ธฐ๋ณธ ์‹คํ–‰ ์˜ต์…˜์€ โ€”-reuse-db โ€”-nomigrations์ž…๋‹ˆ๋‹ค.

[pytest]
DJANGO_SETTINGS_MODULE=myapp.settings
addopts = --reuse-db --disable-warnings --nomigrations
python_files = test*.py
python_functions = test*

์†๋„ ๋•Œ๋ฌธ์— test๋””๋น„๋ฅผ ๋งค๋ฒˆ ์žฌ์ƒ์„ฑ, ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ•˜์ง€ ์•Š๊ณ  ์žฌ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

ํ˜น์‹œ ํ…Œ์ŠคํŠธ ์ค‘ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ๋‚จ์€ ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์–ด ์ถฉ๋Œ์ด ๋‚˜๊ฑฐ๋‚˜, ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์— ๋ฌธ์ œ๊ฐ€ ์žˆ์„ ๊ฒฝ์šฐ, ํ˜น์€ ์ƒˆ๋กœ ์ถ”๊ฐ€ ๋œ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์„ ์ ์šฉ ํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ์•„๋ž˜ ๋ช…๋ น์–ด๋กœ ๋””๋น„ ์žฌ์ƒ์„ฑ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

$ pytest --create-db --migrations

๐ŸชจCustom Fixtures

mem_cache : fixture

pytest(or unittest)๋Š” cache isolation์„ ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ฆ‰ redis๊ฐ€ ๋™์ผํ•˜๊ฒŒ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.

pytest hook์œผ๋กœ ํ…Œ์ŠคํŠธ ์‹คํ–‰ ์‹œ ๋ชจ๋“  redis ์ผ€์‹œ๋ฅผ ์ง€์šฐ๊ธด ํ•˜์ง€๋งŒ ํ…Œ์ŠคํŠธ ๊ฐ„์— ์ผ€์‹œ๊ฐ€ ์ž๋™์œผ๋กœ ์›๋ณต๋˜์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— ํ…Œ์ŠคํŠธ์—์„œ ์ƒ์„ฑ ๋œ ์ผ€์‹œ๋กœ ์ธํ•ด ๋‹ค๋ฅธ ํ…Œ์ŠคํŠธ์—์„œ ๋‹จ๋… ์‹คํ–‰ ์‹œ ๋ฌธ์ œ์—†๋˜ ์ฝ”๋“œ๊ฐ€ ๊ฐ™์ด ๋Œ๋ฆด ์‹œ ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์ด์Šˆ๋ฅผ ๋ฐœ์ƒํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

์ด๋ฅผ ๋ฐฉ์ง€ ํ•˜๊ธฐ ์œ„ํ•ด์„œ in-memory cache๋ฅผ ์‚ฌ์šฉํ•ด isolation์„ ๊ตฌ์ถ•ํ•˜์˜€์Šต๋‹ˆ๋‹ค.

# ์œ„์น˜: myapp/tests/fixtures.py

@pytest.fixture()
def mem_cache():    # noqa
    # in-memory cache to isolate cache between tests
    # also isolates cache between processes, due to it allows one process to access the cache at a time
    with override_settings(CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}):
        cache.clear()
        yield
        cache.clear()

# ์œ„์น˜: myapp/tests/test_models.py

# using in-memory cache to isolate cache between tests
@pytest.mark.django_db()
@pytest.mark.usefixtures('mem_cache')
class TestMyModel:

    def test_cache_key_generation(self):
        # Create an instance of the model
        my_instance = MyModelFactory()

        # Cache key
        key = f'cache_key_prefix:model:{my_instance.id}'

        # Assert that the cache key is set correctly
        assert my_instance.cache_key == key

์œ„ ์˜ˆ์ œ ์ฒ˜๋Ÿผ, ์ผ€์‹œ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋“ค์€ mem_cache fixutre๋ฅผ ์‚ฌ์šฉํ•˜๋Š”๊ฑธ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.

redis ๊ธฐ๋Šฅ์ด ๊ผญ ํ•„์š”ํ•˜๋‹ค๋ฉด ํ…Œ์ŠคํŠธ ์ „ ํ›„ ๋กœ ๋งค๋ฒˆ clear๋ฅผ ํ•ด์ฃผ์‹œ๊ธธ ๋ฐ”๋ž๋‹ˆ๋‹ค.

๐Ÿ‘€code coverage

์ฝ”๋“œ ์ปค๋ฒ„๋ฆฌ์ง€๋ž€ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๊ฐ€ ์ปค๋ฒ„ํ•˜๋Š” ์ฝ”๋“œ์˜ ์ˆ˜์น˜ํ™”๋ฅผ ๋งํ•ฉ๋‹ˆ๋‹ค. instrument tool๋กœ ํ…Œ์ŠคํŠธ ์ง„ํ–‰ ์‹œ ์‹คํ–‰๋˜๋Š” ์ฝ”๋“œ๋ฅผ ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค. ์ปค๋ฒ„๋ฆฌ์ง€ ๋ฐฉ๋ฒ•์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ๊ธฐ์ค€์œผ๋กœ ์ฝ”๋“œ์˜ ์ปค๋ฒ„๋œ ์—ฌ๋ถ€๋ฅผ ๊ฒฐ์ •ํ•˜๊ฒŒ ๋˜๊ณ , ๋ณด๊ณ ๋กœ ์ปค๋ฒ„๋œ ์ฝ”๋“œ์˜ ์ค„ ์ˆ˜๋ฅผ ์ˆ˜์น˜๋กœ ํ™•์ธ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ด๋กœ ์ธํ•ด ํ…Œ์ŠคํŠธ๋ฅผ ๋ชปํ•œ ์ฝ”๋“œ๊ฐ€ ์–ด๋””์ธ์ง€ ์ „์ฒด ์ฝ”๋“œ ์ค‘ ์–ผ๋งˆ๋‚˜ ํ…Œ์ŠคํŠธ๋ฅผ ์ปค๋ฒ„ ๋ชปํ–ˆ๋Š”์ง€ ํ™•์ธ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

coverage ํ…Œ์ŠคํŠธ ์ถœ๋ ฅ ์˜ˆ์‹œ

coverage ํ…Œ์ŠคํŠธ ์ถœ๋ ฅ ์˜ˆ์‹œ

์ฝ”๋“œ ์ปค๋ฒ„๋ฆฌ์ง€ ๋ฐฉ๋ฒ•๋ก  ์ค‘ ๊ฐ€์žฅ ๋งŽ์ด ์‚ฌ์šฉ๋˜๋Š” ๋ฐฉ๋ฒ•์€ line coverage์ž…๋‹ˆ๋‹ค. ์ฝ”๋“œ ์ค„์ด ์‹คํ–‰ ๋  ๊ฒฝ์šฐ ํ…Œ์ŠคํŠธ๊ฐ€ ๋๋‹ค๊ณ  ๊ฐ€์ •ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๋‹ค๋ฅธ ๋ฐฉ๋ฒ•๋ก ์œผ๋กœ๋Š” branch coverage์ž…๋‹ˆ๋‹ค. condition์ด ์žˆ๋Š” ์ฝ”๋“œ ์ค‘ ๋ชจ๋“  true false ์กฐ๊ฑด์ด ๋‹ค ์‹คํ–‰๋˜์–ด์•ผ ํ•ด๋‹น code block์ด ์‹คํ–‰๋๋‹ค๊ณ  ๊ฐ€์ •ํ•˜๋Š” ๋ฐฉ๋ฒ•์ž…๋‹ˆ๋‹ค.

์ €ํฌ๋Š” ๊ฐ€์žฅ ๊ธฐ๋ณธ์œผ๋กœ line coverage๋ฐฉ๋ฒ•๋ก ์„ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค(pytest-coverage ๊ธฐ๋ณธ๊ฐ’).

์ถ” ํ›„ ์ปค๋ฒ„๋ฆฌ์ง€๊ฐ€ ์ถฉ๋ถ„ํžˆ ์˜ฌ๋ผ์˜ค๊ณ , ์ข€ ๋” ์—„๊ฒฉํ•œ ๊ธฐ์ค€์„ ์ ์šฉํ•  ์ˆ˜ ์žˆ์„ ๋•Œ ๋‹ค๋ฅธ ์ปค๋ฒ„๋ฆฌ์ง€ ๋ฐฉ๋ฒ•์„ ์‚ฌ์šฉํ•˜๋Š”๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.

ref: https://tecoble.techcourse.co.kr/post/2020-10-24-code-coverage/

๐Ÿ‘ฅํ…Œ์ŠคํŠธ ๋ฌธํ™” ๋ฐ ์ •์ฑ…

ํ…Œ์ŠคํŠธ ๋ฌธํ™”๋ฅผ ๊ฐ•ํ™”ํ•˜๊ธฐ ์œ„ํ•ด PR์— ์ถ”๊ฐ€๋œ ์ฝ”๋“œ์— ๋Œ€ํ•œ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค ์ž‘์„ฑ์„ ํ•„์ˆ˜ํ™”ํ•˜๊ณ ์ž ํ•ฉ๋‹ˆ๋‹ค. PR์— ํฌํ•จ๋œ ์ฝ”๋“œ ๋ณ€๊ฒฝ ์‚ฌํ•ญ์— ๋Œ€ํ•ด ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋ฅผ ์ž‘์„ฑํ•˜๊ฑฐ๋‚˜ ๊ธฐ์กด ํ…Œ์ŠคํŠธ๋ฅผ ์ˆ˜์ •ํ•˜์—ฌ ์ ์ง„์ ์œผ๋กœ ํ…Œ์ŠคํŠธ ์ปค๋ฒ„๋ฆฌ์ง€๋ฅผ ๋Š˜๋ฆฌ๋Š” ๊ฒƒ์„ ๋ชฉํ‘œ๋กœ ํ•ฉ๋‹ˆ๋‹ค.

GitHub Actions๋ฅผ ํ†ตํ•ด ๋ชจ๋“  ํ…Œ์ŠคํŠธ๋ฅผ ํ†ต๊ณผํ•œ ๊ฒฝ์šฐ์—๋งŒ PR ๋ณ‘ํ•ฉ์„ ํ—ˆ์šฉํ•˜๋ฉฐ, ์ด๋ฅผ ์™„์ „ํžˆ ์ ์šฉํ•˜๊ธฐ ์œ„ํ•œ ์ ์‘ ๊ธฐ๊ฐ„์„ ์ •ํ•ฉ๋‹ˆ๋‹ค.

์ดˆ๊ธฐ ์ปค๋ฒ„๋ฆฌ์ง€ ๊ธฐ์ค€์„ ์ •ํ•˜๊ณ , ์ ์ฐจ ์ƒํ–ฅํ•˜์—ฌ ์ „์ฒด ํ…Œ์ŠคํŠธ ์ปค๋ฒ„๋ฆฌ์ง€๋ฅผ ์ง€์†์ ์œผ๋กœ ๊ฐœ์„ ํ•ฉ๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ์ด ๋ˆ„๋ฝ๋œ ๊ฒฝ์šฐ, ์ฝ”๋“œ ์ปค๋ฒ„๋ฆฌ์ง€๊ฐ€ ๋‚ฎ์•„์ ธ ๋ชจ๋“  ํ…Œ์ŠคํŠธ๊ฐ€ ํ†ต๊ณผํ•˜๋”๋ผ๋„ GitHub Actions์—์„œ ์‹คํŒจ ์ฒ˜๋ฆฌ๋˜๋ฏ€๋กœ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ํ…Œ์ŠคํŠธ ์ž‘์„ฑ ๋ฌธํ™”๋ฅผ ์žฅ๋ คํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿƒ์‹คํ–‰๋ฐฉ๋ฒ•

Prerequisite

ํ…Œ์ŠคํŠธ ๋””๋น„๋ฅผ ์ƒ์„ฑํ•˜๊ธฐ ๋•Œ๋ฌธ์— ๋””๋น„ user์˜ ๊ถŒํ•œ์„ค์ •์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

> GRANT ALL PRIVILEGES ON *.* TO 'user'@'%' WITH GRANT OPTION; FLUSH PRIVILEGES;

requirements..txt ์„ค์น˜

pip install -r requirements..txt

๊ธฐ๋ณธ ํ…Œ์ŠคํŠธ

# ๊ธฐ๋ณธ ์‹คํ–‰
pytest

# ํŠน์ • ํ…Œ์ŠคํŠธ ํŒŒ์ผ ์‹คํ–‰
pytest myapp/tests/test_models.py
pytest myapp/tests/test_views.py::TestTokenGeneration::test_token_generation_permission_check

# ๋ฉ€ํ‹ฐ ํ”„๋กœ์„ธ์‹ฑ ์ ์šฉ
pytest -n auto

๊ด€๋ จ ์„ค์ •ํŒŒ์ผ

๊ธฐ๋ณธ ์„ค์ •์€ test ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์žฌ์‚ฌ์šฉํ•œ๋‹ค.

ํ˜น์‹œ ํ…Œ์ŠคํŠธ ์ค‘ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋‚จ์€๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์–ด ์ถฉ๋Œ์ด ๋‚˜๊ฑฐ๋‚˜, ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์— ๋ฌธ์ œ๊ฐ€ ์žˆ์„ ๊ฒฝ์šฐ ํ˜น์€ ์ƒˆ๋กœ ์ถ”๊ฐ€๋œ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์„ ์ ์šฉ ํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ์•„๋ž˜ ๋ช…๋ น์–ด๋กœ ๋‹ค์‹œ ๋””๋น„์ƒ์„ฑ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

$ pytest --create-db --migrations

์ปค๋ฒ„๋ฆฌ์ง€ ํ…Œ์ŠคํŠธ

line coverage๋ฅผ ๊ธฐ์ค€์œผ๋กœ ํ•ฉ๋‹ˆ๋‹ค.ย  ์ฝ”๋“œ ์ปค๋ฒ„๋ฆฌ์ง€ ๊ด€๋ จ๋ธŒ๋กœ๊ทธ

# ์ „์ฒด ๋ฒ”์œ„
$ pytest --cov=.

# ํ…Œ์ŠคํŠธ ๋ชปํ•œ ์ฝ”๋“œ ํ‘œ์‹œ
$ pytest --cov-report term-missing --cov=

# 80% ์ปค๋ฒ„๋ฆฌ์ง€ ์ดํ•˜ ์‹œ exit 1(fail), CICD์—์„œ ๋งŽ์ด ์‚ฌ์šฉ
$ pytest --cov=. --cov-fail-under=80

โ›“๏ธGithub Action(CI)

github action script๋กœ ํŠน์ • ์ด๋ฒคํŠธ ํŠธ๋ฆฌ๊ฑฐ๋กœ ์ž๋™ ํ…Œ์ŠคํŠธ๋ฅผ ์‹คํ–‰ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

name: Python Tests

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout code
      uses: actions/checkout@v3

    - name: Set up Python
      uses: actions/setup-python@v4
      with:
        python-version: 3.9

    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt

    - name: Run tests with pytest
      run: |
        pytest --cov=myapp --cov-report=xml