해? 말아? 단위 테스트의 가성비를 판단하는 법

Eddy·2022년 6월 2일
17

단위 테스트에 대한 의문

단위 테스트. 개발을 배우다보면 자연스럽게 접하는 단어다.

단위 테스트는 중요하다.
들어서 안다.
구구절절 옳다.

하지만...
막상 테스트를 직접 짤 때는 느낌이 다르다.

그렇게 큰 앱도 아닌데, 굳이 테스트까지 해야 하나?
그냥 실행시켜보면 잘 되는지 알 수 있는데?
왠지 중복처럼 느껴지는 이 코드를 계속 작성해야될까?

그래도 한번 시도해봐야한다는 의무감은 있어서, 단위 테스트를 직접 프로젝트에 적용해보기 시작했다. 확실히 테스트를 다 짜고 나서 통과시키면, 초록색 성공 마크와 띠링- 소리가 꽤 기분 좋았다.

하지만 테스트 코드를 쓰는 건 생각보다 쉽지 않았다.

시행 착오도 많이 겪었다.

앱 만드는 것도 공부할 게 많은데, 테스트 짜는 것도 따로 공부를 해야한다니. 이거 짜다가 기능은 언제 구현하지?

터치 입력 같은 걸 테스트할 때는 '이런 것까지 테스트해야하나?' 라는 의문도 들었다.

도대체 테스팅을 어떻게 하는 게 효과적인 걸까? 궁금해서 열심히 찾아보게 됐다. 그 과정에서 내가 했던 고민과, 공부한 지식을 내 언어로 한번 정리해보려 한다.

단위 테스트를 해야하는 이유가 뭐냐?

이렇게 묻는다면 한 마디로 대답할 수 있다.

테스트가 있으면 변경이 두렵지 않다.

단위 테스트 없이 개발을 한다고 생각해보자.

테스트가 없으면 개발자가 시스템의 한쪽 부분을 수정했을 때, 다른 쪽이 모두 이상없이 돌아가는지 확인하기가 매우 어렵다. (특히나 규모가 크고 복잡하면 더욱 그렇다.)

아키텍처가 유연하고 설계가 훌륭해도 마찬가지다. 언제나 우리가 예상 못하는 사이드 이펙트가 있을 수 있고,우리는 버그 없음을 '확신'할 수단이 없다.

이러면 변경을 하면서 소프트웨어에 예상치 못한 버그가 쌓인다. 개발자는 코드를 변경할 때마다 심리적인 압박을 받는다.

'버그가 생기면 어떡하지? 바꾸지 말고 적당히 땜빵해놓자.'

여기서 악순환이 시작된다.

코드를 정리하다가 망가질 위험이 있으니, 코드를 정리하지 않는다.
코드를 정리하지 않으니 코드는 복잡해진다.
코드가 복잡하게 꼬여있으니 변경하기가 더 어려워진다.

단위 테스트는 이런 상황이 안 생기게 막아준다. 단위 테스트가 있으면, 코드를 리팩토링하고 잘 돌아가는지 확인한다. 기능을 추가하고 잘 돌아가는 지 확인한다.

리팩토링과 기능 추가를 할 때 부담이 가벼워진다.

단위 테스트는 코드를 유연하고, 확장가능하게 만드는 버팀목 역할을 한다.

그 장점 말고는 없어?

음? 이게 엄청난 장점이지만... 몇 가지 더 장점도 있다고 생각한다.

객체 간의 결합도가 낮아진다.

테스트를 하려면 소프트웨어를 이루는 모듈들을 분리하고 교체하기 쉬워야 한다.

객체는 어떤 일을 하기 위해서 다른 객체에 의존한다.

A 객체가 자신이 의존할 B 객체를 직접 내부에서 생성하고 호출한다면, 우리가 테스트하고자 하는 A 객체의 '단위'가 A가 의존하는 B 객체에 영향을 받는다.

A 객체가 의존하는 부분은 테스트 시점에 갈아끼울 수 있어야 한다. 우리가 테스트하려는 부분만 '고립'시킬 수 있어야한다. 그래야 제대로 된 테스트를 할 수 있다.

웨이트 트레이닝을 배워본 사람은 '고립'을 들어봤을거다.

내가 가슴 근육을 키우려면, 어깨 근육의 개입을 차단한 다음 벤치 프레스를 들어올려야 한다. 그래야 내가 원하는 근육에만 부하가 간다. 이걸 '고립'이라고 한다.

테스트의 '고립'도 같은 맥락이다.

테스트 시점에 의존하는 객체를 갈아끼우려면, 외부에서 의존성을 주입해줄 수 있는 구조가 되어야 한다. 이런 구조를 만들려다보면, 객체 간의 결합도를 낮추게 된다.

