카테고리 없음

Test Code 작성을 고민하는 여러분에게

Glen_check 2024. 9. 13. 23:41

포스팅을 작성하기 앞서, 이번 글은 필자의 경험과 주관이 많이 반영되어 있는 글로 구구절절하고, 잘못된 내용이 포함되어 있을 수 있습니다. 또한 테스트 코드 작성 방법에 대해 다루지 않고 있습니다.

참고하여 읽어주신다면 너무 감사하고, 잘못된 내용을 바로잡아주신다면 미리 정말 정말로 감사합니다.

 

Test Code는 왜 중요할까

Toy Project, Team Project에서 테스트 코드를 작성한 경험이 있으나 근본적으로 작성해야 하는 이유를 알고 작성했는지 물어본다면 절대 아니다. '테스트 코드를 잘 이해하고, 작성할 줄 아는 것이 개발자가 가질 수 있는 최고의 무기 중 하나'라는 말에 이력서에 "Test Code 저도 할 줄 알아요!"를 말하고 싶었다.
무엇보다 기능 구현을 위한 코드 작성 시간보다 더 많은 시간을 테스트 코드 작성에 할애하는 것 같아 손이 잘 안가더라.

 

이런 내가 왜 테스트 코드를 주제로 글을 포스팅하고 있을까.

테스트 코드의 중요성을 강하게 깨달은 사건이 있었다. 이전 팀 프로젝트 진행 시 내부 비즈니스 로직이 복잡해질수록 하나의 API를 테스트하는데 길게는 15-20분까지 소요가 되더라.

'A 아이디로 회원가입 진행 후 로그인하고 팀을 만들어 B가 속한 팀에 경기를 신청하고 로그아웃하고, B 아이디로 로그인 후 A의 경기 신청을 거절했을 때 원하는 의도대로 작동하는지 확인'. 하나의 API를 테스트하기 위해 다음과 같은 과정을 계속 반복하고 있었다.

만약 하나의 API 안 여러 가지 규칙이 있다면, 하나의 API를 확인하기 위해 이 과정을 몇 번을 거쳐야 하는지. 솔직히 개발보다 기능이 잘 작동하는지 점검할 때가 가장 힘들었다.

새롭게 작성한 코드가 문제없이 잘 작동한다고 가정해 보자. 갑자기 다른 팀원이 기존에 잘 작동하던 API가 갑자기 작동이 안 되는데 혹시 누가 건드렸냐고 연락이 온다. 개발이 진척될수록 기능 오류는 여기저기서 발생하기 시작하고난리도 이런 난리도 없었다.

그래서 테스트 코드를 미리 작성해 놓으면, 정책이 바뀌거나 리팩토링이 이루어질 때마다 코드가 제대로 작동하지 않는 문제가 발생했다.

 

여러가지 문제상황을 경험해 보고 난 뒤, "Test Code 저도 할 줄 알아요!" 이력서에 어필하려 한 나 자신을 되돌아보며 테스트 코드를 탐구해 보려 한다. 왜냐하면 다음으로 들어갈 프로젝트는 테스트 코드를 정말 잘해내 보고 싶다.

 

Unit Test 기본적인 접근 방식 이해하기

런던파는 테스트 코드를 작성하는 데 있어, '모킹(Mock)'을 적극적으로 활용하는 방법론으로, 테스트 대상 시스템을 협력자에게서 격리하는 것을 의미한다. 이 접근법은 객체 간의 상호작용을 강조하고, 객체들이 서로 잘 협력하는지를 확인하는 데 중점을 둔다.

핵심은 테스트하기 어려운 외부 의존성을 모킹을 통해 단절하고, 단위 테스트에서 각각의 컴포넌트를 독립적으로 테스트하는 것이다.

 

여기서 의문이 들었던 것이 모킹(Mock)을 통해 객체 간의 협력을 분리하고, 독립적으로 테스트하는 데 왜 상호작용을 강조하고 객체들이 잘 협력하는지 확인하는 것이 중점일까 이해가 잘 되지 않았다.

 

객체들이 잘 협력하는가, Mock

1. 객체 간의 협력 검증

모킹을 사용하는 이유는, 테스트하고자 하는 객체의 협력자(의존성)들이 실제로 잘 협력하고 있는지를 독립적으로 검증하기 위함이다. 예시로 어떤 서비스 객체가 특정 동작을 수행하기 위해 의존하고 있는 객체의 메서드를 호출해야 한다면, 이 상호작용이 정확히 일어나고 있는지를 모킹을 통해 확인할 수 있다.

2. 의존성 격리

