파이썬 클린코드를 읽으며 정리한 내용입니다.
# 테스트가 코드의 개선으로 이어지는 예시
class MetricsClient:
"""타사 지표 전송 클라이언트"""
def send(self, metric_name, metric_value):
if not isinstance(metric_name, str):
raise TypeError("metric_name으로 문자열 타입을 사용해야 함")
if not isinstance(metric_value, str):
raise TypeError("metric_value로 문자열 타입을 사용해야 함")
print(f"{metric_name} 전송 값 = {metric_value}")
class Process:
def __init__(self):
self.client = MetricsClient()
def process_iterations(self, n_iterations):
for i in range(n_iterations):
result = self.run_process()
self.client.send("iteration.".format(i), result)
class WrappedClient:
def __init__(self):
self.client = MetricsClient()
def send(self, metric_name, metric_value):
return self.client.send(str(metric_name), str(metric_value))
class Process:
def __init__(self):
self.client = WrappedClient()
def process_iterations(self, n_iterations):
for i in range(n_iterations):
result = self.run_process()
self.client.send("iteration.".format(i), result)
import unittest
from unittest.mock import Mock
class TestWrappedClient(unittest.TestCase):
def test_send_converts_types(self):
wrapped_client = WrappedClient()
wrapped_client.client = Mock()
wrapped_client.send("value", 1)
wrapped_client.client.send.assert_called_with("value", "1")
from enum import Enum
class MergeRequestStatus(Enum):
APPROVED = 'approved'
REJECTED = 'rejected'
PENDING = 'pending'
class MergeRequest:
def __init__(self):
self._context = {
"upvotes": set(),
"downvotes": set(),
}
@property
def status(self):
if self._context['downvotes']:
return MergeRequestStatus.REJECTED
elif len(self._context['upvotes']) >= 2:
return MergeRequestStatus.APPROVED
return MergeRequestStatus.PENDING
def upvote(self, by_user):
self._context['downvotes'].discard(by_user)
self._context['upvotes'].add(by_user)
def downvote(self, by_user):
self._context['upvotes'].discard(by_user)
self._context['downvotes'].add(by_user)
unittest.TestCase
를 상속하여 테스트 클래스 정의import unittest
class TestMergeRequestStatus(unittest.TestCase):
def test_simple_rejected(self):
merge_request = MergeRequest()
merge_request.downvote('maintainer')
self.assertEqual(merge_request.status, MergeRequestStatus.REJECTED)
def test_just_created_is_pending(self):
self.assertEqual(MergeRequest().status, MergeRequestStatus.PENDING)
assertEquals()
이다.assertRaises()
를 이용해 특정 예외가 발생했는지 여부도 확인할 수 있다.assertRaisesRegex()
를 이용하면 발생한 예외의 메시지가 제공된 정규식과 일치하는지도 확인한다.테스트 파라미터화
class AcceptanceThreshold:
def __init__(self, merge_request_context: dict) -> None:
self._context = merge_request_context
def status(self):
if self._context['downvotes']:
return MergeRequestStatus.REJECTED
elif len(self._context['upvotes']) >= 2:
return MergeRequestStatus.APPROVED
return MergeRequestStatus.PENDING
class MergeRequest:
def __init__(self):
self._context = {
"upvotes": set(),
"downvotes": set(),
}
self._status = MergeRequestStatus.OPEN
def close(self):
self._status = MergeRequestStatus.CLOSED
def _cannot_vote_if_closed(self):
if self._status == MergeRequestStatus.CLOSED:
raise MergeRequestException("종료된 머지 리퀘스트에 투표할 수 없음")
@property
def status(self):
if self._status == MergeRequestStatus.CLOSED:
return self._status
return AcceptanceThreshold(self._context).status()
def upvote(self, by_user):
self._cannot_vote_if_closed()
self._context['downvotes'].discard(by_user)
self._context['upvotes'].add(by_user)
def downvote(self, by_user):
self._cannot_vote_if_closed()
self._context['upvotes'].discard(by_user)
self._context['downvotes'].add(by_user)
class TestAcceptnaceThreshold(unittest.TestCase):
def setUp(self):
self.fixture_data = (
(
{'downvotes': set(), 'upvotes': set()},
MergeRequestStatus.PENDING
),
(
{'downvotes': set(), 'upvotes': {'dev1'}},
MergeRequestStatus.PENDING
),
(
{'downvotes': {'dev1'}, 'upvotes': set()},
MergeRequestStatus.REJECTED
),
(
{'downvotes': set(), 'upvotes': {'dev1', 'dev2'}},
MergeRequestStatus.APPROVED
),
)
def test_status_resolution(self):
for context, expected in self.fixture_data:
with self.subTest(context=context):
status = AcceptanceThreshold(context).status()
self.assertEqual(status, expected)
기초적인 pytest 사용 예시
# 이전의 테스트를 pytest로 다시 작성
def test_simple_rejected():
merge_request = MergeRequest()
merge_request.downvote('maintainer')
assert merge_request.status == MergeRequestStatus.REJECTED
def test_just_created_is_pending():
assert MergeRequest().status == MergeRequestStatus.PENDING
# 예외의 발생 유무와 같은 검사는 pytest의 함수를 사용해야 한다.
def test_invalid_types():
merge_request = MergeRequest()
pytest.raises(TypeError, merge_request.upvote, {'invalid-object'})
def test_cannot_vote_on_closed_merge_request():
merge_request = MergeRequest()
merge_request.close()
pytest.raises(MergeRequestException, merge_request.upvote, 'dev1')
with pytest.raises(
MergeRequestException,
match='종료된 머지 리퀘스트에 투표할 수 없음',
):
merge_request.downvote('dev1')
pytest.raises
는 unittest.TestCase.assertRaises
와 동일하며 메서드 또는 컨텍스트 관리자 형태로 호출할 수 있다.테스트 파라미터화
pytest.mark.parametrize
데코레이터를 사용해야 한다.import pytest
@pytest.mark.parametrize("context, expected_status", (
(
{'downvotes': set(), 'upvotes': set()},
MergeRequestStatus.PENDING
),
(
{'downvotes': set(), 'upvotes': {'dev1'}},
MergeRequestStatus.PENDING
),
(
{'downvotes': {'dev1'}, 'upvotes': set()},
MergeRequestStatus.REJECTED
),
(
{'downvotes': set(), 'upvotes': {'dev1', 'dev2'}},
MergeRequestStatus.APPROVED
),
))
def test_acceptance_threshold_status_resolution(context, expected_status):
assert AcceptanceThreshold(context).status() == expected_status
픽스처
MergeRequest
객체를 만들고 여러 테스트에서 이 객체를 재사용할 수 있다.@pytest.fixture
def rejected_mr():
merge_request = MergeRequest()
merge_request.downvote('dev1')
merge_request.upvote('dev2')
merge_request.upvote('dev3')
merge_request.downvote('dev4')
return merge_request
def test_simple_rejected(rejected_mr):
assert rejected_mr.status == MergeRequestStatus.REJECTED
def test_rejected_with_approvals(rejected_mr):
rejected_mr.upvote('dev2')
rejected_mr.upvote('dev3')
assert rejected_mr.status == MergeRequestStatus.REJECTED
코드 커버리지 도구 설정
pytest \\
--cov-report term-missing \\
--cov=coverage_1
test_coverage_1.py
코드 커버리지 사용 시 주의사항
패치와 모의에 대한 주의사항
Mock 객체 사용하기
from datetime import datetime
import requests
from contants import STATUS_ENDPOINT
class BuildStatus:
"""CI 도구에서의 머지 리퀘스트 상태"""
@staticmethod
def build_date() -> str:
return datetime.utcnow().isoformat()
@classmethod
def notify(cls, merge_request_id, status):
build_status = {
'id': merge_request_id,
'status': status,
'built_at': cls.build_date()
}
response = requests.post(STATUS_ENDPOINT, json=build_status)
response.raise_for_status() # 200이 아닌 경우 예외 발생
return response
from unittest import mock
from contants import STATUS_ENDPOINT
from mock_2 import BuildStatus
@mock.patch('mock_2.requests')
def test_build_notification_sent(mock_requests):
build_date = '2019-01-01T00:00:01'
with mock.patch('mock_2.BuildStatus.build_date', return_value=build_date):
BuildStatus.notify(123, 'OK')
expected_payload = {'id': 123, 'status': 'OK', 'built_at': build_date}
mock_requests.post.assert_called_with(
STATUS_ENDPOINT, json=expected_payload
)
@mock.patch
데코레이터를 이용해 테스트 안에서 mock_2.request를 호출하면 mock_requests라는 mock 객체가 대신할 것이라고 알려준다.mock.patch
에서는 build_date()
호출 시 어설션에 사용할 날짜를 반환하도록 패치한다.