테스트 코드는 소프트웨어의 기능과 동작을 테스트하는데 사용되는 코드입니다. 테스트 코드는 개발자가 작성한 코드를 실행하여 예상되는 결과가 나오는지 확인하며 소프트웨어 결함을 찾습니다.
테스트 코드는 V 모델에 따라 크게 4가지로 나뉩니다. 각 테스트의 범위에 따라 예상 기댓값이 적절하게 맞는지 확인하여 테스트를 수행하게 됩니다.
V-모델
소프트웨어 개발의 주요 단계들(비즈니스 요구사항 명세화에서 배포까지)과 상응하는 테스트 레벨(인수 테스팅에서 유닛 테스팅까지) 사이의 일대일 대응 관계를 설명하는 순차적 개발 수명주기 모델입니다.
이 중 개발자는 단위 테스트와 통합 테스트를 주로 다루게 됩니다.
테스트를 목적으로 실제 객체, 연관된 객체를 직접 사용하기 어려울때 대신 사용하는 가짜 객체를 의미합니다.
테스트 더블을 활용하여 객체의 결과값을 예측할 수 있으며, 이를 활용하여 높은 품질의 소프트웨어를 더 빠르고 효과적으로 개발할 수 있게 됩니다.
Test Double은 여러 종류가 있지만 그 중 단위 테스트에서 많이 활용되는 Fake, Stub, Mock에 대해서만 설명하겠습니다.
Fake
객체의 행동을 모방하여 제작합니다.
Stub
특정 메서드가 정해진 응답값을 반환하는 객체입니다. Fake 객체와의 차이점은 응답값이 고정되어 있으며 상태를 검증하는데 사용됩니다.
Mock
Mock는 반환값이 없는 함수나 특정 객체에서 특정 함수가 호출되었는지 테스트할때 사용됩니다. 즉, 내가 바라는대로 호출에 대한 기댓값을 명시하고, 명시한 내용에 따라서 잘 작동되는지 확인합니다.
Stub과 유사하지만 호출된 메서드에 대해 행위를 검증할 때 사용됩니다.
단위 테스트는 소프트웨어 개발에서 일반적으로 사용되는 테스트 중 하나로, 개별적인 코드 단위가 의도한대로 작동하는지 확인합니다.
실제 프로젝트에 적용해보겠습니다.
레포지토리에는 Challenge id로 조회하여 Challenge 객체를 반환하는 프라이빗 함수가 있습니다.
class ChallengeRepository:
def __init__(self, session):
self.session = session
# Challenge 객체를 가져오는 함수
def _get_challenge(self, challenge_id: int) -> Challenges:
"""
챌린지 아이디로 챌린지 조회
Args:
challenge_id (int): 챌린지 아이디
Returns:
Challenges: 챌린지 객체
Raises:
ChallengeNotFound: 챌린지가 존재하지 않을 때
InternalServerError: DB 에러 발생 시
"""
try:
challenge = self.session.query(Challenges).get(challenge_id)
if not challenge:
raise ChallengeNotFound(error_msg=f"Challenge not found: {challenge_id}")
return challenge
except SQLAlchemyError as e:
raise InternalServerError(error_msg=f"Error getting challenge by id {challenge_id}: {str(e)}") from e
테스트 코드는 Pytest를 중점적으로 사용하돼 unittest 모듈도 함께 사용하겠습니다.
pytest에서 지정한 setup_method 를 활용하여 클래스 내부에 있는 테스트코드를 실행하기 전에 필요한 변수를 지정합니다.
여기서 MagicMock()를 활용하여 mock_session을 선언했습니다. session은 Flask 앱 초기화 시 데이터베이스에서 생성하는 객체입니다. 단위 테스트는 _get_challenge()에 대한 테스트만 수행하는 것이므로 session과 같은 외부 의존성을 임시 객체로 선언하여 사용합니다.
class TestChallengeRepositoryGetChallenge:
def setup_method(self):
# 임시 session을 만듭니다.
self.mock_session = MagicMock()
# mocking된 session을 활용하여 테스트할 레포지토리를 정의합니다.
self.repository = ChallengeRepository(self.mock_session)
mocking된 Challenge 객체를 활용하여 레포지토리가 성공인지 테스트를 수행합니다.
여기서 활용된 test fixture란 중복 발생되는 행위를 고정시켜 한곳에 관리하는 개념이라고 보시면 됩니다. 추후 다른 함수에서도 활용될 예정이므로 fixture를 수행했습니다.
여기서 왜 Fake 객체가 아니라 Mock 객체로 선언했는지 궁금할 것입니다. _get_challenge()는 Challenge 객체를 최종적으로 반환합니다. 이 과정에서 발생하는 리턴 값, 예외값을 모두 테스트해야 합니다. 이처럼 다양한 행위를 테스트하기 위해서 Mock를 사용했습니다.
@pytest.fixture
def mock_challenge():
"""Mock challenge object"""
challenge = MagicMock()
challenge.id = 1
challenge.title = "Test Challenge"
return challenge
class TestChallengeRepositoryGetChallenge:
# ...
def test_success(self, mock_challenge):
# self.repository._get_challenge(1) 반환 값이 mock_challenge
self.mock_session.query().get.return_value = mock_challenge
result = self.repository._get_challenge(1)
# 결과 값 비교
assert result == mock_challenge
def test_challenge_does_not_exist(self):
# 발생할 예외 : ChallengeNotFound
self.mock_session.query().get.side_effect = ChallengeNotFound("Challenge not found")
# 임시로 ChallengeNotFound 발생시키기
with pytest.raises(ChallengeNotFound) as exc_info:
self.repository._get_challenge(1)
# 결과 확인하기
assert "Challenge not found" in str(exc_info.value)
레포지토리와 다르게 단순히 값만 반환하는 NameBuilder 함수를 확인해봅시다.
Namebuilder는 challenge_id, user_id를 입력 받아서 build() 함수를 통해 ChallengeInfo라는 객체를 반환합니다.
class NameBuilder:
def __init__(self, challenge_id: int, user_id: int):
self._challenge_id = challenge_id
self._user_id = user_id
def _is_valid_name(self, name:str) -> bool:
"""Kubernetes 리소스 이름 유효성 검사"""
name = name.lower()
if not name or len(name) > 253:
return False
pattern = r'^[a-z0-9]([-a-z0-9]*[a-z0-9])?$'
return bool(re.match(pattern, name))
def build(self) -> Optional[ChallengeInfo]:
"""
챌린지 이름 빌더
"""
challenge_info = ChallengeInfo(challenge_id=self._challenge_id, user_id=self._user_id)
if not self._is_valid_name(challenge_info.name):
raise InvalidName(error_msg = f"Invalid challenge name {challenge_info.name}")
return challenge_info
이럴 경우 ChallengeInfo는 외부 의존성이 없는 단순한 클래스입니다. 그러므로 challenge_stub을 생성하여 값을 고정한 후 build 함수가 적절하게 응답했는지 확인하면 됩니다.
@pytest.fixture
def challengeinfo_stub():
return ChallengeInfo(challenge_id=1, user_id=1)
class TestNameBuilder:
def setup_method(self):
self.namebuilder = NameBuilder(1, 1)
def test_success(self, challengeinfo_stub):
info = self.namebuilder.build()
assert info.challenge_id == 1
assert info.user_id == 1
assert info.name == "challenge-1-1"
결국에는 요구사항에 적합하고 유지보수성이 높은 소프트웨어를 개발하기 위해서는 테스트 코드 작성이 필수입니다.
단순한 웹 애플리케이션을 개발할때는 테스트 코드의 필요성을 느끼지 못했습니다. 코드 전반이 단순하여 오류 발생 시 금방 발견하게 수정할 수 있었습니다. 그러나 2개 이상의 레포지토리, 2개 이상의 플랫폼을 활용하여 개발하면서 오류의 문제점을 재빠르게 확인할 수 없었습니다.
막상 테스트 코드를 짜려고 하니 결합도와 의존성이 높아서 개인적으로 힘들었던 기억이 있습니다. 리펙토링이 아니라 처음부터 다시 개발하는 수준으로 코드를 다시 작성해야 했습니다.
이론적으로는 필요성을 알고 있었습니다. 그러나 실제로 애플리케이션을 클라이언트에게 배포하다보니 테스트 코드의 중요성을 몸소 느끼고 있습니다. 현재 테스트 코드를 조금씩 작성하고 있습니다. 그때마다 고려하지 못한 오류 사항을 마주했고 이에 맞게 코드를 수정하고 있습니다.
<hr><p>Testing: Pytest를 활용한 단위 테스트 구현 was originally published in S0okJu Technology Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>