모킹을 통해 실제 객체 대신 가짜 객체를 사용하여 테스트 대상 객체가 자신의 역할을 정확히 수행하는지, 다른 객체와의 상호작용이 의도대로 이루어지는지를 독립적으로 테스트할 수 있다.

모킹을 사용하면 테스트 대상 객체의 외부 의존성을 격리할 수 있어 다른 객체가 실제로 어떻게 동작하는지에 영향을 받지 않고, 테스트 대상 객체의 상호작용만 검증이 가능하다.

3. 상호작용에 집중

중요한 것은 객체가 '어떻게' 일을 수행하느냐보다는, '무엇을' 수행하는지다. 쉽게 설명해 객체가 내부적으로 어떻게 일을 처리하는지는 중요하지 않으며, 객체가 올바른 상호작용을 통해 무엇을 수행하는지가 더 중요하다.

 

좋은 Test Code는 무엇인가

좋은 테스트 코드를 설명하기 전에 '어떤 목적으로 테스트 코드를 작성하는지', '팀의 정책이 무엇인지' 기준을 잡고 들어가는 것이 좋을 것 같더라. 왜냐하면 좋은 테스트 코드의 모든 조건을 충족할 수는 없다. 해당 내용에서는 어떤 목적으로 테스트를 들어가면 좋을지 기준을 제시할 수 있으면 한다.

회귀 방지

회귀 버그: 기존에 제대로 동작하던 소프트웨어 기능에 문제가 생기는 것을 의미하며, 코드 베이스가 커질수록 잠재적인 버그에 더 많이 노출되어 이를 방지하기 위해서는 테스트가 가능한 많은 코드를 실행하는 것이 중요, 즉 테스트 커버리지를 넓혀 회귀 버그를 방지하는 것이 중요하다.

 

리팩토리 내성

리팩토리 내성은 기존에 작성해 놓은 코드가 테스트 실패하지 않으면서 리팩토링을 진행할 수 있는지에 대한 척도를 의미한다.

리팩토링 후 기존 기능이 정상적으로 동작하지만, 테스트 코드는 실패하는 상황을 거짓 양성(false positive)이라고 한다. 거짓 양성 자체는 이상적으로 피해야 할 상황이지만, 이 상황에서 얻을 수 있는 몇 가지 간접적인 장점(?)이 존재한다. 개인적으로 앞으로 개발하면서 무조건 한 번씩은 겪을 상황으로, 어떻게 하면 해당 상황을 극복할 수 있을지 상기해 보면 좋을 듯하다.

 

1. 테스트 코드의 엄격성 점검

거짓 양성이 발생하면 테스트 코드가 너무 엄격하거나, 테스트 조건이 코드의 유연성을 고려하지 않고 작성되었을 가능성이 있어 이 상황을 통해 테스트 코드가 얼마나 세밀하게 작성되었는지를 되돌아보고, 개선할 기회가 될 수 있다.

예시로 리팩토링 후 코드 구조는 변경되었지만, 기능은 동일하게 동작하는 상황에서 테스트가 실패했다면 테스트 코드가 코드의 특정 구현에 너무 의존적이었는지를 확인하고 수정할 수 있다.

 

2. 테스트 코드의 유지보수성 향상 기회

테스트 코드를 작성하면서 주관적으로 생각하는 가장 중요한 것은 '내가 어떤 것을 테스트하려고 하는지'다. 이 경험을 통해 테스트 코드의 가독성을 높이고, 테스트가 실제로 검증하려는 내용이 무엇인지 더 명확히 할 수 있다.

 

반드시 경계해야 할 상황은, 거짓 양성을 마주할 때 마다 실패의 명확한 이유를 찾지 못하고 무감각하게 테스트를 수정하는 것이다. 그리고 해당 상황이 반복되어 테스트 코드를 더 이상 믿지 못한다면 테스트 코드 작성의 의미가 떨어질 것이 분명하다.

그래서 다음의 상황을 마주할 때마다 반드시 테스트 실패 원인을 명확하게 파악해야 한다. (스스로에게 하는 말)

 

구현 세부 사항보다 최종 결과를 테스트하는 것

처음 테스트 코드를 작성할 때 가장 많이 한 실수로 내부 모든 과정을 테스트해보려는 것이다. 테스트 코드에서 검증해야 하는 부분은 '입력'이 들어왔을 때, 기대하는 '출력;으로 결괏값이 나오는지만 확인하면 된다.
구현 부분에서 A->B->C 흐름으로 동작하는지에 대해서까지 테스트를 한다면, 동작 순서가 변경되었을 때 테스트가 깨지게 된다.

