본문으로 바로가기

테스트 코드 도입기

category 개발 노트/개발 삽질 2023. 3. 26. 03:44
728x90
반응형

 


개요

최근에 작성한 포스팅과 연계되는 내용입니다.  포스팅에 작성했던 내용은 pytest를 사용하면서 자주 사용했던 내용들을 올렸습니다. 이번에 포스팅할 내용은 pytest를 도입하면서 테스트 코드를 어떤 식으로 적용 해나갔는지에 대한 내용을 다룬 글입니다.

 

어떻게 도입할까?

테스트 코드를 작성한 경험이 개인적인 Code Kata를 제외하고는 거의 없었습니다. 또한 테스트 코드에 대한 밑그림이 없는 상태였기 때문에, Project에서 테스트 코드를 작성하는 방법에 대해 고민하게 되었습니다.

개인적으로 Project에 어떤 코드를 도입하는 것은 유지보수나 협업 관점에서 고려해야 할 사항입니다. 따라서 테스트 코드도 마찬가지이며, 어떤 밑그림을 가지고 시작할지 고민하다가 cosmic python에서 언급한 테스트 피라미드를 떠올렸습니다.


이 방법은 하단에서부터 unittest, integration, e2e 순으로 계층을 이루는데, 이렇게 테스트 코드를 작성하면 코드 베이스에서 표현력이 생기기 때문에 테스트 구조를 다음과 같이 구성하였습니다

├── src
│   ├── __init__.py
│   ├── application
│   ├── config
│   └── manage.py
├── tests
│   ├── __init__.py
│   ├── conftest.py
│   └── unit 
│   └── integration 
│   └── e2e

이에 더해 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가 덕지덕지 붙어져 있는 다소 장황한 테스트 코드가 만들어질 수 있습니다.


느려지기 시작하네

미리 만들어둔 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() 메서드에서 애플리케이션 외부로 요청을 보내야 하는 경우가 있습니다. 예를 들어, 특정 서비스의 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. 필요로 하는 데이터를 목록에 포함시키고 있는가?
4. 기타 등등

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기를 활동을 하고 있는 중인데 글또 안에서도 "데일리회고"라는 활동을 하고 있습니다. 데일리 회고를 통해 오늘 작성한 부분들에 대해 다른 개발자분들과 의견을 나눌 수 있는 시간을 가질 수 있었습니다.

이 중에서도 테스트 코드를 개선해 나가는 과정 중 예성님께서 알려주신 클린 코드에서의 테스트 코드 이론이 많은 도움이 되었습니다. 


예성님께 감사를 전합니다.

 

728x90
반응형