실패했는데 종료 코드는 0 이었다 · 버그를 시험으로 못 박는다 · 이론
버그를 시험으로 못 박는다
한 줄 요약
운영 도구의 버그는 먼저 실패하는 시험으로 재현하고, 그다음 고친다. 파일·시간·환경변수·출력에 의존하는 도구는 pytest 의 tmp_path·monkeypatch·capsys 로 그 의존을 시험 안에 가둔다.
왜 이게 필요했나
로그 회전 도구가 "3개만 남긴다" 고 했는데 4개가 남았다. 담당자가 코드를 고쳤고, 다음 주에 같은 버그가 다른 형태로 돌아왔다 — 이번에는 --dry-run 인데 파일이 지워졌다. 두 사고의 공통점은 고친 사람이 고쳐졌다는 증거를 남기지 않았다는 것이다. 증거는 시험이다. 버그를 재현하는 시험은 고치기 전에는 빨갛고 고친 뒤에는 초록이며, 그 뒤로는 같은 버그가 돌아오는 것을 막는 문지기가 된다.
어떻게 동작하나
pytest 는 test_*.py 파일의 test_* 함수를 찾아 실행하고, assert 가 거짓이면 실패로 기록한다. 도구를 시험하려면 도구가 임포트 가능해야 한다 — 앞 모듈에서 main(argv) -> int 모양을 고집한 이유가 여기서 돌아온다. import rotate; rotate.main([str(d), "--keep", "3"]) 처럼 부르면 프로세스를 새로 띄우지 않고도 도구 전체를 시험할 수 있다.
파일. [tmp_path](https://docs.pytest.org/en/stable/how-to/tmp_path.html) 픽스처는 시험 함수마다 고유한 임시 디렉터리를 pathlib.Path 로 준다. 그 안에 파일을 만들고 도구를 돌리고 결과를 보면 되고, 시험이 끝나면 pytest 가 정리한다. 실제 /var/log 를 건드리는 시험은 시험이 아니라 사고다.
시간. "7일보다 오래된 파일" 같은 조건은 time.time() 에 의존한다. 진짜 시간을 기다릴 수는 없으므로 [monkeypatch](https://docs.pytest.org/en/stable/how-to/monkeypatch.html) 로 바꿔 끼운다. monkeypatch.setattr(rotate.time, "time", lambda: 1_700_000_000) 처럼 속성을 갈아 끼우면 그 시험이 끝날 때 원래대로 되돌아간다. 문서가 setattr·delattr·setitem·setenv·delenv·chdir 를 제공한다고 적는데, 전부 같은 원칙이다 — 전역 상태를 시험 안에서만 바꾼다.
출력. 도구가 무엇을 지웠는지 print 로 알린다면 그 문장도 계약이다. [capsys](https://docs.pytest.org/en/stable/how-to/capture-stdout-stderr.html) 픽스처의 readouterr() 가 시험 중 찍힌 stdout 과 stderr 를 돌려준다. assert "delete old.log" in captured.out 처럼 확인한다.
입력 여러 벌. 같은 논리를 keep=0·1·3·10 으로 돌려 보고 싶다면 함수를 네 번 복사하지 않는다. [parametrize](https://docs.pytest.org/en/stable/how-to/parametrize.html) 데코레이터가 인자 목록마다 시험을 하나씩 만든다. 실패하면 어느 값에서 실패했는지 시험 ID(test_keep[3])에 남는다.
import pytest, rotate@pytest.mark.parametrize("keep", [0, 1, 3, 10])def test_keep_leaves_exactly_keep_files(tmp_path, keep): for i in range(5): (tmp_path / f"app-{i}.log").write_text("x") assert rotate.main([str(tmp_path), "--keep", str(keep)]) == 0 assert len(list(tmp_path.glob("*.log"))) == min(5, keep)def test_dry_run_deletes_nothing(tmp_path, capsys): (tmp_path / "a.log").write_text("x") (tmp_path / "b.log").write_text("x") rotate.main([str(tmp_path), "--keep", "0", "--dry-run"]) assert sorted(p.name for p in tmp_path.glob("*.log")) == ["a.log", "b.log"] assert "a.log" in capsys.readouterr().out순서. 버그 보고를 받으면 (1) 재현하는 시험을 쓴다 — 지금 코드로 돌리면 실패해야 한다. 실패하지 않으면 재현이 틀린 것이고, 그 상태로 코드를 고치면 무엇을 고쳤는지 알 수 없다. (2) 코드를 고친다. (3) 시험이 통과한다. (4) 다른 시험도 전부 통과한다 — 고친 것이 다른 것을 깨지 않았다는 증거다. 이 네 단계 중 (1)을 건너뛰는 것이 "다음 주에 같은 버그가 돌아오는" 원인이다.
결과 남기기. CI 는 사람의 눈이 아니라 파일을 읽는다. pytest --junitxml=report.xml 은 시험 수·실패 수·소요 시간을 XML 로 남기고, 그것이 파이프라인의 통과 조건이 된다. 종료 코드도 있다 — 전부 통과하면 0, 하나라도 실패하면 1.
현장에서 만나는 모습
가장 흔한 것은 "시험이 진짜 디렉터리를 본다" 다. os.chdir 를 하거나 /tmp/test 같은 고정 경로를 써서 시험 두 개가 서로 간섭하고, 순서에 따라 결과가 달라진다. tmp_path 는 시험마다 다른 경로라 이 문제가 없다. 두 번째는 시간을 time.sleep(2) 로 만드는 시험이다 — 느리고, CI 가 바쁠 때 실패한다. 시간은 갈아 끼우는 것이다. 세 번째는 "고쳤으니 시험은 나중에" 다. 나중은 오지 않는다. 실패하는 시험을 먼저 쓰면 고치는 시간이 오히려 줄어든다 — 재현이 손에 있으니까.
다음 실습에서 할 것
결함이 두 개 심어진 로그 회전 도구 /opt/fixtures/pyops/buggy/rotate.py 를 작업 디렉터리로 복사해, 순수 함수 시험 → tmp_path 로 재현(실패) → 고치기 → --dry-run 재현 → 고치기 → parametrize → capsys → monkeypatch 로 시간 고정 → --junitxml 보고서 순으로 간다. 채점기는 여러분의 시험을 원본 결함 코드에 대고도 돌려서, 그 시험이 정말 버그를 잡는지 확인한다.