쉽게 깨지지 않는 테스트를 위해서는 구현 세부 사항보다는 최종 결과에 집중해서 테스트를 하는 것이 좋다.

 

빠른 피드백과 유지 보수성

말 그대로 빠르게 테스트를 진행이 가능하고, 빠르게 수정할 수 있는 것을 의미한다. 개발의 속도가 중요한 경우 가장 적합한 테스트 진행의 목적이 될 수 있다.

 

모든 조건을 만족하는 Test Code를 작성하는 것이 어려운 이유

4가지 조건을 모두 만족하는 이상적인 테스트는 있을까?

4가지 특성에 대한 가치를 곱한 결과가 테스트에 대한 가치가 된다. 즉, 하나의 특성이 0이 되면 전체 테스트의 가치는 0이 된다.

회귀 방지, 리팩토링 내성, 빠른 피드백 특성은 서로 상호 배타적이기 때문에 셋 중 하나를 반드시 희생해야 하는데, 구체적으로 예시를 살펴보자.

* 코드를 예시로 설명하지 않아 이해가 잘 안될 수도 있, 글로 최대한 자세히 풀어보겠습니다. 추후 테스트 코드 작성 시 실제 예시를 통해 이해하고 명확하게 설명해보겠습니다.

 

1. 리팩토링 내성 vs 회귀 방지

기존 코드를 리팩토링하는데, 코드의 가독성을 높이기 위해 A 클래스의 메서드를 내부적으로 더 작은 메서드로 나누고, 몇 가지 알고리즘을 개선.

리팩토링을 진행했지만, 모든 테스트는 통과. 여기서 테스트 코드가 리팩토링 내성을 가지고 있다고 생각할 수 있다. 왜냐하면 메서드의 내부 구조를 변경했지만, 테스트 코드가 여전히 잘 작동하기 때문. 하지만 만약 테스트가 내부 구현에 너무 의존하고 있었다면, 리팩토링 과정에서 테스트를 함께 수정해야 했을거고, 이럴 경우 리팩토링 내성이 떨어진다고 볼 수 있다.

반대로 테스트가 리팩토링 내성을 가지려면 구현의 세부사항에 덜 의존적이어야 하는데, 이럴 경우 내부 구현의 작은 변화가 테스트에서 감지되지 않을 수 있기 때문에 회귀 방지가 약화될 수 있다.

 

UI, 데이터베이스, 외부 어플리케이션을 포함한 모든 시스템 구성 요소를 테스트하는 End-To-End Test 방식을 통해 리팩토링 내성과 회귀 방지를 모두 챙길 수 있으나, 테스트가 많기 때문에 빠른 피드백이 불가능

- 많은 코드를 테스트하기 때문에 성공적인 회귀 방지
- 최종 사용자 관점에서 테스트를 진행하기 때문에 세부 구현 사항을 최대한 제거하여 
리팩토링 내성도 우수

 

2. 빠른 피드백 vs 회귀 방지

테스트 커버리지를 높이고 회귀 방지를 강화하기 위해, 각 메서드의 다양한 엣지 케이스와 예외 상황까지 모두 테스트를 진행한다고 가정. 이로 인해 테스트 케이스의 수가 많이 늘어나지만, 테스트를 실행하는 데 많은 시간이 소요되며 생산성이 떨어질 수 있다.

빠른 피드백을 중시하여 테스트 케이스의 일부를 줄이거나, 아주 작은 단위의 테스트 대신 중요한 기능에 집중하는 통합 테스트를 우선시한다면 회귀 방지 기능이 약해질 수 있다.

 

현실적으로 보는 이상적인 테스트는 리팩토링 내성을 최대한으로 하면서 상황에 따라 회귀 방지와 빠른 피드백을 적절히 조절하는 것이라고 생각한다.

결론

Test Code를 작성하면서 "근본적으로 명확하게 어떤 것을 테스트하는지" 정의할 수 있어야 하고, "테스트 코드를 통해 어떤 이점을 챙길지" 우선적으로 고려해야 한다고 생각합니다.

잘 생각해 보면, 현업에 들어가 새로운 서비스를 처음부터 개발하기 보다 기존 기능에 대한 리팩토링과 기능 추가, 유지 보수가 주 업무가 될 것이고, 기존에 구현해놓은 기능들에 영향을 주지 않는 것이 가장 중요하다고 생각합니다.

그러니 테스트 코드에 사실상 더 많은 시간을 소모하는 것이 이상적이라는 생각이 들었습니다. 이후 구체적인 Test Code의 Case들을 들고와, 더 자세히 풀어보는 포스팅을 작성해보겠습니다.

감사합니다.