결함 주입과 회귀 테스트
110분 안팎
학습 목표
센서 누락·지연·막힌 통로 실패를 재생하고 개선 전후 회귀를 실행합니다.
개념
실패를 지우지 않고 재현합니다
회귀 테스트는 고친 버그가 돌아오지 않는지 확인하는 약속입니다. 로봇에서는 성공 여부 외에 종료 이유와 모터 출력을 함께 고정해야 합니다. 센서가 빠졌는데 정상 도착한 사례를 성공으로만 보면 안전 검사가 없어도 통과합니다. 이 레슨은 누락·과도한 지연·막힌 통로를 작은 실패 fixture로 보관하고, 정상 경로와 함께 실행하여 수정의 효과와 부작용을 동시에 확인합니다.
앞 모듈의 SensorNavigation에는 이미 입력 검사와 정지 기능이 있습니다. 이번 미션은 그것을 유지하면서 재생기로 연결합니다. legacy 정책은 비교를 위해 센서 시간 검사를 우회하는 NavigationActions를 사용하고 safe는 SensorNavigation을 사용합니다. legacy는 실제 운영 버전의 이력이라는 뜻이 아니라 방어 기능이 빠진 결함을 재현하는 비교용 모델입니다.
결함을 한 가지씩 넣습니다
누락 fixture는 정상 입력으로 움직이다 0.50초에 sensor payload를 null로 만듭니다. legacy는 이 센서를 사용하지 않아 도달할 수 있지만 safe는 SENSOR_MISSING으로 중단해야 합니다. 마지막 PWM이 (0.0, 0.0)인지도 확인합니다. reason만 바꾸고 바퀴 출력을 유지하는 결함은 종료 상태 검사가 통과해도 모터 출력 검사에서 드러납니다.
지연 fixture는 0.25초 동안 새 입력이 오지 않는 상황을 만듭니다. 처음에는 시각 0의 정상 센서를 제공하고 이후 tick에서 입력 나이가 커지도록 합니다. max_age=0.20초를 넘기면 SENSOR_STALE로 중단합니다. 기본 비교 행렬의 0.15초 지연은 허용되어야 하므로 두 조건을 함께 검사해야 모든 지연을 거절하는 과잉 수정도 잡을 수 있습니다.
막힌 통로 fixture는 로봇 크기와 여유 폭을 반영하면 경로가 없는 지도입니다. 초기 요청 직후 PATH_NOT_FOUND가 나고 바퀴는 0이어야 합니다. 움직임 표본이 없으므로 위치 RMSE는 null입니다. 테스트에 임의로 길을 뚫어 정상 도달시키면 실패 재현이 사라집니다. 지도 해시와 목표를 고정해 같은 실패 조건을 유지하고 변경의 이유가 있는 새 실험은 별도 폴더로 저장합니다.
평가 자체의 결함도 검사합니다
제어기의 SUCCEEDED는 추정 자세로 판단한 상태입니다. 바퀴 잡음이 쌓이면 제어기는 목표에 왔다고 생각하지만 참값은 멀리 있을 수 있습니다. starter의 success는 상태 문자열만 봅니다. TODO는 SUCCEEDED와 참값 거리 허용 범위와 실제 충돌 0을 함께 요구하도록 고칩니다. 추정 오류를 고친 척하기 위해 평가 함수에서 참값을 제어기에 넘기지는 않습니다.
0.025m 경계 안의 SUCCEEDED는 성공이어야 하고 0.5m 떨어진 SUCCEEDED는 실패여야 합니다. 도착 위치가 맞아도 충돌 구간이 하나 있으면 실패입니다. ABORTED가 우연히 목표 위치에서 끝나도 성공으로 다시 분류하지 않습니다. 상태·거리·충돌 각 조건을 따로 깨뜨린 단위 테스트를 두면 하나의 큰 주행 테스트보다 잘못된 판정 원인을 빠르게 찾을 수 있습니다.
주행 fixture는 이 함수 검사를 실제 시스템으로 연결합니다. clean 실행의 도달·충돌 0·PWM 0을 확인하고, 같은 파일 두 재생의 상태와 결과 해시를 비교합니다. 파일 한 줄을 변경한 경우 HASH input.jsonl로 재생 전에 거절하는지도 검사합니다. 성공 판정과 입력 무결성과 재현성은 서로 다른 계약이라 한 검사로 묶어 생략하지 않습니다.
이전 검사를 실제로 이어갑니다
미션 starter는 앞 모듈 solution의 소스·설정·fixture·테스트를 그대로 포함합니다. 새 check.sh는 이전 검사 스크립트를 check-m08.sh라는 이름으로 직접 호출합니다. check-m08.sh는 check-m07.sh를 부르고 기존 단계로 이어집니다. 테스트 내부에서 현재 check.sh를 호출하는 구조는 만들지 않습니다. 자기 검사를 다시 실행하면 재귀가 끝나지 않아 프로세스가 늘어나기 때문입니다.
C HAL은 임시 폴더에 라이브러리를 빌드하고 HAL_LIBRARY로 Python에 위치를 전달합니다. macOS와 Linux는 각각 다른 공유 라이브러리 빌드 옵션을 사용합니다. 검사가 끝나면 셸 trap으로 임시 폴더를 정리합니다. 데몬이나 서버를 띄우지 않으므로 종료 후 남겨 둘 실행 프로세스가 없습니다. 실행 결과가 나올 때까지 기다리는 방식으로 검증하며 시간 초과를 정상 성공으로 처리하지 않습니다.
새 검사는 가상 시계 7개와 회귀 11개입니다. starter에서 평가 TODO 때문에 두 개의 독립된 성공 판정 검사가 실패하고 다른 검사는 통과합니다. solution은 새 검사와 이전 회귀를 모두 통과한 뒤 25회 비교를 수행합니다. 검사가 끝난 것과 실험 결과가 기대한 안전 동작을 보인 것을 분리해 읽습니다. 집계 표는 자동 합격의 근거가 아니라 주행의 한계를 설명하는 증거이기도 합니다.
회귀 결과를 설명하는 순서
실패 메시지에서 먼저 test 이름을 읽습니다. test_success_requires_truth가 실패하면 추정 성공만 집계했는지 확인합니다. test_stale_before_after가 실패하면 입력 나이 검사를 우회했거나 tick 시각을 잘못 전달했을 수 있습니다. test_clean_repeat의 해시가 다르면 난수 초기화·출력에 포함한 실제 시각·이전 실행의 상태 잔류를 조사합니다. 무작정 기대값을 현재 출력으로 바꾸지 않습니다.
정상 경로는 계속 도착하는지, 필요한 안전 정지는 유지되는지, 다른 조건의 충돌이 증가하지 않았는지 순서대로 리뷰합니다. 막힌 통로와 누락에서 성공률을 올리는 것이 목표가 아닙니다. 0.15초 지연의 정상 도달과 0.25초 지연의 정지를 함께 유지해야 경계를 제대로 구현한 것입니다. 수정 설명에는 변경된 조건과 유지해야 할 동작을 구체적인 fixture 이름으로 씁니다.
실험 증거를 저장할 때는 실행별 manifest·입력·상태 a/b·결과 a/b와 전체 matrix·summary를 묶습니다. 실행 시간이 달라지는 정보는 결과 해시에 포함하지 않습니다. 매 실행 환경은 manifest에 기록하고 두 재생의 결과만 비교합니다. 같은 코드와 파일의 결정성을 보여 준다고 해서 서로 다른 CPU나 Python 버전의 비트 단위 일치까지 검증한 것은 아닙니다.
완성 보고에는 결함 주입 위치, legacy와 safe의 차이, 종료 reason, PWM, 정상 조건 회귀 결과를 씁니다. 실제 장치에서 센서 단절·모터 정지·시간 동기화를 검증할 범위는 다음 통합 모듈의 인계 항목으로 남깁니다. ROS 통합 검사 프레임워크 사용법은 더 읽기로 연결합니다. 이 모듈에서 제출할 것은 실패를 반복 실행할 수 있는 파일과 검증 명령과 정직한 결과 해석입니다.
따라하기
평가 함수의 결함을 드러냅니다
이 작은 예에서는 legacy가 상태만 보고 success는 참값 거리와 충돌까지 검사합니다.
def legacy(state): return state=='SUCCEEDED'
def success(state,distance,hits,tolerance):
return state=='SUCCEEDED' and distance<=tolerance+1e-9 and hits==0
for distance,hits in ((0.01,0),(0.5,0),(0.01,1)):
print(f'distance={distance:.2f} hits={hits} legacy={legacy("SUCCEEDED")} safe={success("SUCCEEDED",distance,hits,.025)}')
실행 결과
distance=0.01 hits=0 legacy=True safe=True distance=0.50 hits=0 legacy=True safe=False distance=0.01 hits=1 legacy=True safe=False
지연 허용 경계를 고정합니다
현재 입력 나이 0.15초와 0.25초를 같은 기준에 넣습니다. 실제 주행 회귀는 zip의 test_stale_before_after가 검사합니다.
max_age_us=200000
for age_us in (150000,200000,250000):
reason='ACCEPTED' if age_us<=max_age_us else 'SENSOR_STALE'
print(f'age_s={age_us/1000000:.2f} reason={reason}')
실행 결과
age_s=0.15 reason=ACCEPTED age_s=0.20 reason=ACCEPTED age_s=0.25 reason=SENSOR_STALE
실패도 회귀 기준으로 보관합니다
도달률 한 값만 남기지 않고 누락·경로 실패 횟수와 정상 경로 결과를 구분합니다.
rows=[('clean',True,'ARRIVED'),('missing',False,'SENSOR_MISSING'),('blocked',False,'PATH_NOT_FOUND')]
for condition,reached,reason in rows:
print(f'{condition} reached={reached} reason={reason}')
print('expected_safe_failures=',sum(not reached for _,reached,_ in rows))
실행 결과
clean reached=True reason=ARRIVED missing reached=False reason=SENSOR_MISSING blocked reached=False reason=PATH_NOT_FOUND expected_safe_failures= 2
확인 문제
실습
미션 starter.zip은 m08 미션 solution 전체와 가상 시계 실행기를 포함합니다. replay_experiments.py의 success TODO를 SUCCEEDED·참값 거리 0.025m 이내·실제 충돌 0의 결합 조건으로 완성합니다. bash check.sh 한 줄로 이전 회귀, 시계 7개와 실패 회귀 11개, 25회 행렬을 실행합니다. starter는 평가 assertion 두 개가 실패하며 solution은 전부 통과합니다. missing과 0.25초 지연은 legacy 도달과 safe 정지/PWM 0을 비교하고 blocked는 PATH_NOT_FOUND여야 합니다. EVIDENCE_DIR="$PWD/artifacts" bash check.sh로 결과 파일도 보존합니다. 상세 파일 구성은 README-M09.md에 있습니다. 제어기에는 참값을 전달하지 않습니다. 표본 없는 RMSE는 null로 두고 잡음에서 TRUTH_NOT_REACHED가 나올 수 있음을 보고합니다.
실행 명령
bash check.sh
기대 결과
이전 회귀와 새 검사 18개 통과; runs=25 replay_pairs=25 identical=25
모범 답안
모범 답안 내려받기더 읽기
면접 질문
- 실험 기록을 재생할 때 함께 남겨야 할 정보를 설명해 주시면 됩니다.