소프트웨어 프로젝트가 성장하다 보면 누구에게나 이런 일이 생깁니다. 코드베이스의 일부가 포괄적인 테스트 스위트 없이 운영 환경에 배포되어 버리는 것이죠. 몇 달 뒤 같은 코드 영역을 다시 들여다보면 이해하기 어려워져 있고, 더 나쁜 경우에는 버그가 발생했는데도 어디서부터 고쳐야 할지 감을 잡기 힘든 상황에 직면하게 됩니다.
테스트 없는 코드를 수정하는 일은 큰 도전입니다. 수정 과정에서 무언가를 망가뜨릴지 확신할 수 없고, 모든 동작을 수동으로 확인하는 방식은 기껏해야 실수하기 쉬울 뿐 아니라 대부분의 경우 사실상 불가능합니다.
이런 종류의 코드를 다루는 일은 개발자가 가장 자주 수행하는 작업 중 하나이며, 오랜 기간 이 문제를 해결하기 위한 다양한 기법들이 연구되어 왔습니다. 이전 글에서 다룬 특성화 테스트(characterization test)도 그중 하나입니다.
오늘은 특성화 테스트를 기반으로 하되, 오래전 현대 프로그래밍 세계에 TDD를 알린 켄트 벡(Kent Beck)이 소개한 또 다른 기법을 살펴보겠습니다.
TCR이란?
TCR은 "Test, Commit, Revert"의 약자지만, 정확히는 "test && commit || revert"라고 표현하는 편이 더 적절합니다. 그 이유는 잠시 후에 설명하겠습니다.
이 기법은 레거시 코드를 테스트하기 위한 워크플로를 제시합니다. 프로젝트 파일을 저장할 때마다 테스트를 자동으로 실행해 주는 스크립트를 활용하며, 진행 순서는 다음과 같습니다.
- 먼저, 테스트하고 싶은 레거시 코드 부분에 대해 빈 단위 테스트를 생성합니다.
- 단 하나의 assert 문을 추가한 뒤 테스트 파일을 저장합니다.
- 스크립트가 설정되어 있다면 테스트가 자동으로 실행됩니다. 성공하면 변경 사항이 커밋(commit)되고, 실패하면 변경 사항이 삭제(revert)되므로 처음부터 다시 시도해야 합니다.
테스트가 통과되면 새로운 테스트 케이스를 추가할 수 있습니다.
본질적으로 TCR은 코드를 항상 "녹색(green)" 상태로 유지하는 데 초점을 맞춥니다. TDD처럼 먼저 실패하는 테스트(레드)를 작성한 후 통과(그린)시키는 방식과는 반대이죠. TCR에서는 실패하는 테스트를 작성하면 그 변경 사항이 사라지고 다시 녹색 상태로 돌아갑니다.
목적
이 기법의 주요 목표는 테스트 케이스를 하나씩 추가할 때마다 코드를 조금씩 더 깊이 이해하는 것입니다. 이 과정에서 테스트 커버리지가 자연스럽게 늘어나고, 그렇지 않으면 불가능했던 수많은 리팩토링의 길이 열립니다.
TCR의 장점 중 하나는 다양한 상황에서 활용할 수 있다는 점입니다. 테스트가 전혀 없는 코드에도, 부분적으로만 테스트된 코드에도 사용할 수 있습니다. 테스트가 통과하지 않으면 변경 사항을 되돌리고 다시 시도하면 됩니다.
어떻게 사용할 수 있을까?
켄트 벡은 여러 글과 영상(문서 끝의 링크 참고)에서 프로젝트 내 특정 파일들이 저장될 때 실행되는 스크립트를 사용하는 방식을 좋은 접근법으로 소개합니다.
물론 구체적인 방법은 테스트하려는 프로젝트에 따라 달라집니다. 에디터 플러그인을 통해 파일을 저장할 때마다 아래와 같은 스크립트를 실행하는 것부터 시작해 보세요.
(rspec && git commit -am "WIP") || git reset --hard
Visual Studio Code를 사용한다면 파일을 저장할 때마다 명령을 실행해 주는 "runonsave" 플러그인이 좋은 선택입니다. 위 명령 또는 프로젝트에 맞는 유사한 명령을 설정에 넣으면 됩니다. 이 경우 설정 파일 전체는 다음과 같습니다.
{
"folders": [{ "path": "." }],
"settings": {
"emeraldwalk.runonsave": {
"commands": [
{
"match": "*.rb",
"cmd": "cd ${workspaceRoot} && rspec && git commit -am WIP || git reset --hard"
}
]
}
}
}
참고로, 작업이 끝난 후에는 Git 명령줄에서 커밋들을 스쿼시(squash)하거나, GitHub을 사용한다면 PR 병합 시점에 스쿼시할 수 있습니다.

