78. mock을 사용해, 의존 관계가 복잡한 코드를 테스트하라.
요약
unittest.mock
모듈은 Mock
클래스를 사용해 interface의 동작을 흉내낼 수 있게 해준다.
- 테스트를 할 때, 테스트 대상 코드가 호출해야 하는 의존 관계 함수를 설정하기 힘든 경우에는 목을 사용하면 유용하다.
- 목을 사용할 때는 아래의 2가지 것들이 중요하다. 이를 위해,
Mock.assert_called_once_with
나 이와 비슷한 메서드들을 사용해 이런 검증을 수행한다.
- 테스트 대상 코드의 동작을 검증하는 것
- 테스트 대상 코드가 호출하는 의존 관계 함수들이 호출되는 방식을 검증하는 것
- 목을 테스트 대상 코드에 주입할 때는
- 키워드를 사용해 호출해야하는 인자를 쓰거나,
unittest.mock.patch
또는 이와 비슷한 메서드들을 사용한다.
본문
- 테스트를 작성할 때, 사용하기에 너무 느리거나 어려운 함수와 클래스를 -> Mock을 만들어 해결하는 좋은 방법이 있다.
- 예: db에서 데이터를 가져오는 등의 작업을 실제로 구성하려면 너무 많은 작업이 필요하다.
class DatabaseConnection:
def __init__(self, host, port):
pass
def get_animals(database, species):
...
return [("", datetime(2020,1,1,1,1,1))]
- 참고: fake
- 목이랑 다름
- DatabaseConnection의 기능을 대부분 제공하지만, 더 단순한 단일 thread in-memory 데이터베이스를 사용
- Mock
- 자신이 흉내 내려는 대상(예: 특정 동물이 최근에 먹이를 언제 먹었는지를 db에서 체크하도록 하는 함수)에 의존하는 다른 함수들이 어떤 요청을 보내면,
- mock은 어떤 응답을 보내야 할지 알고 있고, 요청에 따라 적절한 응답을 보내준다.
- 즉,
Mock 인스턴스
는 db에 실제로 접속하지 않고,get_animals
함수를 시뮬레이션한다.
- Mock 클래스는, mock 함수를 만든다.
spec
(argument)
- 목이 작동을 흉내 내야 하는 대상
- 대상(spec) 에 대한 잘못된 요청이 들어오면, 오류를 발생시킨다.
mock.return_value
(attribute)
from unittest.mock import Mock
mock = Mock(spec=get_animals)
expected = [
('점박이', datetime(2020, 6, 5, 11, 15)),
('털보', datetime(2020, 6, 5, 12, 30)),
('조조', datetime(2020, 6, 5, 12, 45)),
]
mock.return_value = expected
- Mock
- 여기서 목이 db를 실제로 사용하지는 않기 때문에, 고유한
object()
값을 목의 database 인자로 넘겼다.
- 여기서 database의 경우처럼 목에 전달되는 개별 parameter에 관심이 없다면,
unittest.mock.ANY
상수를 사용해 어떤 argument를 전달해도 관계없다고 표현할 수도 있다.
- 이처럼 테스트를 과도하게 구체적으로 만들지 말고,
ANY
를 써서 테스트를 느슨하게 하면 더 좋을 때가 있다.
assert_called_once_with
- 어떤 parameter가, 목 객체에게 정확히 한 번 전달돼었는지 검증한다.
assert_called_with
- 가장 최근에 목을 호출할 때, 어떤 인자가 전달됐는지 확인할 수도 있다.
database = object()
result = mock(database, '미어캣')
assert result == expected
mock.assert_called_once_with(database, '미어캣')
mock.assert_called_once_with(database, '기린')
from unittest.mock import ANY
mock = Mock(spec=get_animals)
mock('database 1', '토끼')
mock('database 2', '들소')
mock('database 3', '미어캣')
mock.assert_called_with(ANY, '미어캣')
- Mock
- 예외 발생을 쉽게 Mocking 할 수 있는 도구도 제공 (
side_effect
)
class MyError(Exception):
pass
mock = Mock(spec=get_animals)
mock.side_effect = MyError('애그머니나! 큰 문제 발생')
result = mock(database, '미어캣')
>>>
MyError: 에구머니나! 큰 문제 발생
- 실전 예시
do_round
가 실행될 때, 아래의 것들을 검증해야 한다.
- 원하는 동물에게 먹이가 주어졌는지,
- 데이터베이스에 최종 급양(먹이를 준) 시간이 기록되는지,
- 함수가 반환한 전체 급양(먹이를 준) 횟수가 제대로인지
def get_food_period(database, species):
...
return 3
def feed_animal(database, name, when):
...
def get_animals(database, species):
...
return [("", datetime(2020,1,1,1,1,1))]
def do_rounds(database, species):
now = datetime.datetime.utcnow()
feeding_timedelta = get_food_period(database, species)
animals = get_animals(database, species)
fed = 0
for name, last_mealtime in animals:
if (now - last_mealtime) > feeding_timedelta:
feed_animal(database, name, now)
fed += 1
return fed
do_round
에 대한 테스트 코드 짜기
datetime.utcnow
를 모킹해서 -> 테스트를 실행하는 시간이 서머타임이나 다른 일시적인 변화에 영향을 받지 않게 만든다.
get_food_period
/ get_animals
도 모킹해서 -> db에서 값을 가져와야 하는 것을 간편화
feed_animal
을 모팅해서 -> db에 다시 써야 하는 데이터를 받을 수 있게 해야 한다.
- 테스트 대상인
do_rounds
함수가, 실제 함수가 아닌 목 함수를 쓰게 바꾸는 방법은 무엇일까?
- 방법 1: 모든 함수 요소를 keyword 방식으로 지정해야 하는 인자로 만드는 것
- 단점
- 코드가 장황해진다
- 테스트 대상 함수를 모두 변경해 줘야 한다.
def do_rounds(database, species, *,
now_func=datetime.utcnow,
food_func=get_food_period,
animals_func=get_animals,
feed_func=feed_animal):
now = now_func()
feeding_timedelta = food_func(database, species)
animals = animals_func(database, species)
fed = 0
for name, last_mealtime in animals:
if (now - last_mealtime) > feeding_timedelta:
feed_func(database, name, now)
fed += 1
return
- 방법 1테스트 코드 예시
- 모든 Mock 인스턴스를 미리 만들고, 각각의 예상 return 값을 설정해야 한다.
from datetime import datetime
from datetime import timedelta
now_func = Mock(spec=datetime.utcnow)
now_func.return_value = datetime(2020, 6, 5, 15, 45)
food_func = Mock(spec=get_food_period)
food_func.return_value = timedelta(hours=3)
animals_func = Mock(spec=get_animals)
animals_func.return_value = [
('점박이', datetime(2020, 6, 5, 11, 15)),
('털보', datetime(2020, 6, 5, 12, 30)),
('조조', datetime(2020, 6, 5, 12, 45)),
]
feed_func = Mock(spec=feed_animal)
result = do_rounds(
database,
'미어캣',
now_func=now_func,
food_func=food_func,
animals_func=animals_func,
feed_func=feed_func)
assert result == 2
from unittest.mock import call
food_func.assert_called_once_with(database, '미어캣')
animals_func.assert_called_once_with(database, '미어캣')
feed_func.assert_has_calls(
[
call(database, '점박이', now_func.return_value),
call(database, '털보', now_func.return_value),
],
any_order=True)
- 방법 2:
unittest.mock.patch
관련 함수들을 이용하여 목을 주입하라.
patch
함수는 임시로 모듈이나 클래스의 attribute에 다른 값을 대입해준다.
patch
를 사용하면, 앞에서 본 db에 접근하는 함수들을 임시로 다른 함수로 대체할 수 있다.
- with 문 내에서 사용 가능
- 함수 decorator로 사용 가능
- TestCase 클새스 안의 setUP 이나 tearDown에서 사용 가능
from unittest.mock import patch
print('패치 외부:', get_animals)
>>> <funciton get_animals at ~~~>
with patch('__main__.get_animals'):
print('패치 내부: ', get_animals)
>>> <MagicModck name='get_animals' id='~~~'>
print('다시 외부:', get_animals)
>>> <funciton get_animals at ~~~>
- 아래의 경우는 datetime 클래스가 C확장 모듈이므로, 아래가 난다.
fake_now = datetime(2020, 6, 5, 15, 45)
with patch('datetime.datetime.utcnow'):
datetime.utcnow.return_value = fake_now
>>>
Traceback ...
TypeError: ~~~
def get_do_rounds_time():
return datetime.datetime.utcnow()
def do_rounds(database, species):
now = get_do_rounds_time()
...
with patch('__main__.get_do_rounds_time'):
...
- 해결 방법 2:
datetime.utcnow
만 keyword argument로 사용하고, 다른 목에 대해서는 patch
사용
patch.multiple
의 keyword argument 들은,
__main__
모듈에 있는 이름 중에서, 테스트 동안에만 변경하고 싶은 이름에 해당한다.
DEFUALT
값은 각 이름에 대해 표준 Mock
인스턴스를 만들고 싶다는 뜻이다.
autospec=True
를 지정했기 때문에
- 만들어진 목은 각각이 시뮬레이션하기로 돼 있는 객체(
__main__
모듈에 있는 이름이 같은 원 객체)의 명세를 따른다.
def do_rounds(database, species, *, utcnow=datetime.utcnow):
now = utcnow()
feeding_timedelta = get_food_period(database, species)
animals = get_animals(database, species)
fed = 0
for name, last_mealtime in animals:
if (now - last_mealtime) > feeding_timedelta:
feed_func(database, name, now)
fed += 1
return fed
from unittest.mock import DEFAULT
with patch.multiple('__main__',
autospec=True,
get_food_period=DEFAULT,
get_animals=DEFAULT,
feed_animal=DEFAULT):
now_func = Mock(spec=datetime.utcnow)
now_func.return_value = datetime(2020, 6, 5, 15, 45)
get_food_period.return_value = timedelta(hours=3)
get_animals.return_value = [
('점박이', datetime(2020, 6, 5, 11, 15)),
('털보', datetime(2020, 6, 5, 12, 30)),
('조조', datetime(2020, 6, 5, 12, 45))
]
result = do_rounds(database, '미어캣', utcnow=now_func)
assert result == 2
food_func.assert_called_once_with(database, '미어캣')
animals_func.assert_called_once_with(database, '미어캣')
feed_func.assert_has_calls(
[
call(database, '점박이', now_func.return_value),
call(database, '털보', now_func.return_value),
],
any_order=True)
79. 의존 관계를 캡술화해, mocking과 test를 쉽게 만들라.
요약
- 단위 테스트를 작성할 때 Mock을 만들기 위해 반복적인 준비 코드를 많이 사용해야 한다면,
- 테스트 대상이 의존하는 다른 기능들을 더 쉽게 mocking할 수 있는 클래스로 캡슐화 하는 것이 좋다.
unittest.mock
내장 모듈의 Mock
클래스는, 클래스를 시뮬레이션할 수 있는 새로운 Mock을 반환한다.
- 이 Mock은 메서드 처럼 작동할 수 있고, 클래스 내 각각의 attribute에 접근할 수도 있다.
- end-to-end 테스트를 위해서는, 테스트에 사용할 Mock 의존 관계를 주입하는 데 명시적인 연결점으로 쓰일 수 있는
- 도우미 함수를 더 많이 포함하도록 코드를 리펙터링 하는 것이 좋다.
본문
- 78 section의 Mock은 아래의 단점이 있다.
- 준비 코드가 많이 들어가므로, 테스트 코드를 처음 보고 테스트가 무엇을 검증하려는 것인지 이해하기 어려울 수 있다.
- 이를 극복하기 위해서는
DatabaseConnection
객체를 인자로 직접 전달하는 대신, wrapper 객체를 사용해 DB interface를 캡슐화 하는 것이다.
- 더 나은 추상화를 사용하면 -> Mock 이나 test 를 더 쉽게 만들 수 있으므로
- 때로는 더 나은 추상화를 사용하도록 코드를 refactoring할 만한 가치가 있다.
- 아래 코드는, 78 section에서 다룬 여러 가지 db 도우미 함수를 개별 함수가 아니라, -> 한 클래스 안에 들어 있는 method가 되도록 다시 정의한다.
class ZooDatabase:
...
def get_animals(self, species):
...
def get_food_period(self, species):
...
def feed_animal(self, name, when):
...
from datetime import datetime
def do_rounds(database, species, *, utcnow=datetime.utcnow):
now = utcnow()
feeding_timedelta = database.get_food_period(species)
animals = database.get_animals(species)
fed = 0
for name, last_mealtime in animals:
if (now - last_mealtime) >= feeding_timedelta:
database.feed_animal(name, now)
fed += 1
return fed
- 이렇게 하면,
unittest.mock.patch
를 사용해 Mock을 test 대상 코드에 주입할 필요가 없으므로, do_rounds
에 대한 테스트를 작성하기가 더 쉽다.
- 이제는
ZooDatabase
를 표현하는 Mock 인스턴스를 만들어서, do_rounds의 database로 넘길 수 있다.
- Mock 클래스는
- 자신의 attribute에 대해 이뤄지는 모든 접근에 대해 -> mock 객체를 반환한다.
- 이런 attribute들을 method 처럼 호출할 수 있고,
- 이 attribute들을 사용해 호출 시 return될 예상 값을 설정하고, 호출 여부를 검증할 수 있다.
- Mock에
spec
파라미터를 사용하면,
- 테스트 대상 코드가 실수로 method 이름을 잘못 사용하는 경우를 발견할 수 있으므로 특히 도움이 된다.
from unittest.mock import Mock
database = Mock(spec=ZooDatabase)
print(database.feed_animal)
>>>
<Mock name='mock.feed_animal' id='4384773408'>
database.feed_animal()
database.feed_animal.assert_any_call()
from datetime import timedelta
from unittest.mock import call
now_func = Mock(spec=datetime.utcnow)
now_func.return_value = datetime(2019, 6, 5, 15, 45)
database = Mock(spec=ZooDatabase)
database.get_food_period.return_value = timedelta(hours=3)
database.get_animals.return_value = [
('점박이', datetime(2019, 6, 5, 11, 15)),
('털보', datetime(2019, 6, 5, 12, 30)),
('조조', datetime(2019, 6, 5, 12, 55))
]
result = do_rounds(database, '미어캣', utcnow=now_func)
assert result == 2
database.get_food_period.assert_called_once_with('미어캣')
database.get_animals.assert_called_once_with('미어캣')
database.feed_animal.assert_has_calls(
[
call('점박이', now_func.return_value),
call('털보', now_func.return_value),
],
any_order=True)
database.bad_method_name()
>>>
AttributeError: Mock object has no attribute 'bad_method_name'
- 위 program을, 중간 수준의 통합 테스트와 함께 end-to-end로 테스트하고 싶다면, 여전히 program에
ZooDatabase
를 주입할 방법이 필요하다.
- 의존 관계 주입의 연결점 역할을 하는 도우미 함수를 만들어서 ->
ZooDatabase
를 프로그램에 주입할 수 있다.
- 다음 코드에서는
global
문을 사용해
- 모듈 영역에
ZooDatabase
를 캐시해주는 도우미 함수를 정의한다.
DATABASE = None
def get_database():
global DATABASE
if DATABASE is None:
DATABASE = ZooDatabase()
return DATABASE
def main(argv):
database = get_database()
species = argv[1]
count = do_rounds(database, species)
print(f'급양: {count} {species}')
return 0
- 통합 테스트 만들기
- 테스트를 쉽게 작성할 수 있도록 프로그램을 구현했기 때문에, 이런 통합 테스트도 쉽게 만들 수 있었던 것이다.
import contextlib
import io
from unittest.mock import patch
with patch('__main__.DATABASE', spec=ZooDatabase):
now = datetime.utcnow()
DATABASE.get_food_period.return_value = timedelta(hours=3)
DATABASE.get_animals.return_value = [
('점박이', now - timedelta(minutes=4.5)),
('털보', now - timedelta(hours=3.25)),
('조조', now - timedelta(hours=3)),
]
fake_stdout = io.StringIO()
with contextlib.redirect_stdout(fake_stdout):
main(['프로그램 이름', '미어캣'])
found = fake_stdout.getvalue()
expected = '급양: 2 미어캣\n'
assert found == expected
개발자로서 성장하는 데 큰 도움이 된 글이었습니다. 감사합니다.