매일공부

[Django/pytest] 테스트 작성할 때 다시 볼 정리 — mock의 함정과 뭘 테스트해야 하나 본문

IT/작업기록

[Django/pytest] 테스트 작성할 때 다시 볼 정리 — mock의 함정과 뭘 테스트해야 하나

julian 2026. 9. 1. 16:27

9개 앱에 테스트를 새로 추가하면서 겪은 시행착오와, "애초에 뭘 테스트해야 하는가"에 대한 정리. 나중에 또 pytest를 추가하거나 고칠 때 이 글을 보면 될 것 같다.

1부 — mock으로 Django 내부까지 흉내내려다 걸린 것들

실제 DB에 접속하지 않고 테스트하려고, DB 대신 쓸 "가짜 대역" 객체를 쓴다.

from unittest.mock import MagicMock

fake_user = MagicMock()  # "User인 척"하는 가짜 객체

fake_user는 아무 속성이나 접근해도 에러 없이 또 다른 가짜 객체를 돌려준다. 편하지만 문제는, 진짜 Django 객체가 갖고 있는 내부 속성까지는 안 갖고 있다는 것.

문제 1 — 폼(Form): __init__을 건너뛰면 내부 속성이 통째로 빔

form = IssueForm.__new__(IssueForm)  # __init__을 아예 안 부르고 객체만 만듦
form.add_error("brand", "브랜드는 필수입니다")  # 여기서 에러
AttributeError: 'IssueForm' object has no attribute 'error_class'

error_class, renderer 같은 속성은 Django의 Form.__init__() 안에서 설정된다. __new__로 __init__을 건너뛰면 이런 속성들이 애초에 안 만들어진다. add_error()는 내부적으로 self.error_class를 쓰는데, 그게 없어서 에러가 난 것.

처음엔 그 속성을 하나씩 채워 넣었는데, 채워도 그다음 속성(_bound_fields_cache 등)이 또 없어서 계속 에러 — 두더지 잡기가 됐다.

최종 해결: __new__로 우회하지 말고, 그냥 진짜로 폼을 생성했다.

form = IssueForm()  # __init__을 정상적으로 실행 → 필요한 속성 다 자동으로 채워짐
form.cleaned_data = {"brand": None, ...}  # 검증할 값만 나중에 주입
form.add_error("brand", "브랜드는 필수입니다")  # 정상 작동

IssueForm()을 그냥 만들어도 화면에 값이 바인딩 안 된 상태(unbound)라서 DB 쿼리는 안 나간다. "DB는 안 건드리면서 Django가 필요로 하는 진짜 구조는 다 갖춘" 객체가 되는 것.

문제 2 — 모델 FK: 가짜 객체엔 _state가 없다

assignment = IssueAssignment()
assignment.assigned_to = MagicMock(spec=User)  # "User처럼 보이는" 가짜 객체
assignment.clean()  # 여기서 에러
AttributeError: Mock object has no attribute '_state'

Django는 FK에 값을 대입할 때, 내부적으로 "이 객체가 지금 연결하려는 DB와 같은 DB에 있는 게 맞나?"를 확인한다. 이때 그 객체의 _state.db라는 속성을 들여다본다. _state는 진짜 모델 인스턴스에만 자동으로 붙는 속성이라, MagicMock(spec=User)처럼 "User 흉내만 내는" 가짜 객체한테는 없다.

_state를 수동으로 채워 넣어봤지만, 이번엔 그 안의 db 값이 실제 상황과 안 맞아서 "이 관계는 허용 안 됨"이라는 또 다른 에러(ValueError)가 났다.

최종 해결: 가짜 객체 대신 저장은 안 한 진짜 객체를 썼다.

real_user = User()  # 진짜 User 인스턴스 (저장은 안 함 = .save() 호출 안 함)
assignment.assigned_to = real_user
assignment.clean()  # 정상 작동

User()는 그냥 메모리 위의 파이썬 객체다 — .save()를 호출해야 진짜 DB에 INSERT가 되는데, 이건 안 부르니 DB는 여전히 안 건드린다. 그런데 진짜 User 클래스로 만들었기 때문에 _state 같은 Django 내부 속성은 전부 정상적으로 갖고 있다.

문제 3 — Prefetch가 가짜 쿼리셋을 거부

Prefetch(queryset=가짜쿼리셋)를 썼는데, Django가 그 쿼리셋이 진짜 클래스인지 확인하려다(issubclass()) 에러가 났다. 이건 진짜 객체로 바꾸기보다, Prefetch 자체를 통째로 mock으로 교체해서 그 검증 자체를 우회하는 게 더 간단했다.

공통 패턴과 결론

세 문제 모두 같은 패턴이었다.

가짜 객체(mock)로 값만 흉내 내려 했는데, Django가 내부적으로 "진짜 객체인지" 확인하는 코드(속성 검사, _state 검사 등)에 걸렸다.

해결 방향도 같았다.

"가짜 객체를 점점 정교하게 다듬기"보다, "저장만 안 한 진짜 객체를 쓰기"가 더 안전하고 짧다. Model(), Form()처럼 그냥 만들기만 하고 .save()나 바인딩을 안 하면, DB는 안 건드리면서도 Django 내부 구조는 전부 정상이다.

