테스트 도구 실전 · 오류 응답의 회귀를 잡는 계약 테스트 · 이론
오류 응답의 회귀를 잡는 계약 테스트의 설계 원리
한 줄 요약
알려진 오류·알 수 없는 오류·비공개 정보·추적 헤더를 테스트합니다.
왜 이게 필요했나
오류 메시지 변경 뒤 모바일 앱의 재시도 로직이 망가졌다. 테스트는 예외가 발생했다는 사실만 확인했고 상태와 공개 본문의 의미는 비교하지 않았다. 내부 예외가 사용자에게 그대로 노출되는 회귀도 같은 틈을 통과했다.
어떻게 동작하나
정상 code와 unknown을 표로 만들고 status와 공개 문장을 비교한다. 요청 id는 길이 경계의 양쪽을 검사한다. 문제 본문의 키 집합과 Content-Type을 확인하고 실제 예외 경로에서 헤더와 본문이 같은 추적 값을 갖는지 관측한다.
학생 테스트 → 정상 구현: 실제 시험 모두 통과 └→ 계약 위반 구현: 해당 동작에서 실패수집 실패·0개 실행·강제 종료 ≠ 결함 검출계약을 읽고 실패를 예측하는 워크시트
다음은 구현을 통째로 외우는 답안이 아니라 단계별 코드 리뷰입니다. 각 변경 조각은 의도적으로 계약을 깨뜨립니다. 변경 후에도 정상 사례가 통과할 수 있다는 점에 주의하세요. 실행 전에 어느 입력·예외·상태를 관측하면 차이가 드러날지 예상하고, 구현 후에는 그 예상과 결과를 비교합니다.
1. 업무 예외에 code를 남긴다 — 테스트
제공된 service.py의 다음 공개 계약을 테스트하세요: DomainError(code, message)는 Exception 하위 클래스이며 .code에 code를 보관합니다. str(예외)는 message입니다. 정상 구현에서는 통과하고 이 계약을 어긴 구현에서는 실제 테스트 본문의 실패로 검출해야 합니다. 앞 단계 테스트를 유지하며 test_ 함수를 추가하세요.
판단의 근거: 기계가 판단할 code와 사람이 보는 내부 메시지를 분리합니다. 구현 파일은 수정하지 않습니다. pytest.raises로 예상 예외를 확인하고 정상 결과에는 구체적인 예상값을 단언하세요.
리뷰할 잘못된 변경 조각:
self.code = "invalid"이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
2. code를 상태로 매핑한다 — 테스트
제공된 service.py의 다음 공개 계약을 테스트하세요: status_for(code)는 missing=404, conflict=409, invalid=422, 그 외=500입니다. 정상 구현에서는 통과하고 이 계약을 어긴 구현에서는 실제 테스트 본문의 실패로 검출해야 합니다. 앞 단계 테스트를 유지하며 test_ 함수를 추가하세요.
판단의 근거: 알 수 없는 code를 성공으로 취급하지 않습니다. 구현 파일은 수정하지 않습니다. pytest.raises로 예상 예외를 확인하고 정상 결과에는 구체적인 예상값을 단언하세요.
리뷰할 잘못된 변경 조각:
.get(code, 200)이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
3. 공개 문장을 고정한다 — 테스트
제공된 service.py의 다음 공개 계약을 테스트하세요: public_message(code)는 missing='Resource not found', conflict='State conflict', invalid='Invalid request', 그 외='Internal error'입니다. 정상 구현에서는 통과하고 이 계약을 어긴 구현에서는 실제 테스트 본문의 실패로 검출해야 합니다. 앞 단계 테스트를 유지하며 test_ 함수를 추가하세요.
판단의 근거: 예외 문자열에 DB 주소나 내부 경로가 들어 있어도 공개하지 않습니다. 구현 파일은 수정하지 않습니다. pytest.raises로 예상 예외를 확인하고 정상 결과에는 구체적인 예상값을 단언하세요.
리뷰할 잘못된 변경 조각:
"invalid":"Internal error"이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
4. 요청 id를 제한한다 — 테스트
제공된 service.py의 다음 공개 계약을 테스트하세요: request_id(value)는 ASCII 영문·숫자·밑줄·하이픈으로만 이루어진 1~32자 문자열이면 그대로, 아니면 'untracked'입니다. 정상 구현에서는 통과하고 이 계약을 어긴 구현에서는 실제 테스트 본문의 실패로 검출해야 합니다. 앞 단계 테스트를 유지하며 test_ 함수를 추가하세요.
판단의 근거: 임의 헤더를 반사하지 않도록 길이와 문자 집합을 함께 제한합니다. 구현 파일은 수정하지 않습니다. pytest.raises로 예상 예외를 확인하고 정상 결과에는 구체적인 예상값을 단언하세요.
리뷰할 잘못된 변경 조각:
{1,64}이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
5. 문제 본문을 만든다 — 테스트
제공된 service.py의 다음 공개 계약을 테스트하세요: problem(code, rid)는 type='urn:labhub:problem:'+code, title와 detail=public_message(code), status=status_for(code), request_id=request_id(rid)만 가진 딕셔너리입니다. 정상 구현에서는 통과하고 이 계약을 어긴 구현에서는 실제 테스트 본문의 실패로 검출해야 합니다. 앞 단계 테스트를 유지하며 test_ 함수를 추가하세요.
판단의 근거: 본문과 HTTP 상태가 서로 다르면 클라이언트가 어느 값을 믿을지 결정할 수 없습니다. 구현 파일은 수정하지 않습니다. pytest.raises로 예상 예외를 확인하고 정상 결과에는 구체적인 예상값을 단언하세요.
리뷰할 잘못된 변경 조각:
"status":500이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
6. 응답 형식을 일관되게 만든다 — 테스트
제공된 service.py의 다음 공개 계약을 테스트하세요: response_for(code, rid)는 problem을 본문으로, status_for를 상태로, application/problem+json을 media_type으로, X-Request-ID를 정규화한 rid로 둔 JSONResponse입니다. 정상 구현에서는 통과하고 이 계약을 어긴 구현에서는 실제 테스트 본문의 실패로 검출해야 합니다. 앞 단계 테스트를 유지하며 test_ 함수를 추가하세요.
판단의 근거: 단순 딕셔너리 반환은 오류도 200으로 만들 수 있습니다. 구현 파일은 수정하지 않습니다. pytest.raises로 예상 예외를 확인하고 정상 결과에는 구체적인 예상값을 단언하세요.
리뷰할 잘못된 변경 조각:
media_type="application/json"이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
7. 업무 예외 핸들러를 연결한다 — 테스트
제공된 service.py의 다음 공개 계약을 테스트하세요: install_handlers(app)는 DomainError 핸들러를 등록합니다. 헤더 X-Request-ID를 읽고 exc.code에 대해 response_for를 반환합니다. exc의 message는 응답에 넣지 않습니다. 정상 구현에서는 통과하고 이 계약을 어긴 구현에서는 실제 테스트 본문의 실패로 검출해야 합니다. 앞 단계 테스트를 유지하며 test_ 함수를 추가하세요.
판단의 근거: 예외를 잡는 위치를 흩어 놓지 말고 앱의 공통 경계에 둡니다. 구현 파일은 수정하지 않습니다. pytest.raises로 예상 예외를 확인하고 정상 결과에는 구체적인 예상값을 단언하세요.
리뷰할 잘못된 변경 조각:
response_for("invalid", request.headers.get("x-request-id"))이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
8. 예상하지 못한 오류도 숨긴다 — 테스트
제공된 service.py의 다음 공개 계약을 테스트하세요: create_app()은 핸들러를 설치하고 GET /fail/{code}에서 DomainError(code, 내부문장)를 냅니다. 단 code=boom이면 RuntimeError를 냅니다. RuntimeError 핸들러는 code=internal인 고정 500 문제 응답을 반환합니다. 정상 구현에서는 통과하고 이 계약을 어긴 구현에서는 실제 테스트 본문의 실패로 검출해야 합니다. 앞 단계 테스트를 유지하며 test_ 함수를 추가하세요.
판단의 근거: 테스트에서 예외 재전파를 끄고 실제 500 응답의 바이트를 확인합니다. 구현 파일은 수정하지 않습니다. pytest.raises로 예상 예외를 확인하고 정상 결과에는 구체적인 예상값을 단언하세요.
리뷰할 잘못된 변경 조각:
response_for("invalid", request.headers.get("x-request-id"))이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
현장에서 만나는 모습
이 실습의 문제 응답은 type·title·status·detail·request_id를 갖는 교육용 계약이다. 범용 국제화와 전체 표준 적합성을 주장하지 않는다. request id는 추적에 쓰는 문자열이지 인증 수단이 아니며, 운영 로그에도 비밀값을 그대로 기록해서는 안 된다. 제공 구현은 읽어도 되지만 채점은 별도 사본을 사용한다. 소스 문구 검사나 파일 수정으로 결함을 우회하지 말고 공개 인터페이스의 실행 결과를 검사한다.
다음 실습에서 할 것
여덟 단계가 하나의 실행 가능한 결과물로 이어집니다. 업무 예외에 code를 남긴다 — 테스트 → code를 상태로 매핑한다 — 테스트 → 공개 문장을 고정한다 — 테스트 → 요청 id를 제한한다 — 테스트 → 문제 본문을 만든다 — 테스트 → 응답 형식을 일관되게 만든다 — 테스트 → 업무 예외 핸들러를 연결한다 — 테스트 → 예상하지 못한 오류도 숨긴다 — 테스트.
각 단계는 함수나 파일이 존재한다는 사실이 아니라 실제 반환값·예외·상태 변화를 검사합니다. 정답을 본 뒤에는 일부러 경계 비교나 정리 코드를 바꾸어 어떤 시험이 실패하는지 확인하세요. 앞선 시험이 다음 단계에서도 유지되는 이유를 설명하고, 이 실습이 보장하지 않는 운영 조건을 한 가지 적어 보세요.