[오늘의 헤드라인] 테스트 코드 작성기(2) — Mock

Gyeong Jun Paik
빅펄 (헤드라잇)
11 min readJan 8, 2021

--

이전글: [오늘의 헤드라인] 테스트 코드 작성기(1) — 단위 테스트 프레임워크

안녕하세요. 오늘의 헤드라인 서버팀 소프트웨어 엔지니어 Jun입니다.

오늘의 헤드라인팀은 성능을 위해 coroutine 라이브러리를 활용하고 있습니다. 그러나 해당 coroutine 라이브러리(aioredis, aiobotocore) mock에 대한 use case가 부족해 약 2주간의 삽질을 통해 해결한 경험을 이번글을 통해 공유하고자 합니다

서버팀은 서비스의 안정성을 높이기 위해 백엔드 프로젝트에 테스트 코드 작성을 시도했습니다. 이 프로젝트는 외부 서비스와 통신하는 코드였고, 테스트를 위해서는 각각의 시스템이 필요했습니다.

위의 문제를 쉽게 해결할 방안이 Mock이었습니다.

Mock이란?

직역하면 “모조품”이라는 뜻입니다.

테스트 코드를 실행할 때 외부의 다른 서비스나 모듈을 사용하지 않고 ‘실제 모듈과 유사하게 동작하는 가짜’를 Mock이라고 합니다.

Mock을 사용하는 이유

특정 경우에는 테스트가 외부 서비스(데이터베이스, 다른 웹 서버)로부터 의존하는 경우가 있습니다.

이럴 때는 다음과 같은 이유로 인해 테스트가 어려워집니다:

* 글에서 ‘상용 서비스나 데이터베이스’를 축약하여 ‘외부 서비스’라고 지칭하겠습니다.

1) 외부 서비스를 호출하는 것은 테스트 성능을 저하시킵니다.

  • Mock 자원은 외부 서비스를 흉내 냈지만 ‘실제 자원’보다 작은 리소스를 사용합니다. 또한, CI에서 실제 서비스를 띄우는 과정이 없어 테스트 속도를 최적화할 수 있습니다.

2) 성공해야 하는 테스트일지라도 만약 외부 서비스가 예기치 못한 결과를 반환하면 실패할 수 있습니다.

  • 외부 서비스는 외부 프로세스로 동작하면서 예기치 못한 결과를 반환할 수 있습니다. (ex — 5xx error)그와 달리 테스트 코드와 같은 프로세스에서 동작합니다. 즉, 외부 요인에 영향을 받지 않아 Mock은 Input에 따른 결과가 일정합니다.

3) 외부 서비스를 사용하여 모든 가능한 성공과 실패 시나리오를 테스트하는 것은 어렵습니다.

  • 실제 외부 서비스의 장애를 내기는 쉽지 않습니다. (ex — 데이터베이스 디스크 용량 부족 문제) 하지만, Mock을 사용한다면 이러한 케이스를 쉽게 만들 수 있습니다.

∴ 외부 서비스에 의존하기보다는, 이러한 의존성을 “mock” 으로 대체할 수 있습니다.

mocking 101

pytest에서 mocking을 사용하는 방법을 간단히 설명하겠습니다. pytest에서 mock을 사용할 때 pytest-mock 플러그인을 사용합니다.

pytest-mock: fixture에 대한 mock을 지원하는 pytest 플러그인입니다.

테스트 코드에서 pytest-mock의 patch 함수를 통해 기존의 메소드(or 클래스)에서 사용하는 코드가 mocking된 코드를 호출하게 만들 수 있습니다.

* mocker는 python-mock을 설치하면 따로 import 하지 않고 사용할 수 있습니다.
  • set_new_method라는 fixture를 다른 테스트 코드에서 사용했을 때, 기존의 old_method는 테스트 코드에서 new_method로 동작합니다.

coroutine test code 101

pytest에서 coroutine 코드를 테스트하는 방법을 간단히 설명하겠습니다. pytest에서 coroutine 코드를 테스트할 때pytest-asyncio 플러그인을 사용합니다.

pytest-asyncio 사용법

플러그인을 통해 coroutine 테스트 코드를 처리할 수 있습니다.

오늘의 헤드라인에서 사용하는 주요 서비스와 라이브러리