CI 통과까지 3번 왕복했던 이유

  1. 로컬에서 문법/포맷만 확인 (py_compile, black) → 통과. 이건 "글자가 맞다"만 보는 거지 "실행해보면 안 터진다"는 보장이 아니다.
  2. 1차 push → 컨테이너에서 실제로 pytest 실행 → 다수 실패. 처음엔 정확한 에러 메시지가 짧게 잘려서 안 보였다.
  3. 트레이스백 없이 추측으로 수정 → 일부는 코드를 잘 추적해서 맞혔지만, 일부(폼/모델 관련)는 추측이 틀렸다.
  4. 2차 push → 틀린 부분이 다시 실패. 실제 에러 메시지(트레이스백)를 받아서 정확한 원인을 확인.
  5. 3차 수정 & push → "진짜 객체 쓰기"로 근본적으로 바꾸고 나서야 전부 통과.

교훈: "이럴 것 같다"는 추측으로 고치지 말고, 실제 에러 메시지(트레이스백)를 먼저 확보한 뒤 고쳐야 한다. 추측했다가 틀리면 왕복이 그만큼 늘어난다.

2부 — 애초에 뭘 테스트해야 하나

"테스트"라고 뭉뚱그려 말했지만, 실제로는 앱마다 성격이 다른 걸 검증하고 있었다. 유형별로 정리하면:

유형 1 — 이 값 조합이면 에러가 나야 한다 (폼/모델 검증)

가장 많은 유형. 사용자가 이상한 값을 입력했을 때 화면에서 제대로 막아주는지 확인한다.

form = CustomerForm(data={"user_state": "휴면", "category": ""})  # category가 비어있는 상태
form.is_valid()
assert "category" in form.errors  # 비어있으면 에러가 떠야 정상

확인하려는 건 "코드가 짜여진 그대로 작동하는가"가 아니라, "나중에 누가 이 검증 로직(if문)을 실수로 지우거나 바꾸면, 테스트가 바로 알려주는가"다.

유형 2 — 권한 없는 사람이 시도하면 막혀야 한다 (RBAC/권한 분기)

"작성자 본인 / admin / 그 외 제3자"처럼 갈래를 나눠서 각각 확인한다.

assert can_edit_comment(comment, author) is True   # 작성자 본인 → 가능
assert can_edit_comment(comment, admin_user) is True  # admin → 가능
assert can_edit_comment(comment, other_user) is False  # 제3자 → 불가

이런 코드가 깨지면 "권한 없는 사람이 남의 데이터를 수정할 수 있게 되는" 심각한 문제로 이어지니, 갈래를 다 확인해둔다.

유형 3 — 예전에 실제로 났던 버그가 다시 생기지 않는가 (회귀 테스트)

이번에 실제로 고친 버그를 콕 집어서, "이 버그가 미래에 또 생기면 테스트가 바로 잡아내도록" 만드는 것.

  • 소프트 삭제(soft delete) 시 트리거가 이력을 중복으로 남기던 버그를 고쳤다면 → "삭제하면 이력이 정확히 1번만 저장되는지" 테스트로 박아둔다
  • 이력 테이블 필드의 타입/길이가 실수로 잘못 지정됐던 버그를 고쳤다면 → "이력 테이블들의 식별자 필드 타입·길이가 원본 테이블과 정확히 일치하는지" 검증한다

즉 "새 기능 검증"이 아니라, "과거에 한 번 터졌던 문제가 조용히 부활하는 걸 막는 안전망"이다.

유형 4 — 삭제하면 연쇄적으로 막혀야 하는 것들이 잘 막히는가 (참조 무결성)

연결된 하위 데이터가 있는 상위 데이터를 삭제하려고 할 때, 제대로 막히는지 확인한다.

# 견적서가 연결된 고객은 삭제 시도하면 막혀야 함
customer_with_quotation.delete()  # 조용히 성공하면 안 됨 — 에러가 나거나 차단돼야 함

유형 5 — 관리 명령어가 의도한 결과를 만드는가

권한 그룹을 자동으로 만들어주는 관리 명령어 같은 것도, "실행하면 정확히 의도한 결과가 생기는지"를 확인한다.

유형 6 — 집계/계산 로직이 맞는가

대시보드의 KPI 집계, 매출 계산처럼 숫자를 더하고 필터링하는 로직도 별도로 확인한다. 로직이 복잡할수록 사람이 눈으로 화면 보고 검증하기 어려우니, "이 데이터를 넣으면 이 숫자가 나와야 한다"를 테스트로 고정해둔다.

왜 "제네릭 CRUD"는 뺐나

단순히 목록을 보여주기만 하는 화면(오버라이드 없는 ListView 등)은 테스트에서 제외했다. 이런 화면은 Django가 이미 검증된 기본 기능만 쓰고 있어서, 거기엔 "우리가 새로 짠 로직" 자체가 없다 — 테스트할 대상이 없는 곳이다. 반대로 위 6가지 유형은 전부 "이 프로젝트만의 규칙이 들어간 곳"이라, 그 규칙이 깨지면 알아챌 수 있는 안전망이 필요했다.

정리 — 다음에 pytest를 추가/수정할 때 체크리스트

  1. 뭘 테스트할지 고를 때: 단순 CRUD는 건너뛰고, clean()/save() 오버라이드, 폼 검증, 권한 분기, 참조 무결성, 관리 명령어, 집계 로직 중심으로 고른다
  2. 가짜 객체가 필요할 때: mock의 속성을 하나씩 채워 넣기 전에, "저장 안 한 진짜 객체(Model(), Form())로 대체할 수 있는가"부터 먼저 검토한다
  3. CI가 실패했을 때: 실패 목록만 보고 추측해서 고치지 말고, 실제 트레이스백부터 확보한다
  4. 버그를 고쳤을 때: 그 버그가 재발하지 않는지 확인하는 회귀 테스트를 같이 추가한다
Comments