테스트 가능하게 만들다보면, 원래 코드의 퀄리티가 좋아지는 효과가 있다.

테스트를 잘 쓰면 좋은 설명서가 된다.

읽기 좋은 테스트를 만들면, 소프트웨어의 기능을 설명하는 설명서 역할을 한다.

알고리즘 문제를 풀 때를 떠올려보자. 알고리즘이 해야하는 일을 줄줄 설명해놓은 글을 읽는다.

원소의 개수가 n개이고, 중복되는 원소가 없는 배열이 주어질 때... 어쩌구... 여전히 잘 이해가 안 된다.

그러다 알고리즘 '입출력 예시'를 보면, '아하' 하고 불이 켜진다.

테스트 케이스도 마찬가지다. 테스트는 '이런 상황에서 이렇게 결과가 나와야 한다'는 예시를 보여준다. 덕분에 코드의 동작을 이해하기가 쉬워진다.

TDD를 할 수 있다.

나는 처음에 테스트를 하면, 다 TDD라고 부르는 줄 알았다.

나중에 알고보니, TDD는 그냥 테스팅과는 좀 달랐다. 실제 코드를 짤 때 사용하는 개발 방법론에 가까웠다.

TDD를 하려면 코드를 쓰기 전에 테스트를 작성한다. 이 코드가 '무엇을 해야하는지' 코딩을 하기 전에 생각하도록 강제한다.

테스트가 통과하도록 코드를 짠다. 리팩터링을 한다. 테스트를 다시 작성한다.

프로그래머는 좀 더 깔끔하고 작게 쪼개진 코드를 쓸 수 있게 된다.

하지만 '단위 테스팅'은 테스트의 시점을 강제하지 않는다. 대개는 코드를 쓰고 나서 테스트를 작성한다.

TDD와 단위 테스팅은 서로 다른 개념이지만, 어쨌든 TDD는 테스트에 의존하는 개발이다. TDD를 하기 위해 테스트 케이스는 기본 조건이라 할 수 있다.

테스트 작성의 비용

사실 테스트가 중요하다는 것은 누구나 공감한다. 마치 건강을 위해 금연 금주 하세요~ 같이 당연한 말처럼 들린다.

하지만 막상 테스트를 적용하다보면, '이걸 정말 해야하나?'라는 생각이 든다.

테스트를 작성하는 게 그렇게 쉽지 않다. 제대로 된 테스트를 작성하려면 상당한 '노력(비용)'을 들여야 한다. '제대로 된 단위 테스트'가 무엇인지는 오늘의 주제는 아니므로, 다른 글에서 논의하도록 하자.

아무튼 하고 싶은 말은, 제대로 된 테스트 코드는 상당히 노력의 결과물이라는 점이다.

우리는 테스트의 효과를 알고 있고 많이 듣는다. 하지만 무조건 테스트를 한다고 다 좋은 건 아니다. 테스트가 정말 필요한지 알려면, 테스트를 짜는 데 드는 비용도 명확하게 알아야 한다.

테스트의 '비용'은 어떻게 알 수 있지?

'선택적 단위 테스트'라는 아티클이 하나 있다. 스티브 샌더슨(Steve Sanderson)이 2009년에 쓴 글이다. 내가 본 단위 테스트 글 중에서 가장 도움이 많이 됐다.

이 글은 테스트의 '가성비'를 가늠하는 간단한 기준을 제시한다.

테스트의 가성비는 2가지만 보면 대충 각이 나온다. 그 2가지는, 코드의 '예측가능성''외부 의존성'이다.

예측가능성이 낮을수록 테스트의 효과는 뛰어나다.

뻔한 코드는 테스트의 효과가 적다.

'뻔한 코드'(Obvious code)란 무엇인가. 코드를 한번 딱 보고, 제대로 작동할지 안 할지 알 수 있는 코드다. 이런 코드는 경우의 수가 적다. 복잡한 로직이 없다.

단순히 프로퍼티에 값을 세팅한다든지, 저장된 값을 가져온다든지하는 식이다.

예를 들어, 이런 메서드를 생각해보자. 레이블 요소에 텍스트가 안 들어있으면, Placeholder를 넣어주는 코드다.

func setPlaceholder(_ label: UILabel) -> UILabel {
    if label.text == nil {
        label.text = "Placeholder"
    }
    return label
}

복잡한 로직이 없다. 흘끗 봐도 제대로 작동할지 아닐지 예측하기가 쉽다.

이 코드에도 테스트를 작성할 수 있다. label을 넣어서, placeholder가 잘 들어갔는지 하는 식으로. 하지만 별다른 이득이 없다는 것이다.