오늘의 헤드라인팀에서 다루는 mock을 설명하기 위해 주요 서비스와 라이브러리를 간단하게 소개하겠습니다.

Dynamo DB: AWS의 빠르고 유연한 NoSQL 데이터베이스 서비스입니다.

Elasticache: AWS의 인 메모리 데이터 저장소 및 캐시 서비스입니다. RedisMemcached를 지원합니다. 오늘의 헤드라인팀은 그 중 Redis 자원을 사용합니다.

Kafka: 분산처리에 효과적으로 설계된 메시지 브로커입니다.

dynamodb, redis, kafka를 mock 사용법과 mocking하는 과정에서 겪었던 삽질들에 대한 이야기를 시작하겠습니다.

(note)이번 예제에서 사용할 Python 라이브러리와 프레임워크를 정리했습니다.

# for test
pytest(6.1.2) # python의 단위테스트 프레임워크입니다.
moto[dynamo](1.3.16) # 대괄호 안에 있는 AWS자원을 mock하는 용도로 사용합니다.
pytest-mock(3.3.1) # test에서 사용하는 자원을 mock처리 할 때 사용합니다.
pytest-asyncio(0.14.0) # coroutine 테스트 코드를 실행하는 용도로 사용합니다.
fakeredis(1.4.4) # redis 자원을 mock하는 용도로 사용합니다.
# for production
aioboto3(8.0.5) # AWS의 sdk인 boto3에 coroutine을 적용한 라이브러리입니다.
aioredis(1.3.1) # redis client의 coroutine을 적용한 redis client입니다.
confluent-kafka(1.5.0) # python kafka client입니다.

Dynamodb mock

오늘의 헤드라인 팀에서는 AWS자원(aioboto3)에 mock을 지원하는 moto 라이브러리를 사용하여 dynamodb를 mocking합니다.

aioboto3란?

AWS의 sdk인 boto3에 coroutine을 적용한 라이브러리입니다.

Dynamodb test 예제

dynamodb table( id, title, author, published_at)이 있고, 간단한 서비스 (book_id로 정보를 가져오는 시스템)를 테스트 하겠습니다.

다음은 테이블의 id를 이용해 query를 보내는 소스 코드를 작성했습니다.

book.py

테스트를 위해 moto를 사용해서 production 테이블을 mocking 합니다.

conftest.py

이 코드가 잘 동작하는지 확인하는 테스트 코드를 작성하겠습니다.

* fixture 데이터는 분리해서 관리하는 게 좋지만, 설명을 위해 테스트 코드 안에 작성했습니다.

test_book.py

삽질 1: aioboto3 mocking하기

오늘의 헤드라인 서버에서 dynamodb에서 데이터를 빠르게 가져오기 위해 bulk query 사용합니다. 실제 코드에서는 정상 동작하지만, moto에서 bulk query를 사용할 때 aiobotocore에 이슈가 있습니다.

aiobotocore란?

aioboto3보다 low level의 영역을 다루는 coroutine AWS SDK입니다.

moto에서 mocking된 dynamodb에 bulk query 응답을 받는 과정에서 botocore에 정의된 대로 AWSResponse.raw_header에 접근을 합니다. 하지만, aiobotocore가 botocore와는 다르게 AWSResponse.raw_header가 존재하지 않아 발생한 이슈입니다.

* 이 이슈는 aiobotocore 1.0.4 버전 기준으로 에러가 발생합니다. 자세한 내용은 aiobotocore 라이브러리 이슈 링크에서 확인 할 수 있습니다.

해당 이슈를 우회하기 위해 다음과 같은 코드를 작성했습니다.

custom_mock.py

이슈가 존재하는 메서드를 patch합니다.

conftest.py

위 방법으로 라이브러리 이슈를 해결할 수 있습니다.

Redis mock

오늘의 헤드라인은 Elasticache의 Redis를 사용합니다. redis client로는 aioredis를 사용했습니다. aioredis는 python redis client의 coroutine redis client 입니다. moto는 Elasticache를 지원하지 않습니다. Redis 자원의 mocking을 지원하는 fakeredis라는 라이브러리를 이용했습니다.

삽질 2: aioredis mocking하기

aioredis를 mocking하기 위해서는 아래와 같은 작업을 거쳐야 합니다.

