개발자가 올바른 TDD를 적용하는 방법에 대해서
(Table of Contents)
0. TDD란 무엇인가
1. Test의 종류
2. 적절한 TDD의 사용
3. TDD의 작성 방법
4. TDD의 장점
5. TDD의 단점
6. TDD의 필요
7. Test 원칙
안녕하세요. 라이앤캐처스 프론트엔드 개발자 김순홍입니다.
들어가며
TDD를 어떻게 사용하는지에 대한 기술적인 접근보다는 TDD란 무엇인지, 프로젝트에 TDD를 적용하면 어떤 이점이 있고 적용하기 위해 어떤 검토가 필요한지 어떻게 적용하는 것이 올바른 TDD 인지 기술하도록 하겠습니다.
TDD란 무엇인가
- TDD란 테스트 주도 개발(Test-Driven Development)의 약자로서 코드를 작성하기 전 테스트 코드를 먼저 작성하는 개발 방법론 중 하나입니다.
- 테스트란 제품 혹은 서비스의 품질을 확인하는 과정의 총칭입니다. 즉, 우리가 생산해 내는 제품(프로그램)의 의도치 않는 버그를 찾아내는 과정입니다. 우리가 생산해 내는 제품이 어떤 환경이나 플랫폼 위에서 동작하기 원하는지에 따라 다양한 방식으로 테스트를 진행할 수 있습니다.
Test의 종류
- 테스트 방법엔 대표적으로 독립적인 함수, 모듈, 클래스 등을 개별적으로 테스트하는 단위 테스트와 여러 개의 단위를 묶였을 때 상호작용을 테스트하는 통합 테스트 UI 테스트, 사용자 테스트라고 불리는 E2E 테스트가 있습니다.
- 자동차를 예를 들어 설명하면 바퀴 하나, 핸들 하나를 검사하는 건 단위 테스트 이 핸들과 바퀴의 상호작용 및 각 기능의 양립성을 테스트하는 게 통합 테스트. 그리고 완성된 차를 고객에게 인도하기 앞서 시운전해 보는 과정을 e2e 과정이라고 할 수 있습니다.
- 이러한 테스트들은 테스트가 간단할수록 비용과 작성 난이도의 코스트가 저렴하고 고도화된 테스트일수록 작성 비용이 상승하고 작성 난이도가 어려워집니다. 또한 효율성 측면에서도 개발을 할 때 유닛 테스트는 즉각적인 피드백을 통해 버그나 문제점을 즉시 확인할 수 있지만 이미 상호작용을 하고 있거나 사용자가 사용하는 시점에는 버그를 찾아내거나 문제를 추적하는 과정에서 개발 비용의 상승을 야기할 수 있습니다.
적절한 TDD의 사용
- 목적에 따라 어느 부분에 더 집중하여 테스트를 진행하는지에 따라 그 모양이 트로피 모양이거나 구형의 모양이 될 수도 있지만 일반적으로는 피라미드 형태의 테스트를 진행하게 됩니다.
2. 자동차의 바퀴가 튼튼하다고 해서 자동차의 성능이 담보되는 것이 아니며, 에어백의 품질만으로는 자동차의 안전이 담보되는 것이 아니기 때문에 TDD가 너무 좋다고 해서 지나치게 남발하여 과용하는 것은 오히려 의욕이 떨어지거나 테스트를 진행하면 할수록 비효율에 가까워지기도 하니 어느 정도의 수준을 유지해야 할지 잘 결정하여 선택하여야 합니다.
TDD의 작성 방법
- 단위 테스트, 통합 테스트, E2E 테스트, AB 테스트, 스트레스 테스트, 함수, 기능, UI, API 테스트 등등 다양한 종류와 이름의 테스트가 있어 어렵게 느껴질 수 있지만 모든 테스트는 단 하나의 프로세스로 움직이게 됩니다.
- Given, When. Then 단계를 거쳐서 특정한 기능을 수행하는 실패가 가능한 테스트 코드를 작성합니다.
- 그럼 당연히 코드가 없이 실행된 테스트 코드는 실패하게 됩니다 이 후 작성된 테스트 코드가 통과될 수 있도록 우리가 원하는, 의도한 기능을 수행할 수 있는 제품코드를 작성합니다.
- 그 후 테스트를 실행해 보면 앞서 작성한 테스트 코드는 성공하게 됩니다.
- 그러고 나서 다시 필요한 기능의 테스트를 작성하고 테스트를 통과시키고 다시 작성하고 실패하고 통과시키고의 반복이며, 이것이 테스트 주도 개발 TDD의 주요 작성 방법입니다.
- 테스트 코드를 주기적으로 실행하여 요구사항에 맞게끔 제품이 동작하는지 검사하고 실패했다면 보수하는 작업의 연속과정입니다.
- 여기서 중요한 점은 코드를 전부 작성하는 게 아닌 앞서 작성한 테스트를 겨우 통과할 정도의 최소한의 코드만 우선 작성한다는 점입니다.
- 이 테스트 코드 기반으로 작성된 코드가 우리가 원하는 기능을 모두 수행하여 테스트를 통과하게 되면 그 이후 코드를 다듬는 리팩토링 작업을 시작하게 됩니다. 테스트 코드를 작성해놓는다면 코드를 어떻게 수정하더라도 이 코드는 내가 원하는 데로 동작하는 코드임을 테스트 코드가 보증한다는 점에서 우리는 테스트 코드 작성 전보다 조금 더 자신 있고 과감하게 그리고 계획적으로 리팩토링이 가능해지며 즉흥적인 리팩토링에서 오는 실수를 막아줍니다.
TDD의 장점
- 요구사항의 이해
앞서 말씀드린 테스트 주도 개발, 테스트가 우선적으로 수행되려면 우리는 당연히 우리가 작성하고자 하는 코드의 요구사항에 대한 철저한 이해가 기반이 되어야 합니다. 이는 자연스럽게 설계 단계에서의 디테일의 향상 효과를 기대할 수 있습니다. 우리가 원하는 기능이 무엇인지, 어떤 기능을 구현해야 하는지에 대한 테스트가 우선시 되는 부분이니 너무나 당연한 부분입니다. 코딩을 본격적으로 시작하기 전 모든 요구사항에 대한 점검과 이해의 시간을 가질 수 있다는 점, 그리고 우리가 예상하는 기능이 수행되길 기대하기 때문에 조금은 사용자 입장에서 코드를 작성할 수 있게 됩니다.
2. 문서화
기본적으로 우리가 TDD를 적극적으로 도입했다 함은 다만 코드 한 줄이 작성되거나 수정되어도 테스트 코드가 붙어 있다는 가정이 전제가 되어있어야 할 것입니다. 테스트 코드를 작성할 때 우리는 우리가 원하는 기능의 설명을 테스트 코드에 작성하여 넣게 되는데 이러한 부분이 모이고 모여 문서화의 기능을 하게 됩니다.
내가 코드 리뷰를 요청하거나 혹은 남이 나의 코드를 보고 수정해야 할 일이 생겼을 때 우리는 가끔 이 코드가 어떤 기능을 어떻게 수행하는지 파악하는 데만 많은 시간을 허비하기도 하는데 문서화 함으로서 이코드가 무슨 역할을 하고 어떤 결과를 도출해낼지 알아볼 수 있게 하고 한눈에 코드의 기능과 역할을 파악하는 효과를 기대할 수 있게 됩니다.
이는 자연스럽게 협업하는 과정에서 서로 간 오해의 여지를 줄여주며 기능을 요청하는 입장에서 명세표의 역할을 할 수 있게 되어 기능을 구현하는 입장의 편리를 또한 도모하게 됩니다.
3. 이슈의 예측
작성자가 원하지 않는 방향으로 기능이 동작했을 때 테스트 과정에서 이를 한번 걸러낼 수 있다는 점입니다. 자잘한 코드의 버그들을 단위 테스트를 통해 걸러내고 이는 자연스럽게 높은 확률의 통합 테스트 통과율을 기대할 수 있게 합니다. 이 단순한 메커니즘이 좀 더 상위 레벨의 테스팅을 할 때 불필요한 자원들을 사전에 차단해 주는 효과를 기대할 수 있게 해주며 이는 생상성 향상, 고도화의 근간을 마련해 주기도 합니다.
TDD의 단점
1. 러닝커브
단점으로는 약간의 번거로움과 귀찮음을 동반합니다. 또한 개발자 모두가 TDD에 익숙하기 전에는 개발 속도가 상대적으로 더디게 느껴질 수가 있습니다. 어떤 점이 좋은지 그리고 언제 어디에 어떻게 써야 하는지를 본인이 느끼고서 주도적으로 작성하고 사용하지 않는다면 귀찮은 프로세스의 추가일 뿐입니다.
2. 자원
한번 작성된 코드는 영원히 유지 보수돼야 되며 배포용 코드와 철저히 분리하여야 하기 때문에 테스트 코드 자체를 별도로 관리할 수 있는 인원과 프로세스가 필요합니다. 결국 누군가의 역량이 투입되어 테스트 코드를 관리하고 유지하여야 한다는 것인데 이는 더 나아가 배포 전/후 수정하는 과정에서 혹은 배포 이후 유지/보수 단계에서 조그마한 수정이라도 테스트 코드에 자칫 얽매일 수 있다는 단점이 있습니다.
TDD의 필요
- TDD가 보급되기 전의 개발은 개발자가 개발을 하고 QA 팀이 검수를 하고 이 검사에서 이상이 없으면, 배포가 되는 과정을 거쳐 배포가 되었습니다. 물론 대부분의 개발은 지금도 이런 방법으로 진행되고 있지만 이러한 일반적인 배포 과정은 우리에겐 너무나 익숙한 문제점을 던 저 줍니다.
- 첫 번째로 검증 기간에 많은 시간과 자원을 소모하게 됩니다. 단위 단계의 테스트 과정 중에 문제가 발생하게 된다면 개발자에게 피드백을 줘야 하고 당연히 상위 복합적인 동작의 테스트의 통과를 기대하기 어렵게 됩니다.
- 두 번째는 경우에 따라 병목현상이 발생할 수 있다는 점입니다. 기본적인 부분에서 버그가 발견되어 다음 스텝으로 넘어갈 수 없다면 경우에 따라 다음 일정에 문제를 야기하게 됩니다.
- 개발하는 과정에서 테스트가 잘 동작하는지 보고 확인하고 수정함으로써 즉각적인 피드백을 얻을 수 있게 됩니다. 누군가 검수해 주지 않아도 내가 원하는 기능을 수행하고 있지 않다는 즉각적인 피드백은 멀리 보면 배포 일정을 앞당기게 되는 효과를 기대할 수 있습니다.
- 그리고 QA 단계에서 미처 확인하지 못한 부분까지 커버할 수 있다는 장점 또한 있습니다. 즉 통합 테스트 단계에서의 통과율 상승을 기대할 수 있게 합니다. 앞서 작성 프로세스에서 말씀드렸다시피 통과할 수 있는 최소한의 단위를 테스트하고 통과시키기 때문에 작성 당시엔 번거로울 수 있었겠지만 길게 보았을 때 사람이라면 할 수도 있는 실수의 영역을 높은 커버리지로 대응할 수 있다는 점입니다. 그렇다면 QA는 조금 더 다양하고 고차원적인 QA 진행에 조금 더 집중할 수 있게 되는 효과를 기대할 수 있습니다.\
Test 원칙
FIRST 원칙
1. F — Fast
느린 것에 대한 의존성을 낮추자는 의미입니다. 빈번히, 자주 실행되어야 하는 테스트 코드가 데이터베이스나 네트워크 또는 파일에 접근하게 되면 테스트 코드가 느려지게 되는데 이는 바람직한 테스트 원칙이 아닙니다. 자주 빈번히 실행되어야 하는 테스트 코드가 한번 테스트하는데 1분씩 걸린다고 하면 이 자체만으로도 비효율을 야기할 수 있기 때문입니다. 또한 테스트 코드가 독립적이지 못하고 어딘가 크게 의존하게 된다면 테스트 코드 자체가 불안해질 수 있습니다. 이는 목함 수나 스턱들을 사용하여 효과적으로 대체할 수 있으며 이렇게 어딘가 의존하지 않은 독립적인 코드는 재사용 면에서도 유리하게 활용될 수 있습니다.
2. I — Isolated
독립적 고립적 집중적 정막 작은 최소한의 단위로 집중적으로 유닛으로 테스트하는 것을 뜻합니다. 앞서 계속 강조하였듯 최소한의 통과가 가능한 테스트와 기능을 만드는 것이 커버리지를 높여 종국에는 테스트 효율을 높이는 방법입니다. 또한 테스트 내용 자체가 너무 큰 단위의 테스트 결과를 요구하거나 너무 다양한 기능을 한 개의 테스트 코드로 수행하지 말자는 의미 또한 가지고 있습니다.
3. R — Repeatable
반복 가능하도록 만들라는 의미입니다. 동일한 결과가 유지될 수 있도록 하자는 의미도 갖고 있는데 네트워크나 데이터 베이스의 데이터에 환경 변화에 따라 어쩔 때는 성공하고 어쩔 때는 실패한다면 그 테스트 코드는 누가 보더라도 좋은 테스트 코드라고 볼 수 없게 됩니다. 또한 테스트 순서에 따라 결괏값이 다르다면 그 코드를 재사용 하는데도 큰 문제를 야기할 수 있게 됩니다. 즉 환경에 영향을 덜 받는 테스트 코드를 작성하자라는 원칙이었습니다.
4. S — Self-validating
스스로 검증 가능한 코드를 작성하도록 하는 것입니다. 우리는 Jset와 같은 외부 라이브러리를 통해 이 문제를 효과적으로 해결할 수 있습니다. 어떤 결과가 도출되었는지 만약 테스트가 실패했다면 어디서 실패하였는지 스스로 알아낼 수 수 있도록 작성하자는 원칙입니다. 더 나아가 내가 작성한 테스트 코드가 기존 리파지토리에 추가가 될 때 기존의 우리가 작성한 코드에 어떤 영향을 주는지 혹시 기존에 코드에 어떤 문제를 야기하진 않을지까지 스스로 점검 가능한 환경을 만드는 것 즉 자동화를 통한 검증 단계 까지를 이야기하는 부분입니다. 이 부분은 CICD와 직접적으로 맞닿아 있는 부분이기도 한데요. 작성하는 모든 코드는 테스트 코드를 포함하여 메인리파지토리에 푸시 된다는 가정이 있다면 이 CICD는 200% 활용될 수 있습니다.
지속적 통합, CI의 단계에서는 코드의 변경사항을 주기적으로 자주 머지 되어야 하는데 당연히 빌드 하여 컴파일 되는 과정 중에 서로 부딪치는 코드는 없는지 테스트 코드를 통해 점검되어야 합니다. 테스트 코드가 없는 CI는 사실 올바르게 작동하기 힘들며, 빌드 되는 과정 중에 문제점이 빠르게 발견되어 수정될 수 있도록 하여야 합니다. 개발자가 코드를 추가하는 순간 모든 테스트가 수행되며 모든 테스트가 통과되는지 추가된 테스트가 기존에 테스트에 영향이 있진 않은지 확인되어야 지속적 배포, CD가 올바르게 작동할 수 있습니다.
5. T — Timely
당연한 이야기이지만 코드를 작성하기 전에, 사용자한테 배포되기 전에, 리팩토링하기 전에 작성하자는 원칙입니다. 즉 조금 더 작은 단위일 때 디테일하게 작성하고 사전에 얘기치 못한 문제를 차단하자는 의미도 갖고 있습니다.
CORRECT 원칙
1. C — Conformance
특정한 포맷을 준수하는지 확인하지 확인하여야 합니다. 인풋이 전화번호, 이메일, 아이디, 확장자처럼 요구하는 데이터의 포맷과 다를 경우 어떻게 작동하는지 예상할 수 있어야 하고 만약에 다르다면 어떤 결과를 받아오는지 확인하여 수정할 수 있어야 합니다.
2. O — Ordering
순서 조건확인을 하여야 합니다. 순서가 중요한 경우 순서대로 들어오지 않을 경우를 예상하여야 합니다. 마찬가지로 원하는 순서대로 데이터가 들어오지 않았을 때 어떤 결과가 나오는지 작성되어 있어야 합니다.
3. R — Range
숫자의 범위를 고려하여야 합니다. 범위를 벗어나면 어떻게 작성하는지 예상하여야 하며 넘버 인풋이 들어간다면 의도치 않게 음수나 소수점이 들어가면 어떻게 대응해야 하는지 이것을 원천적으로 차단할지에 대한 고려입니다.
4. R — Reference
외부 의존성 유무, 특정 조건의 유무를 고려하면 좋습니다. 좋은 테스트 코드라면 특정 상황이 아니라면 어떻게 작동하는지 예할 수 있어야 합니다. 예를 들어 코드가 실행되기 전 선행되어 실행되어야 하는 코드가 있다면 선행 코드가 정상적으로 들어오는지의 여부를 테스트할 수 있어야 합니다. 원하는 함수가 선행되어 실행되지 않았을 때 어떤 결과가 나오는지 또한 테스트 코드로서 작성이 되어 있어야 합니다.
5. E — Existence
값이 존재하지 않을 경우 어떻게 동작할지 예상하는 코드가 있어야 합니다. Null 일 때 Undefined 일 때 빈 문자열일 때 등등의 상황에서 동작하는 테스트 코드가 작성되어 있으면 좋습니다.
6. C — Cardinality
o-1-n 법칙에 따라 검증 중복도가 높을 때나 낮을 때 어떤 결과를 예측할 수 있는지. 하나도 없을 때, 하나만 있을 때 많을 때 어떤 결과일지 예상하는 코드가 있어야 합니다.
7. T — Time
순서가 맞지 않은 경우 / 오래 걸린 경우 시간에 따른 결괏값이 다를 경우를 테스트하는 코드가 작성되어야 합니다.
이러한 고려 사항을 일일이 모든 코드에 적용하고 매번 모든 경우의 예외를 고려할 수는 없습니다. 애초에 코드의 단위를 잘 개 쪼개어 복잡하지 않게 최소한의 기능만이 구현되어 있다면 필요한 몇 가지의 요소만 고려되어도 충분히 훌륭한 테스트 코드라고 할 수 있습니다.
마치며
TDD와 CICD , 스크럼 방식의 애자일 뿐만 아니라 이전 제가 근무했던 환경에서 kpi, 위험성 평가, 각종 체크리스트들로 대표되었던 모든 시스템의 도입은 결국 이 시스템을 왜 도입하게 됐으며 도입하게 되면 어디에 어떻게 좋은지 본인 스스로가 필요에 공감하고 적극적으로 수용하고자 하는 마음이 선행되어야 온전히 장점들이 발휘될 수 있다고 생각합니다.
솔직히 작성하기 곤란하고 번거로울 수는 있지만 상당히 많은 장점의 개발 방식의 TDD를 적극적으로 현업에 적용한다면 종국에는 확실한 생산성을 끌어 올릴 수 있지 않을까 생각합니다.