pytest in python
Mon 08 January 2024๐๏ธ๋ชฉ์ฐจ
๐ฏ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:
- function: ๊ธฐ๋ณธ๊ฐ, ๋ฏธ์ง์ ์ ๊ธฐ๋ณธ์ค์ , ํจ์ ์์ ์ ํ๋ก ๋ฆฌ์ ๋จ
- class: class๊ฐ ์ฌํ์ฉ๋๋ scope
- module
- package
- session: ์ ์ฒด ํ ์คํธ ์ธ์ ์ ๊ณต์ ๋จ, global scope์ด๋ผ๊ณ ๋ณผ ์ ์์, multi processing์ผ๋ก test run์ ์ฌ๋ฌ session์ด ์์ ์ ์์
@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
๋ชจ๋ 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 ํ ์คํธ ์ถ๋ ฅ ์์
์ฝ๋ ์ปค๋ฒ๋ฆฌ์ง ๋ฐฉ๋ฒ๋ก ์ค ๊ฐ์ฅ ๋ง์ด ์ฌ์ฉ๋๋ ๋ฐฉ๋ฒ์ 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
๊ด๋ จ ์ค์ ํ์ผ
- pytest.ini
- myapp/settings/pytest.py
๊ธฐ๋ณธ ์ค์ ์ 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
Isaac's Tech Blog