[python]unit_test 2

About_work·2023년 8월 17일
0

python clean code

목록 보기
10/11

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, '미어캣') # mock은 get_animals 함수처럼 동작한다.
assert result == expected

# 어떤 parameter가, 목 객체에게 정확히 한 번 전달돼었는지 검증한다.
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

#### call / assert_has_calls 의 쓰임을 잘 살펴보라!!!
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: ~~~
  • 해결 방법 1: 도우미 함수 만들기
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
profile
새로운 것이 들어오면 이미 있는 것과 충돌을 시도하라.

1개의 댓글

comment-user-thumbnail
2023년 8월 17일

개발자로서 성장하는 데 큰 도움이 된 글이었습니다. 감사합니다.

답글 달기