- DB
- 네이버부스트캠프
- Ai
- 빅분기
- boostcourse
- 코딩테스트 python
- Oracle
- unsub
- boostcoures
- PY4E
- 빅데이터분석기사
- python
- SQL
- AI 플랫폼을 활용한 데이터 분석
- 부스트코스
- 기초다지기
- 정보처리기사
- 코딩테스트
- Django
- [멀티잇]데이터 시각화&분석 취업캠프(Python)
- 오라클
- 데이터 분석 기반 에너지 운영 관리자 양성 및 취업과정
- 파이썬
- 이것이 취업을 위한 코딩테스트다 with 파이썬
- 데이터베이스
- 빅데이터 분석 기반 에너지 운영 관리자 양성 및 취업과정
- 프로그래머스
- 이기적
- r
- 인공지능기초다지기
- Today
- Total
매일공부
[Django/pytest] 테스트 작성할 때 다시 볼 정리 — mock의 함정과 뭘 테스트해야 하나 본문
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번 왕복했던 이유
- 로컬에서 문법/포맷만 확인 (py_compile, black) → 통과. 이건 "글자가 맞다"만 보는 거지 "실행해보면 안 터진다"는 보장이 아니다.
- 1차 push → 컨테이너에서 실제로 pytest 실행 → 다수 실패. 처음엔 정확한 에러 메시지가 짧게 잘려서 안 보였다.
- 트레이스백 없이 추측으로 수정 → 일부는 코드를 잘 추적해서 맞혔지만, 일부(폼/모델 관련)는 추측이 틀렸다.
- 2차 push → 틀린 부분이 다시 실패. 실제 에러 메시지(트레이스백)를 받아서 정확한 원인을 확인.
- 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를 추가/수정할 때 체크리스트
- 뭘 테스트할지 고를 때: 단순 CRUD는 건너뛰고, clean()/save() 오버라이드, 폼 검증, 권한 분기, 참조 무결성, 관리 명령어, 집계 로직 중심으로 고른다
- 가짜 객체가 필요할 때: mock의 속성을 하나씩 채워 넣기 전에, "저장 안 한 진짜 객체(Model(), Form())로 대체할 수 있는가"부터 먼저 검토한다
- CI가 실패했을 때: 실패 목록만 보고 추측해서 고치지 말고, 실제 트레이스백부터 확보한다
- 버그를 고쳤을 때: 그 버그가 재발하지 않는지 확인하는 회귀 테스트를 같이 추가한다
'IT > 작업기록' 카테고리의 다른 글
| "빈 DB에만 터지는" Django 마이그레이션 버그 — 트리거 SQL 파일을 잘못 읽고 있었다 (0) | 2026.09.03 |
|---|---|
| 포커스 도둑 — 첫 클릭만 씹히던 이상한 버그 (0) | 2026.09.02 |
| Django(Python)와 Node.js/NestJS, 뭐가 다를까 — 스택 전환을 고민하다 미룬 이유 (0) | 2026.09.01 |
| black은 왜 훅에 없을까 — 포매터와 검증 도구는 다르다 (0) | 2026.08.31 |
| 발송 이력과 장애 격리: "부가 기능의 실패가 핵심 기능을 끌고 내려가지 않게" (0) | 2026.08.31 |