fakeredis 1.4.4버전 기준으로 파라미터 없이 pool을 생성하지만, aioredis는 address라는 파라미터가 필요합니다. 그래서 moto의 이슈처럼 aioredis의 pool을 mocking하기위한 메서드를 작성했습니다.

custom_mock.py

이제 conftest.py에서 aioredis 메서드 create_redis_pool을 custom_create_redis_pool로 patch합니다.

conftest.py

Kafka mock

삽질 3: kafka mocking하기

kafka는 인기있는 message broker지만, python에서 mocking을 할 수 있는 좋은 use case를 찾지 못했습니다. 그래서 Kafka의 message broker기능을 간단히 mocking하는 방법으로 문제를 해결하고자 했습니다.

* message broker에는 메세지를 생성하는 Producer생성된 메시지를 소비하는 Consumer가 있습니다.

kafka에 메시지를 전송하기 위한 기존 코드는 다음과 같습니다.

kafka.py

KafkaProducer를 간단히 흉내 내기 위한 Mock Class(KafkaProducerMock)를 작성하겠습니다.

KafkaProducerMock은 다음과 같이 설계됐습니다.

  • _producer: 메시지의 데이터가 정상적으로 전송됐는지 확인하기 위해 kafka에 전송된 데이터를 저장하기 위해 queue(list)로 구현했습니다.
  • produce(self, data): KafkaProducer는 데이터를 전송하는 메서드지만, Mock은 _producer에 데이터를 추가하는 메서드입니다.
  • close(self): KafkaProducer의 연결을 종료하는 메서드지만, Mock에서 데이터를 초기화하는 메서드입니다.
custom_mock.py

KafkaProducer를 KafkaProcuerMock으로 patch 합니다.

conftest.py

Kafka client가 제대로 작동하는지 확인하기 위한 테스트 코드를 작성했습니다.

test_kafka_util.py

위에서 정의한 queue를 통해 어떤 데이터가 들어갔는지 테스트할 수 있습니다.

테스트 실행 속도가 느릴 때 꿀팁

테스트 코드의 수가 늘어날수록 테스트 소요 시간이 길어집니다.

프로젝트의 테스트 코드를 순차적으로 실행할 때,평균 1분 12초 정도가 걸렸습니다. 테스트 코드를 매번 실행할 때마다 걸리는 시간은 생산성을 낮춘다고 판단했습니다. 그래서 이문제를 해결하기 위해 코드들을 병렬로 실행하는 전략을 시도했습니다.

테스트 코드를 병렬로 실행하는 방법에 대해 리서치를 했습니다.

그 중 pytest-xdist, pytest-parallel의 선택지가 있었습니다.

pytest-xdist: 대형 오픈소스에서 많이 채용되는 pytest 병렬처리 플러그인입니다.

pytest-parallel: pytest-xdist가 Thread safe하지 않았음을 지적했지만, coroutine에 코드를 실행할 때 이슈가 있었고, stable 버전이 제공되지 않아 선택에서 제외했습니다.

pytest-xdist를 사용하여 아래의 실행 환경 기준으로 1분 12초의 테스트 코트 실행 시간이 12초로 단축됐습니다.

결론

  • 외부 서비스와 결합하는 부분에서 mocking을 사용해야 했고 쉽지는 않았지만 오늘의 헤드라인 서버팀은 해결할 수 있습니다.
  • 테스트 코드를 github action에 연동하여 CI/CD 작업까지 끝내니 API의 안정성을 얻을 수 있었다.

궁금한 내용을 댓글에 올려주시면 친절하게 답변하겠습니다.

긴 글 읽어주셔서 감사합니다.

오늘의 헤드라인 팀에서 함께할 ML 엔지니어를 찾습니다!

  • 오늘의 헤드라인은 머신러닝 및 딥러닝 기술이 핵심인 뉴스 추천 서비스입니다.
  • 오늘의 헤드라인 ML/추천 팀은 머신러닝 및 데이터 기술을 적극적으로 활용하여 서비스에 적용하고 있습니다.

함께 대한민국 국민들의 뉴스 소비 습관을 혁신하고 싶은 분 연락주세요.

오늘의 헤드라인 ML 엔지니어 채용 공고

Reference

--

--