pytest import error, file mismatch 해결법

3줄 요약
- 같은 이름의 테스트 파일이 두 개 생기면 그 파일만 실패하는 것이 아니라 수집 자체가 중단됩니다. pytest 9.1.1로 재현하니 파일 3개에 오류 2건, exit 2, 실행된 테스트 0건이었습니다.
- 처방은
__init__.py와--import-mode=importlib두 가지이고 수집 시간 차이는 없었습니다(테스트 300개 기준 0.03초 대 0.04초). 갈리는 지점은 테스트 디렉터리 안에 헬퍼 모듈을 두고 있는지입니다. - 지금 저장소에서 중복 파일명 탐지 스크립트를 한 번 돌려 보시면 됩니다. 아직 안 터졌더라도 파일 하나만 더 추가되면 그날 CI가 멈춥니다.
"리드님, 저는 파일 하나만 추가했는데 왜 테스트가 0개 돌았다고 나오죠?"
팀원이 결제 API 테스트를 웹 쪽에도 붙이려고 tests/api/test_order.py를 복사해 tests/web/test_order.py로 만들었습니다. 로컬에서 pytest tests/web으로 확인하고 올렸으니 통과였습니다. CI는 빨간불이 됐고 로그에는 import file mismatch라는 처음 보는 문장이 떠 있었습니다.
이상한 건 실패 개수였습니다. 새로 추가한 파일 하나가 아니라 스위트 전체가 실행되지 않았습니다. 그날 저는 이 오류를 파일 이름 문제로 읽었고, 그 때문에 처방을 두 번 잘못 넣었습니다.
1. 조건별로 무엇을 고를지
먼저 정리해 두겠습니다. 아래 표의 조건은 뒤에서 전부 실측으로 확인했습니다.
| 우리 저장소 상황 | 고를 것 | 이유 |
|---|---|---|
테스트 디렉터리에 헬퍼 모듈(tests/helpers.py 등)을 두고 테스트가 직접 임포트한다 |
__init__.py 전 계층 추가 |
importlib에서는 이 임포트가 통째로 깨집니다 |
| 테스트가 픽스처만 쓰고 서로를 임포트하지 않는다 | --import-mode=importlib |
파일을 만들지 않고, sys.path도 건드리지 않습니다 |
테스트 디렉터리 이름이 앱 패키지 이름과 겹친다(tests/api vs src/api) |
어느 쪽이든 되지만 __init__.py는 최상위까지 |
중간까지만 넣으면 앱 패키지를 가로챕니다 |
| 테스트 파일이 20개 미만이고 계층도 얕다 | 파일명 유일화 | 규약 하나로 끝나고 배울 것이 없습니다 |
--pyargs로 설치된 패키지의 테스트를 돌린다 |
--import-mode=append 검토 |
로컬 사본이 아니라 설치본을 집습니다 |
수집 성능은 판단 근거에서 빼십시오. 뒤에 실측을 붙였지만 차이가 측정 오차 안에 있었습니다.
2. 오류 한 줄이 스위트 전체를 세웁니다
재현 환경과 트리
Python 3.10.12, pytest 9.1.1로 재현했습니다. pytest 9.1.1은 PyPI JSON API의 upload_time_iso_8601 기준 2026-06-19T10:58:31Z, 한국 시간 2026-06-19 19:58에 올라온 버전입니다. 트리는 실무에서 흔한 모양으로 만들었습니다.
exp1/
├── pyproject.toml # [tool.pytest.ini_options] testpaths = ["tests"]
├── src/shop/core.py
└── tests/
├── api/test_order.py # __init__.py 없음
├── web/test_order.py # 같은 파일명
└── mobile/test_order.py # 같은 파일명
__init__.py를 하나도 넣지 않은 것이 요점입니다. 빈 __init__.py를 두지 않는 쪽이 요즘 기본값처럼 되어 있고, 테스트 디렉터리는 특히 그렇습니다.
실행 결과
파일을 두 개만 둔 상태에서 pytest -q를 돌렸을 때 나온 출력입니다.
==================================== ERRORS ====================================
___________________ ERROR collecting tests/web/test_order.py ___________________
import file mismatch:
imported module 'test_order' has this __file__ attribute:
/tmp/exp1/tests/api/test_order.py
which is not the same as the test file we want to collect:
/tmp/exp1/tests/web/test_order.py
HINT: remove __pycache__ / .pyc files and/or use a unique basename for your test file modules
=========================== short test summary info ============================
ERROR tests/web/test_order.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.04s
Interrupted가 붙은 줄이 출발점입니다. 오류는 tests/web/test_order.py 한 파일에서 났는데, 정상 수집됐던 tests/api/test_order.py의 테스트도 실행되지 않았습니다. 파일 개수를 늘려 가며 세어 본 결과입니다.
| 같은 이름 파일 수 | 수집 오류 | 실행된 테스트 | exit code |
|---|---|---|---|
| 1개 | 0건 | 1건 | 0 |
| 2개 | 1건 | 0건 | 2 |
| 3개 | 2건 | 0건 | 2 |
3개 + --continue-on-collection-errors |
2건 | 1건 (3건 중) | 1 |
먼저 수집된 파일이 이름을 선점하고 나머지가 튕겨 나갑니다. 어느 파일이 선점하는지는 디렉터리를 훑는 순서로 결정되니, 폴더 이름을 api에서 zapi로 바꾸는 것만으로 살아남는 파일이 바뀝니다.
--continue-on-collection-errors가 더 위험한 이유
수집이 중단되니 이 옵션을 붙여 강행하려는 생각이 자연스럽게 듭니다. 저도 그때 그렇게 했습니다. 결과는 1 passed, 2 errors, exit 1이었습니다.
문제는 exit 1이 CI에서 읽히는 방식입니다. exit 2는 pytest가 시작조차 못 했다는 신호라 사람이 로그를 열어 봅니다. exit 1은 흔한 실패 신호라, 실패한 테스트 이름만 고치면 됐다고 판단하게 됩니다. 테스트 3건 중 2건이 실행 목록에 오르지도 못했다는 사실은 요약 줄 끝의 2 errors에만 남고, 실행되지 않은 테스트는 실패로 집계되지 않으니 커버리지 리포트에서도 조용히 빠집니다.
테스트가 흔적을 남기지 않고 사라지는 문제는 이 오류만의 것이 아닙니다. 잡을 나눠 돌릴 때 빈 그룹이 생기면 exit 5로 잡이 죽고, 무작위 순서 플러그인과 겹치면 테스트가 조용히 누락되는 것도 같은 계열입니다. 파이프라인을 잘라 붙일수록 돌지 않은 테스트를 세는 장치가 따로 필요해집니다.
3. 파일명이 아니라 패키지 루트 문제입니다
오류 메시지의 HINT는 __pycache__를 지우거나 파일 이름을 유일하게 만들라고 제안합니다. 뒤쪽은 확실히 통하지만, test_order_api.py, test_order_web.py처럼 디렉터리 이름을 파일 이름에 한 번 더 적게 되어 디렉터리 구조가 하는 일이 없어집니다. 원인을 알면 다른 길이 보입니다.
prepend 모드가 sys.path에 무엇을 넣나
pytest의 기본 임포트 모드는 prepend입니다. 테스트 파일이 있는 디렉터리를 sys.path 맨 앞에 끼워 넣고 모듈을 임포트합니다. 실제로 몇 개가 들어가는지 conftest에서 찍어 봤습니다.
# tests/conftest.py — 수집이 끝난 시점에 프로젝트 경로만 골라 셉니다
import sys
def pytest_collection_modifyitems(items):
mine = [p for p in sys.path if p.startswith("/tmp/exp2")]
print(f"\n[프로젝트 경로 {len(mine)}개]", mine)
for it in items:
print(" ", it.nodeid, "->", it.module.__name__)
테스트 디렉터리 12개, 테스트 함수 12개인 저장소에서 돌린 결과입니다.
| 임포트 모드 | sys.path에 들어간 프로젝트 경로 |
|---|---|
prepend (기본) |
14개 |
importlib |
1개 |
14개는 테스트 디렉터리 12개에 tests/와 최상위 디렉터리를 더한 값입니다. importlib에서 남은 1개는 python -m pytest 호출이 현재 디렉터리를 넣는 몫이고 pytest가 넣은 것이 아닙니다. 테스트 디렉터리가 하나 늘 때마다 sys.path 항목도 하나 늘어난다고 보면 됩니다.
경로가 늘어나는 것 자체는 문제가 아닙니다. 그 디렉터리가 sys.path의 맨 앞에 들어간다는 것이 문제입니다. tests/api가 검색 1순위가 되면 그 안의 이름이 다른 무엇보다 먼저 잡힙니다.
모듈 이름 세 가지
같은 파일이 배치에 따라 어떤 이름으로 임포트되는지 찍어 봤습니다. 이것이 충돌의 실체입니다.
| 배치 | tests/api/test_order.py의 모듈 이름 |
충돌하는가 |
|---|---|---|
__init__.py 없음, prepend |
test_order |
예 |
tests/api/__init__.py만 있음 |
api.test_order |
아니요 |
tests/__init__.py까지 있음 |
tests.api.test_order |
아니요 |
__init__.py 없음, importlib |
tests.api.test_order |
아니요 |
__init__.py가 없을 때 모듈 이름이 test_order가 됩니다. 디렉터리 정보가 통째로 사라진 것입니다. 파이썬은 임포트한 모듈을 sys.modules에 이름으로 저장하니, 두 번째 파일이 test_order를 찾았을 때 이미 다른 파일이 앉아 있습니다. pytest는 그 모듈의 __file__이 지금 수집하려는 파일과 다르다는 것을 발견하고 오류를 냅니다.
그러니 이건 파일 이름이 겹쳐서 난 오류가 아니라 모듈 이름이 디렉터리를 반영하지 못해서 난 오류입니다. 처방 두 가지가 모두 통하는 이유도 여기서 나옵니다. 표의 아래 세 줄은 방법이 다를 뿐, 모듈 이름에 디렉터리를 되돌려 놓는 같은 일을 합니다.
중간까지만 넣으면 안 고쳐집니다
tests/에 하나 넣으면 그 아래가 전부 패키지가 될 것 같지만 아닙니다. tests/__init__.py만 만들고 하위 디렉터리에는 넣지 않은 상태로 돌리니 같은 오류에 exit 2가 그대로 나왔습니다.
pytest는 테스트 파일이 있는 디렉터리부터 위로 올라가며 __init__.py가 끊기지 않고 이어지는 마지막 디렉터리를 패키지 루트로 잡고, 끊기면 거기서 멈춥니다. 테스트 파일과 같은 디렉터리에 __init__.py가 없으면 위쪽에 몇 개를 넣어도 그 파일은 패키지 밖입니다. 넣을 거면 테스트 파일이 있는 모든 디렉터리부터 최상위까지 빠짐없이 넣어야 합니다.
4. __init__.py 처방이 앱 패키지를 가로채는 일
여기가 제가 두 번째로 잘못 넣은 지점입니다. 하위 디렉터리에 __init__.py를 다 넣었고 오류는 사라졌는데, 다른 테스트가 깨졌습니다.
tests/api가 api가 되는 순간
앞의 표에서 tests/api/__init__.py만 있을 때 모듈 이름이 api.test_order였습니다. 패키지 루트가 tests/이므로 tests/가 sys.path 앞에 들어가고, 그 안의 api가 최상위 패키지 이름이 됩니다. 앱에도 api 패키지가 있으면 이름이 정면으로 부딪힙니다.
# src/api/__init__.py 에 표식을 넣고, tests/api/test_models.py 에서 확인합니다
import api
def test_which_api_package():
print("[api.__file__]", api.__file__)
print("[api.NAME]", getattr(api, "NAME", "<없음>"))
from api.models import order_total # 앱 코드
assert order_total([1, 2]) == 3
pythonpath = ["src"]를 설정해 앱 코드를 올려 둔 상태에서 돌린 결과입니다.
[api.__file__] /tmp/exp3/tests/api/__init__.py
[api.NAME] <없음>
E ModuleNotFoundError: No module named 'api.models'
1 failed in 0.01s
import api가 성공했는데 그 api는 앱 패키지가 아니라 테스트 디렉터리였습니다. 앱 패키지가 가려졌으니 api.models는 없는 모듈이 됩니다. ModuleNotFoundError가 앱 코드 이름으로 나오니 처음 보면 경로 설정이 잘못된 줄로 읽히는데, 실제 원인은 몇 분 전에 오류를 없애려고 만든 빈 파일입니다.
두 처방 모두 이것을 고칩니다
같은 저장소에 두 가지를 각각 적용해 봤습니다. tests/__init__.py를 추가해 패키지 루트를 최상위로 올린 경우와, __init__.py를 전부 지우고 --import-mode=importlib로 돌린 경우입니다. 양쪽 다 api.__file__이 /tmp/exp3/src/api/__init__.py를 가리키고 테스트가 통과했습니다.
이름이 겹치는 디렉터리는 api, models, core, utils처럼 짧고 일반적인 것들입니다. 앱과 테스트가 같은 도메인을 다루니 이름이 닮는 것은 자연스럽고, 그래서 이 충돌은 우연이 아니라 시간 문제입니다.
5. importlib이 받아 가는 비용
importlib 모드가 깔끔해 보이지만 대가가 있습니다. 공식 문서에도 단점이 명시돼 있고, 실제로 걸려 봐야 크기를 알 수 있습니다.
tests/helpers.py를 임포트할 수 없습니다
테스트 디렉터리에 공용 헬퍼를 두고 쓰는 저장소가 많습니다. tests/helpers.py를 만들고 tests/api/test_order.py에서 from helpers import make_order로 가져오게 한 뒤 두 모드로 돌렸습니다.
# --import-mode=importlib
ImportError while importing test module '/tmp/exp1/tests/api/test_order.py'.
Hint: make sure your test modules/packages have valid Python names.
E ModuleNotFoundError: No module named 'helpers'
# 기본 prepend
[sys.path 중 프로젝트 경로] ['/tmp/exp1/tests/api', '/tmp/exp1/tests', '/tmp/exp1']
1 passed in 0.00s
prepend에서 이 임포트가 통했던 이유가 위 줄에 보입니다. tests/가 sys.path에 올라가 있었기 때문입니다. sys.path를 건드리지 않는 것이 importlib의 장점인데, 그 장점이 곧 이 임포트가 깨지는 이유입니다. 우회할 여지가 없습니다.
픽스처는 옮길 수 있지만 유틸은 앱 코드로 가야 합니다
공식 문서는 헬퍼를 앱 코드 쪽으로 옮기라고 권합니다(app.testing.helpers 같은 위치). 픽스처라면 conftest.py로 옮기면 되니 부담이 적습니다. tests/, tests/api/, tests/web/에 conftest.py를 하나씩 두고 확인해 보니 세 개가 모두 conftest라는 같은 이름으로 등록되면서도 오류 없이 각자 픽스처를 제공했습니다. conftest.py는 일반 테스트 모듈과 다른 경로로 읽힌다는 뜻입니다.
문제는 픽스처가 아닌 유틸입니다. 테스트 데이터 팩토리, 비교 함수, 더미 서버가 여기 해당합니다. 앱 코드로 옮기면 프로덕션 패키지에 테스트 전용 모듈이 섞여 배포 산출물에서 뺄지 말지를 다시 정해야 합니다. conftest.py에 다 밀어 넣는 것도 답이 아닙니다. 세션 범위 픽스처가 워커마다 다시 실행되는 문제를 겪어 보면, conftest.py가 무거워질수록 병렬 실행 비용이 어디서 새는지 알기 어려워집니다.
수집 시간은 판단 근거가 아닙니다
importlib이 sys.path를 안 건드리니 더 빠를 것 같아 재 봤습니다. 테스트 파일 60개, 테스트 함수 300개짜리 저장소에서 --collect-only를 3회씩 돌린 값입니다.
| 모드 | 1회 | 2회 | 3회 | pytest 자체 집계 |
|---|---|---|---|---|
prepend |
0.105초 | 0.103초 | 0.102초 | 0.03초 |
importlib |
0.104초 | 0.103초 | 0.113초 | 0.04초 |
차이가 없습니다. 세 번째 값의 흔들림이 두 모드의 차이보다 큽니다. 성능을 근거로 고르지 마십시오. 고를 근거는 헬퍼 모듈을 어디에 둘 것인가 하나입니다.
기본값은 바뀌지 않습니다
한때 importlib이 미래의 기본값이 될 예정이었기 때문에 어차피 바뀔 테니 미리 옮기자는 판단이 가능했습니다. 지금은 그 근거가 없습니다. pytest 공식 문서는 importlib에도 단점이 있는 것이 분명해졌으므로 기본값이 당분간 prepend로 남는다고 적어 두었습니다(2026-09-07 확인). pytest 9.1.1의 --help가 내놓는 값도 {prepend, append, importlib} 세 개 그대로입니다.
importlib으로 옮기는 것은 대세를 따르는 일이 아니라 저장소 사정에 맞춰 내리는 선택입니다. 뒤집을 수도 있다는 전제로 결정하시면 됩니다.
세 처방의 비용
| 항목 | __init__.py 전 계층 |
--import-mode=importlib |
파일명 유일화 |
|---|---|---|---|
| 도입 난이도 | 빈 파일 생성. 다만 어디까지 넣을지 규칙을 알아야 함 | 설정 한 줄 | 기존 파일 이름 일괄 변경 |
| 유지보수 비용 | 디렉터리 추가마다 파일 하나. 빠뜨리면 같은 오류 재발 | 헬퍼 배치 규칙을 계속 지켜야 함 | 이름이 길어지고 디렉터리와 중복 |
| 팀 학습곡선 | 패키지 루트 개념을 알아야 함. 절반만 알면 4장의 충돌을 만듦 | "테스트끼리 임포트 금지" 한 줄로 전달 가능 | 없음 |
| 앱 패키지 충돌 | 최상위까지 넣지 않으면 발생 | 발생하지 않음 | 발생하지 않음 |
| 되돌리기 | 파일 삭제로 즉시 | 설정 한 줄 삭제. 헬퍼 이전은 남음 | 이름 되돌리기 필요 |
6. 오늘 저장소에서 할 것
아직 안 터진 충돌을 먼저 찾습니다
이 오류는 파일이 추가되는 순간 터지니 지금 초록불이라고 안전한 것이 아닙니다. __init__.py가 없는 디렉터리의 테스트 파일만 모아 이름 충돌을 세는 스크립트를 CI 스텝으로 넣어 두면 됩니다.
# check_test_basenames.py — 중복이 있으면 exit 1
import collections, pathlib, sys
root = pathlib.Path(".")
seen = collections.defaultdict(list)
for p in root.rglob("test_*.py"):
if "__pycache__" in p.parts:
continue
if (p.parent / "__init__.py").exists(): # 패키지 안이면 충돌하지 않습니다
continue
seen[p.name].append(str(p))
bad = {k: v for k, v in seen.items() if len(v) > 1}
for name, paths in sorted(bad.items()):
print(f"충돌 {name} ({len(paths)}개)")
for x in sorted(paths):
print(" ", x)
print("충돌 파일명", len(bad), "종")
sys.exit(1 if bad else 0)
앞의 재현 저장소에서 돌리니 충돌 test_order.py (3개)와 경로 세 줄, 충돌 파일명 1 종, exit 1이 나왔습니다. __init__.py가 있는 디렉터리를 건너뛰는 줄이 중요합니다. 이미 패키지로 만든 곳은 이름이 겹쳐도 문제가 없으니, 그것까지 세면 고칠 것 없는 경고가 쌓입니다.
확인 순서
□ 위 스크립트를 돌려 충돌 파일명이 몇 종인지 센다
□ tests/ 아래 디렉터리 이름과 앱 패키지 최상위 이름이 겹치는지 대조한다
→ 겹치면 __init__.py 를 넣더라도 반드시 최상위까지 넣는다
□ 테스트가 tests/ 안의 모듈을 직접 임포트하는 곳을 grep 한다
→ 있으면 importlib 은 고르지 않는다
□ 정한 모드를 pyproject.toml 에 적어 로컬과 CI 를 맞춘다
□ 스크립트를 CI 스텝으로 넣어 다음 충돌은 사람 대신 기계가 찾게 한다
이 글에서 확인하지 못한 것
오류 메시지의 HINT는 __pycache__ 삭제를 먼저 제안하는데, 제 재현 범위에서는 그 경로를 만들지 못했습니다. 디렉터리 이름만 바꿔 .pyc가 따라 이동한 경우와 심볼릭 링크로 같은 파일을 두 경로에서 지정한 경우를 모두 시도했지만 pytest 9.1.1은 오류 없이 통과시켰습니다. 제가 만난 오류는 전부 파일명 충돌 쪽이었고, __pycache__가 원인인 사례는 확인된 근거가 없습니다. 오래된 pytest에서 넘어오는 저장소라면 다를 수 있으니, 두 제안 중 어느 쪽인지는 __pycache__를 지우고 한 번 돌려 보는 것으로 가릅니다.
수집 시간의 절대값은 컨테이너 환경(Python 3.10.12, 2코어)에서 나온 것이라 각자 환경에서 다시 재셔야 합니다. 다만 두 모드의 차이가 오차 안에 있다는 결론은 규모를 키워도 뒤집히기 어렵습니다.
댓글
댓글 쓰기