[번역] 캐릭터라이제이션 테스트로 레거시코드 길들이기

gompro·2021년 5월 29일
0
post-thumbnail

이 글은 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

안타깝게도 현재 해당 코드에 대한 테스트 코드는 한 줄도 없습니다. 즉, 코드를 수정했을 때 코드의 동작이 깨질지 알 수 없다는거죠. 여기서부터 어떻게 해야될까요?

Legacy code를 다루는 법

가장 먼저 할 일은 가능한한 코드에 손을 대지 않고 테스트부터 작성하는 것입니다. 이렇게 함으로써

  • 기존에 존재하는 코드를 더 잘 이해할 수 있습니다.
  • 코드를 바꿔나가면서 내가 수정한 부분이 테스트를 깨뜨리는지 확인할 수 있습니다. (피드백)

간단한 테스트부터 작성해봅시다.

첫 번째 테스트

어떤 테스트 라이브러리를 사용하든지 상관없지만 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

더 많은 테스트 추가하기

지금까지 매우 간단한 경우만을 커버하는 테스트를 작성했는데요, 아래와 같은 케이스들을 포함하도록 테스트를 확장해봅시다.

  • 짧은 4자리 스트링
  • 짧은 3자리 스트링
  • 빈 스트링
  • 대쉬(-)나 점(.)이 포함된 스트링

위 케이스들을 모두 추가하고 나면

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을 별도 함수로 분리했다는 것인데, 이는 해당 부분의 코드를 이제부터 변경해야되기 때문입니다.

TDD를 통한 코드 수정

테스트 스펙을 갖추고 기존 코드를 정리했다면 이제 프로덕트 매니져로부터 전달받은 요청사항을 반영할 차례입니다. 이메일은

***************.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

돌아보기

새로운 요구사항을 코드에 반영하기 위해 몇 가지 단계를 거쳤는데요, 각 단계를 다시 살펴봅시다.

  • 이해하기 힘든 기존 코드는 프로덕션 환경에서 사용 중이기 때문에 코드의 현재 동작을 함부로 수정해서는 안 됩니다.
  • 캐릭터라이제이션 테스트를 통해 코드의 현재 동작을 테스트 스펙으로 정리합니다.
  • 충분한 테스트 스펙을 작성한 뒤, 기존 코드에 대한 리팩토링을 진행합니다.
  • 테스트 스펙이 새로운 요구사항을 테스트 스펙에 반영했고, 테스트가 실패합니다.
  • 마지막으로 코드를 수정하여 모든 테스트 코드가 통과될 수 있도록 만듭니다.

일련의 과정을 통해 테스트가 작성되지 않은 기존 코드를 변경해야되는 경우에 어떤 과정을 거쳐 안전하게 요구사항을 반영할 수 있는지를 살펴봤습니다. 여러분도 캐릭터라이제이션 테스트를 사용해 코드를 좀 더 안전한 방식으로 수정하는 테크닉을 익힐 수 있길 바랍니다.

profile
다양한 것들을 시도합니다

0개의 댓글