이렇게 하면 작업 브랜치에서 만든 수많은 커밋이 메인 브랜치에는 단 하나의 커밋으로 반영됩니다. GitHub의 다음 다이어그램이 잘 설명해 줍니다.

TCR로 첫 번째 테스트 작성하기
간단한 예제로 이 기법을 살펴보겠습니다. 정상 동작한다고 알고 있는 클래스가 있지만, 이를 수정해야 하는 상황입니다.
변경 사항을 만들고 바로 배포할 수도 있지만, 수정 과정에서 아무것도 망가뜨리지 않는다는 것을 확실히 하고 싶습니다. 언제나 좋은 습관이죠.
# worker.rb
class Worker
def initialize(age, active_years, veteran)
@age = age
@active_years = active_years
@veteran = veteran
end
def can_retire?
return true if @age >= 67
return true if @active_years >= 30
return true if @age >= 60 && @active_years >= 25
return true if @veteran && @active_years > 25
false
end
end
첫 번째 단계는 테스트를 담을 새 파일을 만드는 것입니다. can_retire? 메서드의 첫 번째 분기를 보겠습니다.
def can_retire?
return true if @age >= 67
...
...
end
따라서 이 케이스부터 먼저 테스트할 수 있습니다.
# specs/worker_spec.rb
require_relative './../worker'
describe Worker do
describe 'can_retire?' do
it "should return true if age is higher than 67" do
end
end
end
여기서 유용한 팁 하나를 소개합니다. TCR로 작업할 때는 저장하는 순간 테스트가 통과하지 못하면 최신 변경 사항이 사라집니다. 따라서 assert 문이 포함된 줄을 작성하고 저장하기 전에, 테스트를 "준비"하는 코드를 최대한 미리 작성해 두는 것이 좋습니다.
위 파일을 그대로 저장한 다음, 이제 테스트 한 줄을 추가합니다.
require_relative './../worker'
describe Worker do
describe 'can_retire?' do
it "should return true if age is higher than 67" do
expect(Worker.new(70, 10, false).can_retire?).to be_true ## 저장하는 순간 이 줄은 사라질 수 있습니다
end
end
end
파일을 저장했을 때 새로 추가한 줄이 사라지지 않았다면 성공입니다. 테스트가 통과했다는 뜻이니까요!
더 많은 테스트 추가하기
첫 번째 테스트를 확보했다면, 거짓(false) 케이스까지 고려하면서 계속 테스트를 추가할 수 있습니다. 조금 작업을 진행하면 다음과 같은 형태가 됩니다.
# frozen_string_literal: true
require_relative './../worker'
describe Worker do
describe 'can_retire?' do
it 'should return true if age is higher than 67' do
expect(Worker.new(70, 10, false).can_retire?).to be true
end
it 'should return true if age is 67' do
expect(Worker.new(67, 10, false).can_retire?).to be true
end
it 'should return true if age is less than 67' do
expect(Worker.new(50, 10, false).can_retire?).to be false
end
it 'should return true if active years is higher than 30' do
expect(Worker.new(60, 31, false).can_retire?).to be true
end
it 'should return true if active years is 30' do
expect(Worker.new(60, 30, false).can_retire?).to be true
end
end
end
매번 it 블록을 먼저 작성하고 저장한 뒤, expect(...) assertion을 추가하는 순서로 진행합니다.
당연히 테스트는 원하는 만큼 추가할 수 있지만, 대체로 모든 케이스가 커버되었다고 확신되면 너무 많은 테스트를 추가하는 것은 피하는 편이 좋습니다.
그래도 아직 커버해야 할 케이스가 몇 가지 남아 있으니, 완전함을 위해 추가해 주는 것이 좋겠습니다.
최종 테스트
다음은 최종 형태의 스펙 파일입니다. 여기서도 케이스를 더 추가할 수 있지만, TCR의 진행 과정을 설명하기에는 충분하다고 생각합니다.
# frozen_string_literal: true
require_relative './../worker'
describe Worker do
describe 'can_retire?' do
it 'should return true if age is higher than 67' do
expect(Worker.new(70, 10, false).can_retire?).to be true
end
it 'should return true if age is 67' do
expect(Worker.new(67, 10, false).can_retire?).to be true
end
it 'should return true if age is less than 67' do
expect(Worker.new(50, 10, false).can_retire?).to be false
end
it 'should return true if active years is higher than 30' do
expect(Worker.new(60, 31, false).can_retire?).to be true
end
it 'should return true if active years is 30' do
expect(Worker.new(20, 30, false).can_retire?).to be true
end
it 'should return true if age is higher than 60 and active years is higher than 25' do
expect(Worker.new(60, 30, false).can_retire?).to be true
end
it 'should return true if age is higher than 60 and active years is higher than 25' do
expect(Worker.new(61, 30, false).can_retire?).to be true
end
it 'should return true if age is 60 and active years is higher than 25' do
expect(Worker.new(60, 30, false).can_retire?).to be true
end
it 'should return true if age is higher than 60 and active years is 25' do
expect(Worker.new(61, 25, false).can_retire?).to be true
end
it 'should return true if age is 60 and active years is 25' do
expect(Worker.new(60, 25, false).can_retire?).to be true
end
it 'should return true if is veteran and active years is higher than 25' do
expect(Worker.new(60, 25, false).can_retire?).to be true
end
end
end
리팩토링 방향
여기까지 읽으셨다면 코드에서 뭔가 걸리는 부분을 느끼셨을 겁니다. 테스트 코드와 Worker 클래스 양쪽 모두에서 매직 넘버(magic number)가 너무 많습니다. 이 값들은 상수로 추출하는 것이 좋습니다.
또한 공개 메서드인 can_retire? 내부의 각 분기를 별도의 비공개(private) 메서드로 분리할 수도 있습니다.
두 가지 리팩토링은 연습 과제로 남겨두겠습니다. 이제 테스트가 있으니, 과정에서 실수를 하더라도 테스트가 알려줄 것입니다.
마무리
여러분의 프로젝트에 TCR을 적용해 보시길 권합니다. 외부 서버의 화려한 CI 환경이나 새로운 라이브러리 의존성이 필요 없는, 비용이 거의 들지 않는 실험입니다. 컴퓨터에서 특정 파일을 저장할 때마다 명령을 실행할 수 있는 방법만 있으면 됩니다.
덧붙여, 테스트를 추가하는 과정이 일종의 "게임"처럼 느껴지는데, 이것 역시 재미있는 경험이 됩니다. 게다가 실패하는 테스트가 에디터에서 즉시 제거되는 이 규율 덕분에, 저장소에 올리는 테스트가 반드시 통과된다는 확신이라는 추가 안전망을 얻게 됩니다.
레거시 코드를 다룰 때 이 새로운 기법이 도움이 되길 바랍니다. 저는 지난 몇 달간 여러 번 사용했는데, 항상 즐거운 경험이었습니다.
추가 자료
- 입문용으로 좋은 소개 영상
- 켄트 벡이 설명하는 VS Code에서 TCR 활용법
- 파일 저장 시 스크립트를 실행해 주는 VS Code 플러그인