반면 까다로운 비즈니스 로직을 코딩하거나, 복잡한 문자열을 파싱하는 경우라면 어떨까?

이런 코드는 코드를 딱 보고 결과를 예측하기 쉽지 않다.
우리가 떠올릴 수 있는 경우의 수보다 훨씬 더 많은 경우의 수가 있을 것이다.

얼마 전 캘린더를 만들어야 할 일이 있었다. 특정한 달의 날짜와 요일을 구하는 함수 여러개가 필요했다. 달력, 날짜 관련 함수는 경우의 수가 정말 많다. 시간대(TimeZone)이나 윤달, 윤년 같은 예외 케이스도 많다.

이런 함수는 일단 잘 돌아가도, 정말 모든 케이스를 커버했는지 불안해진다. 나중에 다시 봐도 제대로 동작하는지 보려고해도 꽤 시간과 노력이 든다.

이런 코드가 바로 테스트가 필요한 코드다.

코드를 모두 이해하지 않아도 된다. 발생할 수 있는 케이스를 모두 생각해내려고 애쓰지 않아도 된다.

테스트 케이스가 빠르게 확인을 해줄 테니까. 이럴 때 단위 테스트는 매우매우 효과적이다.

어떤 코드에 대한 단위 테스트의 가치를 가늠하고 싶다면, '로직이 단순한가 복잡한가' 혹은 '코드가 얼마나 예측하기 쉬운가'를 보면 된다.

외부 의존성이 많을 수록 테스트의 비용은 커진다.

테스트를 깔끔하게 작성하는 것, 그것을 유지보수하는 것도 꽤 어려운 일이다.

하지만 스티브는 베스트 프랙티스를 따르면 그런 문제들은 충분히 해결가능하다고 한다. 실전에서는 큰 문제가 아니라는 것이다.

진짜 테스트를 어렵게 만드는 주범은 따로 있다.
바로 '외부 의존성'이다.

물론 모든 의존성이 나쁜 건 아니다. 하지만 의존성 중에서도 테스트를 어렵게 만드는 것들이 있다.

웹 서버 요청에 의존한다든지,
타이머를 사용한다든지,
파일 시스템에 접근한다든지,
전역적인 싱글톤 객체를 사용한다든지,

이런 외부 의존성은
테스트를 느리게 만들거나 (Not fast),
부수 효과를 만들고 (Not isolated),
테스트할 때마다 결과가 달라지도록 만든다. (Not repeatable)

우리가 테스트하려는 함수가 외부에 많이 의존한다면, 이를 대신할 가짜(테스트 더블)을 만들어내야한다. 이걸 만드는 것도 코드의 구조에 따라서 굉장히 복잡해질 수 있다. 여러가지 고급 기법이나 외부 라이브러리를 사용해야 한다.

게다가 더 큰 문제가 있다.

의존성을 많이 가지는 코드일수록 변경될 가능성도 높다.

특정 코드가 의존하는 다른 모듈은 시간이 지나면 인터페이스가 바뀔 가능성이 있다. 그때마다 해당 코드도 거기에 맞게 변경해야 한다.

코드가 자주 바뀌면 단위 테스트도 자주 바뀌어야 한다. 의존성을 아무리 잘 분리하고 테스트가능하게 만들어도, 이걸 유지보수하는 비용은 피할 수 없다.

따라서 테스트의 비용은 거의 대부분 '외부 의존성이 얼마나 많은가'에 달려있다.

단위 테스트를 해야할 때와 말아야할 때를 이 두가지로 구분할 수 있다는 건가?

바로 그 말이다.

이 매트릭스를 보자. 테스트의 비용과 효과를 기준으로 나눈 사분면이다. 스티브의 그림을 번역해서 가져와봤다.

예측이 어렵고 의존성은 적은 코드

왼쪽 위는 '예측이 어렵고 의존성은 적은 코드'다. 스티브는 이 영역을 '알고리즘'이라고 이름 붙였다.

대출 이자를 계산하는 등 복잡한 비즈니스 룰을 담은 메서드. 복잡한 데이터를 파싱하는 함수 등이 들어간다. 외부 참조는 적은 편이다. 말 그대로 '알고리즘'스럽다.

이런 코드는 단위 테스트를 꼭 해야한다. 테스트에 들어가는 비용이 높지 않으면서, 효과는 크다.

예측이 쉽고 의존성은 많은 코드

오른쪽 아래는 '예측이 쉽고 의존성은 많은 코드'다. 스티브는 이 영역을 '코디네이터'라고 이름 붙였다.

주로 여러 개의 모듈을 알고, 그 사이에서 데이터를 주고받는 코드다. 로직 자체는 단순하지만, 외부 의존성은 굉장히 많다.

