이 글은 José M. Gilgado의 글 Taming Legacy Code With Characterization Tests를 번역한 글입니다.
원문은 링크에서 찾아볼 수 있습니다.
잘 동작하지만 이해하기 어렵고 전혀 테스트되지 않은 기존 코드를 변경해야 했던 적이 있나요? 그런 코드를 변경해서 배포할 때 어떤 기분이 들었나요? 긴장됐나요?
저는 얼마 전에 작성된 지 7년이 지난 수백 줄의 API를 수정해야 했습니다. 새로운 기능 배포를 하면서 코드를 더 깔끔하고, 잘 테스트되게끔 만들기 위해서 이제부터 소개할 몇 가지 테크닉을 사용했습니다. 지금부터 제가 어떤 방식으로 문제를 해결했는지 간단한 예제를 통해 알아보려 합니다.
예제를 위해, 민감한 데이터를 마스킹 처리하는 코드가 있고, 프로덕트 매니져로부터 이메일이 마스킹되는 방식을
***************.com
에서
*****@example.com
와 같이 바꿔달라는 요청을 받았다고 가정해봅시다.
텍스트 에디터를 열어 현재 코드를 확인합니다.
def mask(str)
if str.length <= 4
return str
end
# Email
if str =~ URI::MailTo::EMAIL_REGEXP
limit = str.length - 4
return "#{'*' * limit}#{str[limit..-1]}"
end
# Phone Number or ID
if str.is_a?(Numeric)
limit = str.length - 4
return "#{'*' * limit}#{str[limit..-1]}"
end
# String, like a name
limit = str.length - 4
return "#{'*' * limit}#{str[limit..-1]}"
end
안타깝게도 현재 해당 코드에 대한 테스트 코드는 한 줄도 없습니다. 즉, 코드를 수정했을 때 코드의 동작이 깨질지 알 수 없다는거죠. 여기서부터 어떻게 해야될까요?
가장 먼저 할 일은 가능한한 코드에 손을 대지 않고 테스트부터 작성하는 것입니다. 이렇게 함으로써
간단한 테스트부터 작성해봅시다.
어떤 테스트 라이브러리를 사용하든지 상관없지만 rspec
(역주: 루비 진영에서 인기있는 테스트 프레임워크)이 프로젝트에 설정돼있기 때문에 그대로 사용하도록 하겠습니다.
describe 'mask' do
it "masks regular text" do
expect(mask('simple text')).to eq('*******text')
end
end
코드에 달린 주석 (# String, like a name
)을 토대로 스트링 전체에서 마지막 4글자를 제외한 글자를 *로 치환한다는 테스트를 작성합니다. 예상한대로 테스트가 통과하네요!
$ rspec
.
Finished in 0.0026 seconds (files took 0.12025 seconds to load)
1 example, 0 failures
이쯤되면 다른 코드에 대해서도 똑같은 방식으로 테스트를 작성하고 싶겠지만 다른 테크닉을 한 번 써보도록 하겠습니다.
캐릭터라이제이션 테스트는 마이클 페더스에 의해 고안되었으며, 아직 동작을 모두 파악하지 못한 코드에 대해 테스트를 작성하는 테크닉입니다. 이 테크닉은 코드의 동작을 추측하는 대신 코드가 어떤 일을 할지 적극적으로 탐색할 것을 강조합니다.
코드의 동작을 모르는 상태로 테스트를 작성하고, 해당 테스트 코드를 코드의 동작을 설명하는 문서로 만드는 방식입니다. 한 번 이 방식대로 테스트를 작성해봅시다.
먼저 실패하는 테스트를 작성합니다.
it "x" do
expect(mask('simple text')).to eq(nil)
end
결과는
F
Failures:
1) mask x
Failure/Error: expect(mask('simple text')).to eq(nil)
expected: nil
got: "*******text"
(compared using ==)
# ./spec/mask_spec.rb:5:in `block (2 levels) in <top (required)>'
Finished in 0.01882 seconds (files took 0.12916 seconds to load)
1 example, 1 failure
Failed examples:
rspec ./spec/mask_spec.rb:4 # mask x
이제 테스트 실행을 통해 얻은 결과('simple text'를 마스킹 처리하면 '*******text'이 리턴된다.)를 바탕으로 통과되는 테스트를 작성할 수 있습니다.
it "masks regular text" do
expect(mask('simple text')).to eq('*******text')
end
처음 테스트를 작성할 때와 동일한 코드를 작성했지만 처음과 달리 코드의 동작을 추론하는 대신 가능한 인풋을 넣어 돌려본 결과를 통해 테스트를 작성했습니다.
이제 다른 테스트를 추가해봅시다.
아까와 마찬가지로 실패할 테스트를 추가해봅시다.
it "x" do
expect(mask('example@example.com')).to eq(nil)
end
$ rspec
.F
Failures:
1) mask x
Failure/Error: expect(mask('example@example.com')).to eq(nil)
expected: nil
got: "***************.com"
(compared using ==)
# ./spec/mask_spec.rb:9:in `block (2 levels) in <top (required)>'
Finished in 0.03376 seconds (files took 0.34069 seconds to load)
2 examples, 1 failure
Failed examples:
rspec ./spec/mask_spec.rb:8 # mask x
일반 텍스트를 넣었을 때와 동작은 다르지 않은 것 같습니다. 이제 위에서 작성한 테스트를 바꿔봅시다.
it "masks an email address" do
expect(mask('example@example.com')).to eq('***************.com')
end
처음에 봤던 코드 중간에 숫자 형태의 스트링을 처리하는 코드가 있다는 것을 기억할 겁니다.
if str.is_a?(Numeric)
limit = str.length - 4
return "#{'*' * limit}#{str[limit..-1]}"
end
또 한 번 실패하는 테스트를 작성해봅시다.
it "x" do
expect(mask('635914526')).to eq(nil)
end
$ rspec
..F
Failures:
1) mask x
Failure/Error: expect(mask('635914526')).to eq(nil)
expected: nil
got: "*****4526"
(compared using ==)
# ./spec/mask_spec.rb:13:in `block (2 levels) in <top (required)>'
Finished in 0.02038 seconds (files took 0.17098 seconds to load)
3 examples, 1 failure
Failed examples:
rspec ./spec/mask_spec.rb:12 # mask x
다시 한 번 테스트 실행 결과를 바탕으로 테스트 케이스를 추가할 수 있습니다.
it "masks numbers as strings" do
expect(mask('635914526')).to eq('*****4526')
end
지금까지 매우 간단한 경우만을 커버하는 테스트를 작성했는데요, 아래와 같은 케이스들을 포함하도록 테스트를 확장해봅시다.
위 케이스들을 모두 추가하고 나면
require_relative './../mask'
describe 'mask' do
it "masks regular text" do
expect(mask('simple text')).to eq('*******text')
end
it "masks an email address" do
expect(mask('example@example.com')).to eq('***************.com')
end
it "masks numbers as strings" do
expect(mask('635914526')).to eq('*****4526')
end
it "does not mask a string with 4 characters" do
expect(mask('asdf')).to eq('asdf')
end
it "does not mask a string with 3 characters" do
expect(mask('asd')).to eq('asd')
end
it "does not do anything with empty strings" do
expect(mask('')).to eq('')
end
it "masks symbols like regular characters" do
expect(mask('text .-@$ with symbols-')).to eq('*******************ols-')
end
end
위와 같은 모습이 됩니다.
코드의 현재 동작을 테스트 스펙으로 정리했다면 코드를 변경하기 전에 코드를 단순하게 만들 필요가 있습니다.
이제 테스트 코드를 작성했기 때문에 더욱 안전하게 리팩토링을 수행할 수 있습니다.
리팩토링의 모든 과정을 담기에는 글이 너무 길어지기 때문에, 어떤 방식으로 리팩토링을 할 수 있는지 또 리팩토링 후에 코드가 어떤 식으로 변화되는지 적어보겠습니다.
StringUtils
클래스로 옮긴다.class StringUtils
N_VISIBLE_CHARACTERS = 4
def self.mask(str)
return str unless should_mask(str)
if is_email(str)
mask_email(str)
else
mask_string(str)
end
end
def self.should_mask(str)
str.length > N_VISIBLE_CHARACTERS
end
def self.mask_email(str)
limit = str.length - N_VISIBLE_CHARACTERS
"#{'*' * limit}#{str[limit..-1]}"
end
def self.is_email(str)
str =~ URI::MailTo::EMAIL_REGEXP
end
def self.mask_string(str)
limit = str.length - N_VISIBLE_CHARACTERS
"#{'*' * limit}#{str[limit..-1]}"
end
end
여기서 주목할 점은 mask_email
을 별도 함수로 분리했다는 것인데, 이는 해당 부분의 코드를 이제부터 변경해야되기 때문입니다.
테스트 스펙을 갖추고 기존 코드를 정리했다면 이제 프로덕트 매니져로부터 전달받은 요청사항을 반영할 차례입니다. 이메일은
***************.com
이 아닌
*****@example.com
와 같이 마스킹되어야 한다는 요구사항입니다.
이 요구사항을 구현하기 위해 TDD를 사용해봅시다.
코드를 변경하기 전에 실패하는 테스트를 먼저 작성합니다.
저희는 이미 위에서 기존 동작을 검사하는 테스트 코드를 작성했기 때문에 기존 테스트 코드가 새로운 요구사항대로 동작하는지 검사하도록 코드를 수정합니다.
it "masks an email address" do
expect(StringUtils::mask('example@example.com')).to eq('*******@example.com')
end
코드를 수정하지 않았기 때문에 예상한대로 테스트가 실패합니다.
$ rspec
.F......
Failures:
1) StringUtils mask masks an email address
Failure/Error: expect(StringUtils::mask('example@example.com')).to eq('*******@example.com')
expected: "*******@example.com"
got: "***************.com"
(compared using ==)
# ./spec/string_utils_spec.rb:10:in `block (3 levels) in <top (required)>'
Finished in 0.08843 seconds (files took 0.2876 seconds to load)
8 examples, 1 failure
Failed examples:
rspec ./spec/string_utils_spec.rb:9 # StringUtils mask masks an email address
이제 코드가 의도한대로 동작하게끔 수정해줍니다.
def self.mask_email(str)
email_sections = str.split("@")
email_username = email_sections.first
email_domain = email_sections.last
"#{'*' * email_username.length}@#{email_domain}"
end
테스트가 통과하는군요!
$ rspec
........
Finished in 0.00385 seconds (files took 0.14405 seconds to load)
8 examples, 0 failures
새로운 요구사항을 코드에 반영하기 위해 몇 가지 단계를 거쳤는데요, 각 단계를 다시 살펴봅시다.
일련의 과정을 통해 테스트가 작성되지 않은 기존 코드를 변경해야되는 경우에 어떤 과정을 거쳐 안전하게 요구사항을 반영할 수 있는지를 살펴봤습니다. 여러분도 캐릭터라이제이션 테스트를 사용해 코드를 좀 더 안전한 방식으로 수정하는 테크닉을 익힐 수 있길 바랍니다.