해결책
Ramp의 Python 모놀리스 테스트 스위트는 팀이 5,000개 이상의 테스트로 확장하면서 대부분 기능/통합 스타일인 탓에 20분에 가까워지고 있었다. 인프라 팀은 스위트를 다시 작성하는 대신 런타임을 직접 공략했다. 병렬 워커를 4개로 제한하던 데이터베이스 설정 병목을 해결해 CI 동시성을 높이고, 로컬 실행 간에 테스트 데이터베이스를 처음부터 tear down하고 다시 빌드하는 것을 중단하며, 주어진 코드 변경과 관련된 테스트만 실행하는 증분 테스트 러너를 구축했다.
증분 러너의 첫 버전은 coverage.py를 사용해 각 테스트가 실행한 코드 라인을 매핑한 뒤, 새 커밋에서 변경된 파일을 그 맵과 diff했다. 작동은 했지만 특정한 공백이 있었다. Ramp의 코드베이스는 Flask-SQLAlchemy 모델 클래스를 사용하는데, 커버리지 측정은 거의 모든 실행을 모델 클래스 선언 자체가 아니라 SQLAlchemy 내부에 귀속시킨다. 모델에 컬럼을 추가해도 순수 라인 커버리지에서는 '이 클래스가 변경됨'으로 등록되지 않아, 증분 러너가 스키마 변경 버그를 잡았어야 할 테스트를 건너뛸 수 있었다.
이를 해결하기 위해 팀은 coverage.py를 Python 내장 sys.settrace로 교체해, `self` 또는 `cls` 인자가 있는 `call` 이벤트를 검사하고, 그 객체를 클래스로 해석하며, `__bases__`를 너비 우선으로 거슬러 올라가 모든 조상 클래스를 정의된 파일에 매핑했다. 그 결과 스냅샷은 테스트가 실행한 라인뿐 아니라 전체 상속 체인에서 의존한 클래스까지 기록할 수 있게 되어, 하위 클래스 코드 경로가 직접 실행되지 않았더라도 기반 클래스 선언의 변경이 모든 하위 클래스의 테스트를 올바르게 무효화했다.
동시성 수정과 전체 데이터베이스 재빌드 생략을 결합해, 증분 러너는 일반적인 커밋에서 스위트의 30-40%를 커버하고 전체 실행이 잡았을 실패의 약 90%를 잡아냈으며, 엔지니어에게 전체 10-20분 대신 몇 분 만에 실행 가능한 신호를 제공했다.
달성한 것
런타임에 클래스 상속을 추적해 실행되지 않은 기반 클래스 편집도 해당 테스트를 트리거하도록 한다
Coverage.py는 실행된 라인을 기록할 뿐 그 라인이 속한 클래스를 기록하지 않으므로, SQLAlchemy ORM 내부에서의 실행은 사용자의 모델 클래스에 귀속되지 않는다.
sys.settrace는 호출 프레임을 직접 노출하므로, `self`/`cls`를 검사하면 라인 수준 실행이 어디서 일어났는지와 무관하게 '어떤 클래스인지'를 복원할 수 있다.
그 클래스에서 `__bases__`를 거슬러 올라가면 하나의 호출 이벤트가 전체 조상 목록이 되어, 기반 클래스 변경이 체인의 모든 파일에 귀속된다.
증분 러너를 전체 스위트 대신이 아니라 전체 스위트와 함께 실행했기 때문에, 약 10%의 탐지 공백이 프로덕션 위험이 되지 않았다.
커버리지 기반 의존성 지름길은 커버리지 도구가 변경된 코드에 귀속시키지 않는 상속 같은 실제 의존성을 놓칠 수 있다. 빠른 경로를 신뢰하기 전에 그 사각지대를 확인하라.
그 후
Ramp는 이 시점 이후 증분 러너에 더 투자할 가치가 없다고 판단했다. 남은 약 10%의 탐지 공백을 메우려면 Bazel 같은 것을 중심으로 재구성해야 했는데, 당시 최우선 순위는 아니었다. 동시성 수정(워커별 중복 데이터베이스 리셋을 제거한 뒤 머신 크기를 두 배로 늘림)만으로 CI 시간이 절반으로 줄었고, 로컬에서 전체 데이터베이스 재빌드를 생략해 실행당 10-30초를 절약했다. 팀은 Ramp의 병렬화 지원이 부족했던 pytest-testmon 같은 오픈소스 대안에 대한 대안으로 sys.settrace 기반 상속 순회를 공개했다.
근거 확인하기
출처
- Faster Python Tests Ramp Engineering (Builders Blog) · builders.ramp.com ·