이 영역은 단위 테스트를 안 하는 게 좋다. 테스트를 만들고 유지보수하는 비용이 너무 크기 때문이다. 차라리 효과가 높은 곳에 그 시간을 쓰는 게 낫다.

예측이 쉽고 의존성도 적은 코드

왼쪽 아래는 예측이 쉽고 의존성도 적은 코드다. 값을 가져오거나 설정하는 게터(Getter)/세터(Setter) 코드 등이 여기 속한다.

이 영역은 뭐 하나마나 큰 차이가 없다. 그렇게 어렵지도 않고, 그렇게 효과가 크지도 않다.

예측도 어렵고 의존성도 많은 코드

오른쪽 위는 '예측도 어렵고 의존성도 많은 코드'다.

여러 모듈과 협력을 하면서도, 동시에 로직도 복잡하게 엮여있는 코드다.

잠깐 iOS 개발의 사례를 들어보자. iOS 앱에선 이 '코디네이터' 영역에 들어갈만한 대표주자가 있다. 바로 뷰 컨트롤러다.

컨트롤러는 뷰와 모델 사이의 중개자 역할을 담당한다. 뷰와 모델의 재사용성을 높이기 위해 앱에 특화된 로직을 배치한다. 따라서 신중하게 코딩하지 않으면, 여러 모듈에 대한 의존성도 많이 가지면서, 로직도 복잡한 코드가 되어버린다.

이런 경우에는 어떻게 해야할까? 이런 코드는 비용이 많이 들지만, 테스트를 안 하기에는 너무 위험하다.

답은 '리팩토링'이다. 외부에 의존하는 부분과 복잡한 로직을 담은 부분을 별도의 객체나 함수로 분리한다.
다시 말해 '알고리즘' 코드와 '코디네이터' 코드로 분리시킨다.
그리고 복잡한 로직을 담은 알고리즘 코드만 테스트를 짠다.

iOS의 뷰 컨트롤러도 강하게 결합된 의존성이 많아 테스트가 까다롭다.
그럴 때 컨트롤러를 억지로 테스트하려고 하지 말자. 테스트해야하는 로직만 잘 분리해내자. 가성비가 나오는 테스트만 작성할 수 있다.

다른 객체를 알거나, 복잡한 로직을 수행하거나 둘 중 하나만 하게 하자.

요약 정리

  • 예측이 어렵고 의존성이 적은 코드 ("알고리즘") -> 테스트 작성
  • 예측이 쉽고 의존성은 많은 코드 ("코디네이터") -> 테스트 작성하지 않음
  • 예측도 쉽고 의존성도 적은 코드 -> 노 상관
  • 예측도 어렵고 의존성도 많은 코드 -> '알고리즘'과 '코디네이터'로 분리

모든 경우의 수와 모든 코드를 테스트하지 않아도 되는 거야?

테스팅 전문가들도 '코드 커버리지 100%'는 의미없다고 말한다.

'코드 커버리지가 부족하다'는 건 유의미한 신호다. 빼먹은 테스트 케이스들을 찾을 수 있다.

하지만 '코드 커버리지가 90%이니 좋은 코드다'는 성립하지 않는다. 코드 커버리지는 단순하고 매력적인 지표다. 하지만 '테스트를 위한 테스트'를 만들기 십상이다.

효과뿐만 아니라 비용을 판단할 줄 아는 것. 코드의 예측가능성과 외부 의존성을 보고 적절한 기준을 세우는 것. 그리고 테스트를 하기로 했다면 '제대로' 할 줄 아는 것.

개발자가 갖춰야할 가장 중요한 단위 테스팅의 기술이라고 생각한다. 이게 단위 테스트에 대해 가졌던 의문에 대해 나름대로 내린 결론이다.

요약

  • 단위 테스트가 있으면 변경이 두렵지 않다. 잘 짠 테스트 코드는 리팩토링과 기능 확장을 쉽게 만들어준다.
  • '빠르고 믿을 수 있는 테스트'를 짜려면 상당한 노력이 필요하다.
  • 테스트도 비용 대비 효과를 고려해야 한다.
  • 테스트의 효과는 코드의 예측 가능성으로 가늠할 수 있다.
  • 테스트의 비용은 외부 의존성의 개수로 가늠할 수 있다.
  • 예측이 어렵고 의존성이 적은 코드는 꼭 테스트를 하고, 예측이 쉽고 의존성이 많은 코드는 굳이 테스트하지 않는다.
profile
개발 지식을 쉽고 재미있게 설명해보자. ▶️ www.youtube.com/@simple-eddy

0개의 댓글