목차
개요
최근에 작성한 포스팅과 연계되는 내용입니다. 포스팅에 작성했던 내용은 pytest를 사용하면서 자주 사용했던 내용들을 올렸습니다.
이번 포스팅은 pytest를 도입하면서 테스트 코드를 어떤 식으로 적용 해나갔는지에 대한 내용을 다룬 글입니다.
어떻게 도입할까?
사내에서 개발된 기능은 개발된 페이지와 API를 직접 호출해봄으로써 테스트 중인 상황이다. 이러한 상황에서 테스트 코드를 작성하고 도입해야한다. 테스트 코드를 작성한 경험이 개인적인 프로젝트를 제외하고는 거의 없었기에 사내 프로젝트에서 테스트 코드를 작성하는 방법에 대해 고민하게 되었다.
프로젝트 어떤 코드에 대한 구조를 도입하는 것은 상호간 통용되는 유지보수나 협업 관점에서 고려해야 할 사항이라고 생각한다. 이에 대한 생각을 기반으로 어떤 밑그림을 가지고 시작할지 고민하다 "파이썬으로 살펴보는 아키텍처 패턴"이라는 책에서 봤던 테스트 피라미드가 생각났다.
이 방법은 unittest, integration, e2e 순으로 계층을 이루는데 이렇게 테스트 코드를 작성하면 코드 베이스에서 표현력이 생긴다.
이에 따라 다음과 같은 구조로 디렉토리를 생성했다.
├── src
│ ├── __init__.py
│ ├── application
│ ├── config
│ └── manage.py
├── tests
│ ├── __init__.py
│ ├── conftest.py
│ └── unit
│ └── integration
│ └── e2e
Makefile도 추가하자.
이에 더해 Makefile도 사용했다. "Makefile"은 소프트웨어 빌드 도구인 Make에서 사용되는 파일 형식이다. Makefile은 빌드 프로세스의 세부 사항을 지정하고, 프로젝트의 소스 코드 및 종속성에 대한 정보를 제공하여 프로젝트를 빌드하는 데 사용된다.
├── src
│ ├── __init__.py
│ ├── application
│ ├── config
│ └── manage.py
├── tests
│ ├── __init__.py
│ ├── conftest.py
│ └── unit
│ └── integration
│ └── e2e
├── Makefile # add
Makefile에서는 커맨드 셋을 미리 정의하여 각각의 커맨드를 수행할 수 있도록 했다. 예를 들어, "make test"와 같은 커맨드를 실행하면, 미리 정의된 "test" 커맨드 셋이 실행되어 해당 프로젝트의 테스트를 실행한다. 이렇게 미리 정의된 커맨드 셋을 사용하면 테스트를 실행하는 데 필요한 복잡한 명령어를 일일이 입력하지 않아도 된다.
# Makefile
test: test.unit test.integration test.e2e
test.e2e:
pytest ./tests/src/e2e
test.integration:
pytest ./tests/src/integration
test.unit:
pytest ./tests/src/unit
모든 계층에 테스트 코드를 작성해야되나?
일단 프로세스를 구성하고 나서, "테스트 비용"에 대한 고민을 하게 되었다. "비용"이라는 용어는 여러 의미를 내포하고 있지만, 여기서는 "테스트의 실행 속도와 완료 속도"를 주요 관심사로 삼았다.
모든 계층에 대해 테스트 코드를 작성하려면 작업 속도가 느려질 수 있으므로, E2E 테스트 코드부터 작성하여 천천히 구체적인 테스트 코드로 발전시켜가는 방식으로 접근했다.
E2E 테스트로 시작하기
API 서버이기에 E2E 테스트를 먼저 작성하는 것을 목표로 했다. E2E 테스트가 어색한 의미를 가질 수도 있겠다는 생각이 들었다.
어떤 자료들은 E2E 테스트를 UI 테스트라고도 설명하기 때문이다 사용자 인터페이스를 의미하는 UI 용어 때문에 브라우저에서 어떤 화면을 보여줄 것인가에 대한 관점으로 해석할 수도 있다. 그러나 BackEnd에서는 Controller에 대한 관점에서 e2e 테스트를 바라봐야 할 것 같다.
API는 클라이언트와 통신하기 위해 Request를 받아서 Response를 반환하는 것이므로 사용자 인터페이스와는 조금 다른 면이 있다. 하지만 API의 Controller(Django의 View)가 응답하는 데이터는 실제 사용자에게 보여질 데이터이기에 E2E라고 봐도 무리는 없다.
Test 코드 뼈대
처음 E2E에 대해 작성된 테스트 코드는 다음과 같은 형태를 띄우고 있었다.
# Example
class TestSomeView:
def test_요청성공(self, client):
# Request & Response
response = client.get(path="/api/test",data={})
response_content = response.json()
# Assert
assert response_content['status'] == 200
어떤 URI에 Request를 하면 이 Response의 status를 통해 Request가 잘 성공했는지를 테스트하는 코드이다.
사실 이 예제는 API 테스팅 튜토리얼에서 나오는 아주 대표적인 코드이기 때문에 이렇게만 작성해 나가면 아주 쉽게 갈 수 있겠지만 다른 문제를 맞이하게 된다.
사전 조건이 필요한데?
위 예시에서 작성한 코드는 요청하기 전에 어떤 사실도 가정하지 않으므로, 요청을 보내기 전에 어떤 조건을 설정할 필요가 없습다. 그러나 만약 특정 API가 "미리 가입된 사용자에 대한 데이터"를 기반으로 Response를 반환하는 경우엔 테스트 코드를 작성하는 방법에 대해 고민해야한다.
fixture를 사용하면 테스트 코드를 실행하기 전과 후에 원하는 동작을 수행할 수 있다. 이렇게 fixture를 정의하고 테스트 코드를 작성하면 다음과 같은 형태의 테스트 코드가 만들어진다.
@pytest.fixture
def setup_user(client):
response = client.post(path='/api/signup', data={'userid':'','password':''}
response_content = response.json()
yield response
response = client.delete(path='/api/signout', HTTP_ACCESS_TOKEN=access_token)
class TestSomeView:
def test_some_request(self, client, setup_user, get_session):
...
response = client.get(path="/api/user/profile", HTTP_ACCESS_TOKEN=get_session)
...
이 방식은 미리 개발된 API를 호출하므로 속도가 느릴 뿐만 아니라 fixture를 사용하기 위한 API가 다른 사전 조건을 필요로 하게 된다면 fixture가 무거워지기도 하고, 불필요한 쿼리의 발생과 이를 매번 호출하면서 드는 비용 때문에 상당한 성능 저하가 발생할 가능성도 있으므로 Bad Case이다.
또한 이 방식으로 fixture를 만들어나가면 테스트 코드에 fixture가 덕지덕지 붙어져 있는 다소 장황한 테스트 코드가 만들어질 수 있다.
XUnit 스타일로 사전조건 설정하기
미리 만들어둔 API를 테스트 코드에서 재사용함으로써 발생하는 비용을 줄이기 위해 고민하던 과정에서 Pytest Document를 통해 테스트 코드에서 필요로 하는 데이터만을 정리해서 다음과 같이 정리하기 시작했다.
class TestSomeView:
def setup_method(self):
# 데이터 생성
# 데이터 조건 설정
def test_some_method(self):
# API 호출
# API 입력 데이터 설정
# API 출력 데이터 검증
def teardown_method(self):
# 생성된 데이터 삭제
위와 같은 문제를 해결하기 위해, 특정 View의 테스트 코드의 함수가 실행되기 전과 후로 필요한 데이터를 명시적으로 정의하고, 테스트가 끝나면 해당 데이터를 삭제하는 작업을 "setup_method"와 "teardown_method"를 이용하여 구현했다.
"setup_method"와 "teardown_method"는 각각 함수가 실행되기 전과 후에 호출되는 함수로, 필요한 데이터를 초기화하거나 삭제하는 등의 작업을 수행할 수 있다.
어떤 경우에는 test_some_method() 메서드에서 애플리케이션 외부로 요청을 보내야 하는 경우가 있다. 예를 들어 Third Party API에 요청을 보내 결과를 받아와야 하는 경우다.
이러한 경우 mocking 처리를 통해 필요한 데이터를 가상으로 생성하여 테스트를 수행한다.
class TestSomeView:
...
def test_some_method(self, client, mock):
# mocking
mock.patch("", return_value={})
# API 호출
# API 입력 데이터 설정
# API 출력 데이터 검증
...
더 빨라졌을까?
fixture와 사전조건에 대한 코드를 개선하지 않고 수행 시간을 확인했을 때는 대략 65s ~71s 정도가 나왔다.
fixture와 사전조건에 대한 코드를 개선해
CASE | Before | After |
CASE-001 | 2.90s | 1.18s |
CASE-002 | 2.55s | 0.6s |
CASE-003 | 2.87s | 1.23s |
CASE-004 | 3.14s | 1.38s |
CASE-005 | 6.20s | 2.94s |
CASE-006 | 7.55s | 3.54s |
CASE-007 | 4s | 2s |
CASE-008 | 7s | 5.6s |
TOTAL | 36.21s | 18.47s |
위 표에 기록된 결과만 확인했을 때는 대략 각 테스트 케이스마다 절반 가량 수행 시간이 줄었기 때문에 개선 작업을 하고 나면 수행 시간이 절반 가량 줄었을 거라 예상했는데 작업을 거친 후의 수행시간은 대략 다음과 같다.
기대했던 수치는 아니지만 그래도 18.5% 정도의 개선이 있었다. mocking 처리를 함으로써 외부 시스템을 호출할 때 발생하는 Warning(예: Deprecated)도 사라졌다.
요약하면, 테스트를 수행할 때, 불필요한 쿼리를 줄이고 외부 시스템에 의존하는 경우에는 mocking을 사용, 테스트 케이스에서 필요한 데이터만 미리 설정해두어 테스트를 개선할 수 있습니다.
아직 고민이 필요한 부분 : '무엇'을 테스트 해야할까?
테스트 코드를 작성하고 수행시키면서 개선해 나갈 수 있는 부분(불필요한 쿼리 개선, Mocking 등)들에 대해서는 어느 정도 혼자 처리가 가능하지만 고민해 볼 여지가 있는 부분은 어느 범위까지 테스트를 수행해야 하는지를 결정하는 것이었다.
예를 들어 페이지네이션 기능이 적용되어 있는 API에 대한 테스트는 다음과 같은 항목들을 생각해 볼 수 있다.
1. 정렬 기능이 잘 작동하나?
2. 필터링 기능이 잘 작동하나?
3. 필요로 하는 데이터를 목록에 포함시키고 있는가?
1번만 하더라도 테스트 코드를 작성하게 되면 다음과 같은 코드가 나와버린다.
@pytest.mark.parametrize(
'sort_key',
[
'sort_key_1','sort_key_2','sort_key_3','sort_key_4',
'sort_key_5','sort_key_6','sort_key_7','sort_key_8'
]
)
def test_some_list_view(self, client, sort_key):
...
설정된 정렬키가 제대로 동작하는지 확인한다는 측면에서는 잘못된 게 아니라고 생각하지만 이런 방식의 테스트 코드는 이 케이스만 수행하는데 17 ~ 20s 정도가 수행되는 결과가 나온다.
특정 테스트 케이스가 어떤 동작을 수행할 것인지 명확하지 않은 경우, 스스로 생각할 수 있는 범위에 대한 무궁무진한 테스트를 만들기 때문에 제대로 테스트하고 있는 건가라는 여지가 남기도 한다.
아직 고민이 필요한 부분 : 작업 시간
테스트 코드를 도입하면서 테스트 코드 작성에 더 많은 시간이 소요된다면 이는 효율적인 방법일까 생각해본다.
프로젝트의 계층 구조에 따라 테스트 코드를 작성해야 하므로 작업 시간이 더 많이 드는 것은 사실이지만 어떤 테스트를 작성해야 하는지를 결정하여 작업 시간을 효율적으로 사용하는 것이 중요한 포인트인 것 같다.
P.S
글또 8기를 활동을 하고 있는 중인데 글또 안에서도 "데일리회고"라는 활동을 하고 있습니다. 데일리 회고를 통해 오늘 작성한 부분들에 대해 다른 개발자분들과 의견을 나눌 수 있는 시간을 가질 수 있었습니다.
이 중에서도 테스트 코드를 개선해 나가는 과정 중 예성님께서 알려주신 클린 코드에서의 테스트 코드 이론이 많은 도움이 되었습니다.
예성님께 감사를 전합니다.
'개발 노트 > Experience' 카테고리의 다른 글
[Experience] wkhtmltopdf 사용으로 인해 발생한 SSTI 해결하기 (8) | 2024.09.02 |
---|---|
[AWS] SESv2를 이용한 수신측 메일 열람여부 확인하기 (0) | 2024.06.03 |
tradingview.com로 알아보는 Websocket 데이터를 수집하는 방법 (1) | 2023.05.15 |
Flask Scheduling이 2번 실행되는 현상에 대해 (0) | 2023.03.04 |
Insecure XMLHttpRequest EndPoint (0) | 2022.07.11 |