잘못된 설정은 배포가 아니라
시작 순간에 걸러야 한다
두 개의 테스트가 여덟 가지 잘못된 값을 확인한다. 모두 “조용히 통과하면 나중에 진단하기 어려운” 종류다.
이 파일을 만든 이유
설정 오류는 증상이 늦게 나타난다. APP_INFERENCE_BASE_URL=http://127.0.0.1:8080/v1처럼 /v1을 붙여 두면 서버는 정상적으로 뜨고, /healthz도 200을 돌려준다. 문제는 실제 생성 요청이 /v1/v1/chat/completions로 나갈 때에야 드러난다. 그때 보이는 것은 404 하나뿐이라 원인이 설정이라고 짐작하기 어렵다.
이 파일은 그런 값들을 객체를 만드는 순간 거부하게 만들어 둔 뒤, 그 거부가 실제로 일어나는지 확인한다. 검증 규칙 자체는 config.py의 field_validator와 Field 제약에 있다.
전체 코드
import math
import pytest
from pydantic import ValidationError
from app.core.config import Settings
def test_inference_url_must_be_a_server_root() -> None:
invalid_urls = (
"http://127.0.0.1:8080/v1",
"http://user:password@127.0.0.1:8080",
"http://127.0.0.1:8080?model=gemma",
"http://127.0.0.1:8080#health",
)
for url in invalid_urls:
with pytest.raises(ValidationError):
Settings(_env_file=None, inference_base_url=url)
def test_inference_timeouts_are_positive_and_finite() -> None:
invalid_values = (0, -1, math.inf, math.nan)
for value in invalid_values:
with pytest.raises(ValidationError):
Settings(_env_file=None, inference_probe_timeout=value)
import 해설
import math
math.inf와 math.nan을 만들기 위해서다. 이 둘은 Python에서 정상적인 float이라 isinstance(value, float)를 통과한다. 그래서 별도로 막지 않으면 timeout 값으로 들어갈 수 있다.
import pytest
pytest.raises를 쓰기 위해서다. “예외가 나는 것이 정답”인 테스트를 표현하는 도구다.
from pydantic import ValidationError
Pydantic이 검증에 실패했을 때 던지는 예외 type이다. 어떤 예외든 나기만 하면 통과시키는 대신 type을 지정했다. 그래야 오타로 인한 NameError가 “검증이 동작했다”로 오해되지 않는다.
테스트 1 — URL은 서버 root여야 한다
def test_inference_url_must_be_a_server_root() -> None:
invalid_urls = (
"http://127.0.0.1:8080/v1",
"http://user:password@127.0.0.1:8080",
"http://127.0.0.1:8080?model=gemma",
"http://127.0.0.1:8080#health",
)
for url in invalid_urls:
with pytest.raises(ValidationError):
Settings(_env_file=None, inference_base_url=url)
| 거부하는 값 | 무엇이 문제인가 |
|---|---|
…:8080/v1 | 경로는 Adapter가 붙인다. 설정에도 있으면 /v1/v1/…로 중복된다 |
user:password@… | 자격 증명이 설정 문자열과 log에 남는다. 인증은 별도 계층의 일이다 |
…?model=gemma | model은 inference_model 설정이 담당한다. 두 곳에서 지정되면 어느 쪽이 이기는지 불분명해진다 |
…#health | fragment는 서버로 전송되지도 않는다. 적어 둔 사람의 의도와 실제 동작이 다르다 |
_env_file=None이 왜 필요한가
이것이 없으면 Settings()가 개발자 컴퓨터의 be/.env 파일을 읽는다. 그러면 .env가 있는 사람과 없는 사람의 테스트 결과가 달라진다. None을 주어 파일 읽기를 끄고, 오직 인수로 준 값만 검증 대상이 되게 한다.
with pytest.raises(ValidationError)
블록 안에서 그 예외가 반드시 나야 통과한다. 예외가 나지 않으면 Failed: DID NOT RAISE로 실패한다. 즉 “거부되어야 할 값이 통과했다”를 잡아낸다.
왜 for 반복인가
네 값이 모두 같은 규칙 하나(require_server_root)를 시험한다. 다만 주의할 점이 있다. 앞쪽 값에서 실패하면 뒤쪽 값은 실행되지 않아, 한 번의 실행으로는 어느 값까지 확인됐는지 알기 어렵다. pytest.mark.parametrize를 쓰면 값마다 별도 테스트로 갈라져 실패 지점이 바로 보인다. 지금은 값이 넷뿐이고 규칙이 하나라 단순한 반복을 택했다.
테스트 2 — timeout은 양수이고 유한해야 한다
def test_inference_timeouts_are_positive_and_finite() -> None:
invalid_values = (0, -1, math.inf, math.nan)
for value in invalid_values:
with pytest.raises(ValidationError):
Settings(_env_file=None, inference_probe_timeout=value)
| 거부하는 값 | 그대로 두면 생기는 일 |
|---|---|
0 | 요청이 시작하자마자 timeout으로 실패한다. 서버가 정상이어도 항상 실패 |
-1 | 음수 deadline은 의미가 없다. library에 따라 예외 또는 즉시 만료로 갈린다 |
math.inf | 영원히 기다린다. 추론 서버가 응답하지 않으면 연결이 풀에 잡힌 채 반환되지 않는다 |
math.nan | 모든 비교가 거짓이 된다. nan > 0도 nan < 0도 거짓이라 조건문 검사로는 못 잡는다 |
math.nan이 이 목록에 있는 이유가 중요하다. if timeout <= 0: raise 같은 손으로 쓴 검사는 nan을 그냥 통과시킨다. config.py가 Field(gt=0, allow_inf_nan=False)를 쓴 것은 이 함정을 피하기 위해서다.
세 timeout 중 inference_probe_timeout 하나만 시험한다. 셋이 같은 Field 제약을 공유하므로 대표 하나로 규칙이 붙어 있음을 확인하는 방식이다. 다만 이는 나머지 둘에 제약을 빠뜨려도 이 테스트는 통과한다는 뜻이기도 하다.
실제 실행 결과
cd be
.venv/bin/python -m pytest tests/test_config.py -v
tests/test_config.py::test_inference_url_must_be_a_server_root PASSED
tests/test_config.py::test_inference_timeouts_are_positive_and_finite PASSED
2 passed
거부되는 모습을 직접 보고 싶다면 예외 메시지를 출력해 본다.
cd be
.venv/bin/python -c "
from app.core.config import Settings
import math
for bad in ['http://127.0.0.1:8080/v1', 'http://127.0.0.1:8080#health']:
try:
Settings(_env_file=None, inference_base_url=bad)
except Exception as error:
print(bad, '->', error.errors()[0]['msg'])
try:
Settings(_env_file=None, inference_probe_timeout=math.nan)
except Exception as error:
print('nan ->', error.errors()[0]['msg'])
"
http://127.0.0.1:8080/v1 -> Value error, Use a server root URL without /v1, credentials, query or fragment
http://127.0.0.1:8080#health -> Value error, Use a server root URL without /v1, credentials, query or fragment
nan -> Input should be a finite number
앞의 두 메시지는 config.py에 사람이 적은 문장이고, 마지막은 Pydantic이 allow_inf_nan=False로부터 자동으로 만든 문장이다.
자주 발생하는 오류
| 증상 | 원인 | 진단 |
|---|---|---|
DID NOT RAISE | config.py에서 검증 규칙이 빠지거나 약해짐 | validator와 Field 제약을 확인한다. 테스트가 아니라 설정 코드가 잘못된 경우다 |
| 로컬에서만 실패 | _env_file=None을 빠뜨려 be/.env를 읽음 | 인수로 파일 읽기를 끈다 |
ValidationError가 아닌 다른 예외 | 값 자체가 잘못돼 Pydantic 이전에 터짐 | error.errors()를 출력해 어느 단계인지 확인한다 |
| 서버는 뜨는데 생성만 404 | /v1이 붙은 URL이 다른 경로로 들어감 | 환경변수·.env·compose 파일을 모두 확인한다. 이 테스트는 코드 경로만 지킨다 |
설계 선택과 대안
왜 설정 검증을 테스트까지 만들어 지키는가?
검증 규칙은 “당연해 보여서” 리팩터링 중에 지워지기 쉽다. 특히 field_validator는 호출하는 코드가 없어 참조를 찾아도 아무도 안 쓰는 것처럼 보인다. 테스트가 있으면 지웠을 때 즉시 빨간불이 켜진다.
올바른 값도 함께 시험해야 하지 않나?
정상 값은 나머지 45개 테스트가 전부 사용하므로 이미 간접적으로 검증된다. 이 파일은 거부해야 할 값에 집중한다. 다만 “http://127.0.0.1:8080과 http://127.0.0.1:8080/은 통과한다”는 긍정 사례를 한 줄 넣으면 규칙이 지나치게 엄격해지는 것도 막을 수 있다.
parametrize로 바꾸면 무엇이 좋아지나?
값마다 독립된 테스트가 되어 여덟 개의 이름이 생긴다. 하나가 깨져도 나머지는 계속 실행되고, 실패 목록만 봐도 어떤 값이 문제인지 알 수 있다. 값이 더 늘어난다면 바꿀 만하다.
이전 단계와 다음 파일
여기서 시험한 규칙의 정의는 config.py에 있다. 그 설정이 실제로 HTTP client와 Adapter로 흘러가는 과정은 main.py에서, 설정값이 응답에 나타나는지 확인하는 테스트는 test_health.py에서 이어진다.