파이썬 테스트 코드 작성하기
테스트 코드는 왜 필요할까
그동안 이론적인 이야기는 많이 들었던 것 같다. 취준때는 남들 다 짜니까 했던것도 있고, 어쨌든 필요성에 대해 공감을 깊게 하지는 못했던 것 같다. 뜬구름 잡는 느낌이랄까?
챗gpt를 보면 다른 사람들이 만든 gpt를 공유하는 기능이 있다. 데이터를 미리 넣어놓을수도 있고, 프롬프트를 작성해서 일정한 양식대로 대답하게 할 수도 있다. 이번에 내가 만들게 된 기능이 gpt를 공유하고, 다운로드 할 수 있는 일종의 gpt 스토어 기능이다. 이제 개발 막바지에 접어드는데, 테스트 환경이 비교적 느슨하게 구축되어 있었다.
그러다보니 너무 힘들었던 점들이 기존 api 수정건들과 신규 api 개발건이 섞여있는데 어디서, 언제 내가 짠 코드들이 말썽을 일으킬지 도저히 감이 안왔다.
지금 코드 멀쩡히 잘 돌아가면 테스트코드 굳이 필요한가 생각이 들기도 했는데, 엔터프라이즈 레벨에서는 아닌 것 같다.
이것만 개발하고 어디 지구 씨앗 박물관 같은곳에 소스코드 그대로 보관할거 아닌이상, 계속 신규개발과 유지보수가 이루어질테고 그때마다 폭탄돌리기는 계속될 것이라 생각했다.
내가 지금 팀과 회사에 정말 좋은 점 중 하나가 내 자율성을 최대한 보장해준다. 그 덕에 회사 리소스로 이것저것 해보고 싶은거 다 해봤던 것 같은데, 일단 본론으로 돌아오면 테스트 실행 환경을 조금 더 고도화 해보기로 했다.
사실 나도 파이썬으로 테스트 코드는 처음 짜봐서 많이 헤매기도 했는데, 어차피 기조는 비슷해서 환경을 어느정도 잡은 후부터는 테스트 커버리지를 올리기 위해 케이스 작성 중이다. 이제 코드 얘기를 해보자.
환경을 구축해보자
일단 가장 먼저 고민을 했던 부분이 테스트DB를 따로 구축할지, 데이터 모킹을 할지 정말 많이 고민했다. 결론부터 말하자면 테스트DB를 따로 하나 구축했다.
우리는 몽고DB를 사용중이고, mongomock같은 테스트 전용 메모리 DB를 사용할까 고민도 했다. 내가 우리 서비스에 대한 코드 이해도가 더 높아지면 스택이 달라질 수도 있겠지만, 지금은 기존 api + 데이터들이 어떤식으로 유기적으로 움직이는지 파악이 잘 안된다.
우선순위를 좀 정해봤는데, 지금 나에게 더 중요한게 안정적으로 작동하는 환경을 담보하는 것인지, 리소스 사용량을 낮추고 프로젝트에 대한 이해도를 높일 것인지 골라보라고 하면 전자로 마음이 많이 기울었던 것 같다.
그리고 우리가 ai 어시스턴트나 외부 스토리지 연동같은 의존성들이 있다보니 이게 실제로 리소스 생성이나 배정이 잘 이루어지고 있는지, DB랑 매핑이 잘 되는지 여부도 중요했기에 테스트 DB를 구축해서 최대한 유사한 환경을 만들어보려고 노력했다.
테스트 DB 데이터 구축은 사실 크게 고민은 안했고, 그냥 잘 작동하는 개발 서버 DB 데이터를 다 덤프 떠왔다. 대충 코드를 살펴보자면 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import dotenv
from pymongo import MongoClient
def copy_database(source_db, test_db):
collections = source_db.list_collection_names()
for collection_name in collections:
source_collection = source_db[collection_name]
test_collection = test_db[collection_name]
for doc in source_collection.find(batch_size=100):
test_collection.insert_one(doc)
print(doc)
print(f"컬렉션 '{collection_name}'이 복사되었습니다.")
print("모든 컬렉션 복사가 완료되었습니다.")
# # Docker Compose 명령어 실행 (docker-compose up -d)
# def run_docker_compose():
# try:
# print("DB 덤프를 위해 도커가 필요합니다. 도커가 작동하지 않는다면 먼저 docker-desktop을 켜주세요")
# result = subprocess.run(["docker-compose", "up", "-d"], check=True, capture_output=True, text=True)
# print(f"Docker Compose output: {result.stdout}")
# except subprocess.CalledProcessError as e:
# print(f"Error running Docker Compose: {e.stderr}")
if __name__ == "__main__":
# run_docker_compose()
dotenv.load_dotenv("개발파일")
test_client = MongoClient("커넥션스트링")
source_client = MongoClient("커넥션스트링")
print("원격 데이터베이스 목록:")
for i in source_client.list_database_names():
print(i)
table_name = input("복사할 데이터베이스 이름을 입력해 주세요. 확인되는 데이터베이스는 위와 같습니다.").strip()
source = source_client[table_name]
test = test_client[table_name + "_TEST"]
# if local.list_collection_names():
# print("데이터베이스에 기존 데이터가 존재합니다.")
# should_clear = input("초기화하시겠습니까? (y/n): ").strip().lower()
# if should_clear == 'y':
# for collection_name in local.list_collection_names():
# local[collection_name].drop()
# print("데이터베이스 초기화 완료.")
# else:
# print("데이터베이스를 유지합니다. id값 등이 중복되면 에러가 발생할 수 있습니다")
# 데이터 복사
print("데이터 복사를 시작합니다...")
copy_database(source_db=source, test_db=test)
source_client.close()
test_client.close()
이상적인건 각자의 개발 환경에 최대한 영향이 없게 쉘스크립트 같은걸로 작성해주면 좋긴 한데, 시간도 별로 없고 어차피 다들 파이썬 깔려 있어서 그냥 파이썬으로 작성했다. 로컬 작업도 종종 해서 도커 띄우는 코드도 좀 넣었는데 생각보다 그다지 사용 안해서 주석쳐놨고, DB 초기화 코드도 쓰다보니 괜히 후달려서 주석 쳐놨다.
단위? 통합?
이미 DB가 붙어서 이게 정확한 유닛 테스트가 맞는지는 모르겠지만, 단순히 메서드 단위의 작은 검증을 단위 테스트라고 한정짓는다면 단위 테스트의 커버리지를 올리기에는 시간이 좀 걸릴 것 같다. 지금 약간 왔다갔다 중이댜. 통합 테스트 쭉 짜다가 가능해보이면 단위 테스트도 짜고 될 수 있는대로 짜는 중이다.
가끔 인터넷에서 듣던 ‘테스트 가능한 코드를 작성해야 한다’는 말에 공감을 잘 못했던 것 같다. 개인 공부할때는 계속 스프링으로 코드를 작성했는데, 스프링의 큰 장점 중 하나가 레이어를 어느정도 강제해주는 효과가 있는 것 같다. 그 탓인지 테스트하는데 큰 어려움이 없었고, 그다지 큰 공감을 하지 못했던 것 같은데 이번에 제법 절실히 느끼고 있다.
결국 코드의 책임이나 역할이 잘 분리가 되어 있으면 테스트가 용이할 것 같은데, 워낙 바쁜 일정들이다 보니 어쩔 도리가 없던 것 같다. 시간적 여유가 조금씩 생길 때 이런 기술 부채들을 하나씩 해결해 나가면 재밌을 것 같다. 나만 재밌으려나?
conftest, pytest
파이썬은 pytest를 주로 많이 쓰는 것 같길래 사용해봤다. conftest라는 파일이 초기 설정파일로 쓰이는 것 같다. 테스트도 접두사로 test_ 이런식으로 네이밍 룰이 있던데 이름으로 제약을 두는 것 같다. 우리 기존 코드가 Azure key vaults라는 키값 저장소랑 굉장히 강한 의존성을 이루고 있어서 이걸 어떻게 분리해야하나 고민을 많이 했다. aws에서는 KMS라는 이름으로 많이 불리는 것 같다. 키 매니지먼트 서비스인듯 하다.
의존성을 오버라이드해야 하나 싶었는데 pytest에서 fixture라는걸 설정해주니까 테스트에서는 이 값들로 덮어씌워지는 것 같았다. 그런데 자꾸 db관련 비동기 루프가 다르다고 에러가 자꾸 났다. 이걸 어떻게 잘 결합해줘야하나 고민했는데, 현재 루프로 덮어씌우는 기능을 지원하길래 강제로 넣어버렸다. 어차피 테스트니까 별 상관 없을 것 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
@pytest_asyncio.fixture(loop_scope="session", autouse=True)
async def config_mock_mongo():
client = AsyncIOMotorClient("스트링스트링")
client.get_io_loop = asyncio.get_running_loop # 루프 끼워넣기
await init_beanie(
database=client["컬렉컬렉"],
document_models=["모델모델"],
)
yield client
client.close()
그 외에도 자주 쓰이는 값들을 편하게 사용하고 싶어서 몇 개 더 정의를 해봤다. loop_scope는 생명주기 관련 선언인듯 하고, function으로 하면 당연히 함수 종료되면 리소스 정리 코드도 실행되리라 기대하고 넣었다. 되겠지??
1
2
3
4
5
6
7
8
@pytest_asyncio.fixture(loop_scope="function")
async def saved_dummy_뭐시기뭐시기():
fake_id = PydanticObjectId("111111111111111111111111")
gpt = await 수없이 길고 긴 생성자...
yield gpt
await gpt.delete()
문제는 동일한 객체가 아니라 새로운걸 여러개 생성해봐야 하는 테스트들이 있어서 계속 고민중이다. 일단은 리소스 정리 책임은 개발자가 잘 하는걸로 떠넘겼는데 분명히 실수할 가능성이 매우매우 높아서 어떻게 해야 할지 고민이다.
1
2
3
4
5
6
7
# 사용 시 리소스 정리 잘해주세요
async def make_dummy_뭐시기뭐시기() -> 반환형:
fake_id = PydanticObjectId("111111111111111111111111")
gpt = await 수없이 길고 긴 생성자...
return gpt
그 외에는 설정값들을 pytest.ini 파일에 기입해줬다. 이것도 인식시키려먼 pytest-env라는걸 설치해줘야 하더라. 굳이 별도의 .env파일로 빼지 않은 이유는 경로 설정이 미흡했는지 커맨드로 실행하면 인식 못하고 ide 통하면 인식하고 중구난방이라 빼버렸다. pytest.ini를 통해서 환경변수들을 넣어주니 안정적으로 작동했다.
1
2
3
4
5
6
7
8
# pytest.ini
[pytest]
env =
# 설정값들...
asyncio_mode=auto
addopts = -p no:warnings
# 기타 경로 설정 등등..
설치해야 하는 의존 라이브러리들은 다음과 같다.
1
pip install pytest-asyncio pytest-env
이거는 병렬 실행을 원한다면 설치하면 좋다. cpu 가용량 파악해서 알아서 잘 돌려주는 것 같다. 그냥 pytest로 돌리니까 좀 느리긴 하더라.
1
2
pip install pytest-xdist
pytest -n auto
남은 과제는?
지금은 내가 이리저리 테스트를 작성중인데, 솔직히 나 혼자 다 짜기에는 너무 많긴 하다. 그리고 앞으로 신규 피쳐를 개발할 팀원들이 같이 협력해줘야 이게 잘 유지가 될 것 같은데, 이런 문화나 기조를 어떻게 만들고 이어가야 할 지 고민이다. 테스트 말고도 사실 누군가의 눈으로 보면 과제인 것들이 많이 남아있긴 한데, 나한테는 약간 게임 스테이지 깨는 기분이랄까? 해볼만한 것들이 많이 보여서 기대되고 재밌긴 하다. 당분간은 재밌는 일들이 좀 많을 것 같다.