애자일 속 TDD
우리는 소프트웨어를 개발하고, 또 다른 사람의 개발을 도와주면서 소프트웨어 개발의 더 나은 방법들을 찾아가고 있다.
이 작업을 통해 우리는 다음을 가치 있게 여기게 되었다:공정과 도구보다 개인과 상호작용을
포괄적인 문서보다 작동하는 소프트웨어를
계약 협상보다 고객과의 협력을
계획을 따르기보다 변화에 대응하기를가치 있게 여긴다. 이 말은, 왼쪽에 있는 것들도 가치가 있지만, 우리는 오른쪽에 있는 것들에 더 높은 가치를 둔다는 것이다.
애자일 소프트웨어 개발 선언
폭포수 모델은 인기있는 소프트웨어 개발 방법론 중 하나로, 초기에 모든 요구사항을 분석하고 정의한 후에 개발을 진행하는 방식입니다. 가장 직관적인 접근법이지만, 개발 과정에서 발견되는 통찰과 변경사항을 반영하기 어렵고, 고객과의 상호작용이 제한되어 제품이 완성되기 전까지는 문제를 파악하기 어려웠습니다. 이로 인해 문제가 나중에 발견되어 비용이 증가했습니다.
이와 함께 고객 요구사항의 불확실성, 신속한 시장 변화, 경쟁 압력 등이 부각되면서 애자일 방법론이 등장하게 되었습니다. 애자일은 지속적인 변경에 대응하고 빠르게 가치를 제공하기 위한 방법론으로, 고객과의 긴밀한 협력, 빠른 피드백, 작은 주기의 반복을 강조합니다.
TDD(Test-Driven Development, 테스트 주도 개발)는 새로운 요구사항이나 변경이 발생하면 그에 맞게 테스트를 작성하고, 해당 테스트를 통과할 정도의 코드를 작성하는 순환적인 주기를 따릅니다. 기존 기능에 영향을 주지 않는 것을 확인하며 작은 변경이 이루어지기 때문에 변화에 대응하기가 훨씬 수월해집니다.
테스트는 실행 가능한 명세로써 기능을 문서화하는 역할을 합니다. 테스트를 통해 특정 조건에서 어떻게 작동해야 하는지 명확하게 기술함으로써 동작을 쉽게 이해하고 사용할 수 있게 됩니다. 또한, 테스트를 먼저 작성을 하면서 코드를 호출자의 관점으로 바라보며 편리하게 사용할 수 있는 설계를 하는데 집중합니다.
이러한 방식들은 애자일의 “계획을 따르기보다 변화에 대응하기”와 “포괄적인 문서보다 작동하는 소프트웨어를” 실현하는데 도움을 줍니다.
반복 주기와 핵심 원칙
TDD는 3가지의 단계를 짧은 주기 동안 반복합니다.
-
테스트 작성 (Red - 빨강 단계): 새로운 기능을 추가하거나 기존 기능을 변경하는 작업에 대한 테스트 케이스를 작성합니다. 이 시점에서는 해당 테스트는 실패합니다.
-
코드 작성 (Green - 초록 단계): 작성한 테스트를 통과할 정도의 최소한의 코드를 작성합니다. 이 단계에서는 코드가 테스트를 통과하게 됩니다.
-
리팩토링 (Refactor - 리팩터링 단계): 테스트를 통과한 코드를 리팩토링하여 코드의 가독성을 높이고 중복을 제거합니다. 이렇게 함으로써 코드의 품질을 향상시킵니다.
로버트 C. 마틴은 반복 주기 동안 지켜야 할 세 가지 중요한 원칙을 제시합니다.
-
실패할 테스트를 작성하기 전에는 아무런 제품 코드도 작성하지 않는다: 설계에 집중하도록 유도합니다. 새로운 기능이 어떻게 사용되는지를 고려하여 테스트 케이스를 먼저 작성합니다. 이로써 초기에 설계 결정에 대한 빠른 피드백을 받을 수 있고, 설계를 신속하게 수정할 수 있게 됩니다.
-
실패할 테스트를 한 번에 하나 이상 작성하지 않는다: 현재 작업 중인 기능에 집중하도록 유도합니다. 다른 변경 사항이나 부수적인 영향을 최소화하여 안정적인 코드를 유지할 수 있도록 합니다. 또한, 짧은 피드백 루프를 만들어 빠르게 테스트 결과를 받아볼 수 있고, 신속하게 대응할 수 있도록 해줍니다.
-
현재 실패한 테스트를 만족시키는 코드 외에는 작성하지 않는다: 코드의 불필요한 확장을 방지하며, 기능의 추가나 수정이 필요한 시점에만 코드를 작성하도록 유도합니다. 이를 통해 코드의 단순성을 유지하고, 요구사항에 따라 점진적으로 코드를 발전시킵니다.
이러한 주기를 통해 가장 간단한 테스트부터 복잡하고 고도화된 테스트까지 여러 테스트를 작성하게 됩니다. 이 과정속에서 제품 코드는 테스트가 발전함에 따라 모든 테스트를 통과할 수 있게 점진적으로 개선되고 일반화됩니다. TDD는 테스트를 통해 구현을 도출하는 문제 해결 방법이기도 합니다.
이렇게 테스트는 제품 코드가 나아갈 방향을 제시하며, 제품의 구현에 직접적인 영향을 줍니다. 만약 올바르지 않은 방향으로 구현이 나아간다면, 몇 걸음 뒤로 가서 과거에 작성했던 테스트를 다시 수정해야 합니다. 기능을 어떻게 구현해야 하는지 확실히 알고 있다면 이 과정을 거치지 않고 더 큰 보폭으로 좀더 고도화된 테스트를 작성하며 빠르게 전진할 수 있습니다.
테스트의 다양한 종류
품질을 보장하기 위해서는 다양한 종류의 테스트가 필요합니다. 주로 사용되는 테스트 종류로는 단위 테스트, 통합 테스트, 종단간 테스트, 그리고 인수 테스트가 있습니다.
단위 테스트(Unit Test): 개별 모듈이나 함수의 동작을 확인합니다. 약 70-80%의 테스트가 단위 테스트에 할당됩니다.
통합 테스트(Integration Test): 다양한 모듈이나 컴포넌트가 협업하는 상황을 확인합니다. 약 10-15%의 테스트가 통합 테스트에 할당되며, 테스트를 실행할 때 여러 프로세스와 여러 스레드가 필요할 수 있습니다.
종단간 테스트(End-to-End Test): 시스템의 전체 기능과 성능을 종합적으로 평가합니다. 전체 테스트 중 5% 미만이 종단간 테스트에 할당되며, 테스트를 실행할 때 여러 연결된 기기들이 필요할 수 있습니다.
인수 테스트(Acceptance Test): 고객의 관점에서 소프트웨어가 정확하게 동작하는지를 검증합니다. 종단간 테스트가 이 테스트에 포함될 수 있습니다. 주로 사용자 스토리의 정상 동작 여부를 중점적으로 평가하며, 고객의 요구 사항을 충족하는지를 확인합니다.
단위 테스트에서 종단간 테스트로 갈수록 덜 실행되고 작성되는 경향이 있습니다. 통합 테스트, 종단간 테스트, 그리고 인수 테스트는 전체 시스템이나 사용자의 입장에서의 기능을 검증하기 때문에 더 많은 리소스와 시간이 필요합니다. 이러한 테스트는 단위 테스트와 달리 여러 모듈 또는 컴포넌트 간의 상호 작용을 고려해야 하므로 복잡성이 증가합니다. 그렇기 때문에 주로 저렴하고 간단한 단위 테스트가 자주 실행되고 작성됩니다.
“실패할 테스트를 작성하기 전에는 아무런 제품 코드도 작성하지 않는다”는 원칙은 단위 테스트에만 국한되지 않고, 통합 테스트, 종단간 테스트, 그리고 인수 테스트에도 모두 적용됩니다. 크기와 무관하게 테스트를 우선으로 작성하고 그 후에 제품 코드를 작성하는 것은 설계를 검토하고 구현이 나아갈 방향을 제시합니다.
동일한 기능에 대해 여러 범위의 테스트를 작성해야 하는 경우가 있습니다. 새로운 사용자 스토리를 개발할 때, 먼저 해당 스토리의 핵심 기능을 인수 테스트로 작성합니다. 이 인수 테스트는 가능한 단순하게 작성되어야 합니다. 그리고 테스트 대역
을 활용하여 인수 테스트를 통과시킵니다. 이후 필요한 모듈과 컴포넌트를 식별하고, 각 모듈에 대한 통합 테스트를 수행합니다. 이 경우에도 테스트를 통과시키기 위해 테스트 대역
을 활용할 수 있습니다.
통합 테스트를 통과한 후, 더 구체화된 모듈을 활용해 작성된 인수 테스트와 통합 테스트를 리팩토링합니다. 그 이후에 각 모듈에 대한 단위 테스트를 작성하여 세부 기능을 설계합니다. 마지막으로 기능을 구현하고 다시 리팩토링을 실행합니다. 리팩토링 과정에서 더 이상 불필요해진 테스트 대역
은 제거될 수 있습니다.
테스트 대역
테스트 대역(Test Double)은 테스트 대상이 실제 의존하고 있는 환경이나 객체의 역할을 대신하기 위해 사용됩니다. 여러 종류의 테스트 대역
이 있으며, 각각은 특정한 테스트 시나리오나 목적에 맞게 선택하여 활용됩니다.
더미 (Dummy): 매개변수를 전달하지만 실제 동작이 필요하지 않은 객체입니다. 매개변수가 필요하지만 실제로 사용되지 않는 경우 더미 객체를 활용하여 테스트를 수행합니다. 예를 들어, 인터페이스를 구현하는데 필요한 매개변수만 갖춘 더미 객체를 사용하여 특정 조건을 시뮬레이션할 수 있습니다.
스텁 (Stub): 특정 메소드나 함수를 호출하면 미리 정의된 결과를 반환하는 객체입니다. 실제 동작하는 객체를 대체하여 호출 시 특정한 결과를 제어하고자 할 때 스텁을 사용합니다. 예를 들어, 외부 서비스 호출 시 스텁을 사용하여 실제 호출을 방지하고 가상의 결과를 반환하여 의존성을 격리할 수 있습니다. 스텁은 더미로 사용될 수 있습니다.
스파이 (Spy): 호출된 메소드나 함수에 대한 정보를 기록하는 객체입니다. 호출 여부, 호출 횟수, 매개변수 등을 기록하여 테스트 결과를 분석합니다. 특정 메소드가 예상대로 호출되었는지 확인하거나 특정 동작이 발생하는지를 확인할 때 스파이를 사용합니다. 스파이는 스텁으로 사용될 수 있습니다.
모의 객체 (Mock Object): 호출된 메소드나 함수에 대한 기대 값을 지정하여 검증을 할 수 있는 객체입니다. 모의 객체는 특정 상호작용을 테스트하고자 할 때 사용됩니다. 예를 들어, 외부 API 호출을 모방하고 특정 매개변수로 호출되는지 여부를 확인할 때 모의 객체를 사용합니다. 모의 객체는 스파이로 사용될 수 있습니다.
가짜 객체 (Fake): 실제로 동작하는 구현이지만, 특정 테스트를 위해 가벼운 형태로 만들어진 객체입니다. 실제 동작을 간소화하시고 가볍게 사용할 수 있어 테스트 대상에 집중하기 위해 사용됩니다. 예를 들어, 실제 파일 시스템 대신 메모리에 데이터를 저장하는 가짜 파일 시스템을 사용할 수 있습니다.
테스트 대역
을 활용하면 코드의 독립성을 확보하고, 외부 의존성으로부터 모듈을 격리시킬 수 있습니다. 가상 데이터를 생성하여 외부 의존성에 영향받지 않고도 간소화된 테스트를 수행할 수 있어, 빠르고 독립적인 테스트 환경을 구축할 수 있습니다. 또한, 테스트 대상이 외부와 어떻게 상호작용을 하는지를 확인할 수 있습니다.
그러나 테스트 대역
을 오용하면 테스트와 구현을 더 의존적으로 만들 수 있습니다. 특히 모의 객체
나 스파이
는 세부적인 구현 사항을 노출시켜 테스트가 더 예민하게 반응하게 만들어, 구현 변경 시 테스트가 더 자주 실패하게 만듭니다. 또한, 테스트 대역
의 오용은 실제 동작과의 괴리를 초래하고, 테스트 코드의 신뢰성이 감소할 우려가 있습니다.
저는 테스트를 작성할 때 유연성을 중요시하고 상태를 검증하는 것을 선호하여, 테스트 대상이 실제로 참고하는 의존 컴포넌트, DOC(depended-on component)를 우선 사용합니다. 그러나 DOC가 무겁고 느리거나 불안정 할 경우엔 가짜 객체
를 활용합니다. 가짜 객체
는 복잡한 원본 객체를 더 싸게 대체할 수 있게 해줍니다. 그러나 대신할 객체가 복잡하면 가짜 객체
의 구현도 복잡해질 수 있어 가짜 객체
를 검증하는데 추가적인 테스트가 필요할 수 있습니다.
하지만 가짜 객체
는 자동화된 테스트뿐만 아니라 개발 과정에서 서비스를 실행할 경우에도 활용될 수 있습니다. 복잡한 개발 환경을 구축하는 대신 가짜 객체
를 활용한 대역 환경을 사용하여 데모를 제공하고 로컬 개발 환경에서 빠르게 서비스를 실행하여 변경 사항을 확인할 수 있습니다.
이렇게 만들어진 서비스는 스스로 실행 가능한 Stand-Alone 방식을 제공하여 개발자에게 편의성을 제공하고 빠른 피드백을 가능하게 합니다.
런던 학파와 시카고 학파
설계의 관점에서 런던 학파와 시카고 학파는 두 가지 상반된 견해를 제시하고 있습니다. 이러한 차이로 인해 설계 전략과 진행 방향에 다른 방식을 사용합니다.
런던 학파 (Mockist): 유연성보다 확실성을 우선시합니다. 모의 객체
나 스파이
를 활용하며 결과보다는 그 과정인 행동에 중점을 둡니다. 밖에서 안으로 (outside-in) 설계를 하는 방식을 채택하여 사용자 인터페이스에서 시작하여 비지니스 규칙을 향해 나아갑니다. 경계마다 모의 객체
나 스파이
를 사용하여 내부와의 상호 작용을 확인하고, 비지니스 규칙을 만나면 해당 규칙을 구현하고 다시 사용자 인터페이스로 돌아오는 경로를 테스트합니다.
시카고 학파 (Classicist): 확실성보다 유연성에 중점을 둡니다. 상호 작용보다는 그 결과에 초점을 맞춥니다. 비지니스 규칙부터 시작하여 사용자 인터페이스 방향으로 (inside-out) 설계를 하는 방식을 택하며, 값 테스트와 속성 테스트를 통해 비지니스 로직을 먼저 구현합니다. 그 후에 사용자 인터페이스로 이동하여 모듈의 경계를 작업합니다. 그리고 하나의 계층을 탐험하면서 추상화 일반화의 기회나, 디자인 패턴을 찾아내는 방식으로 설계를 진행합니다.
이 학파들은 서로 다른 견해를 가지고 있지만, 하나의 방법만을 사용할 필요는 없습니다. 작업하는 부분이 어떤 위치에 있는지에 따라 추구하는 가치가 달라질 수 있고, 서로 다른 관점이 공존할 수 있습니다.
저는 시카고 학파
에 가깝지만 경우에 따라 런던 학파
의 방식을 사용합니다. 특히 실제 의존성을 연결하는데 비용이 큰 모듈의 경계에서 다른 외부 모듈을 연결할 때는 테스트 대역
을 활용합니다. 외부와 통신하는 방식에 집중할 필요가 있으면 모의 객체
나 스파이
를 활용하여 행위를 테스트하고, 그렇지 않은 경우에는 가짜 객체
를 이용하여 상태 테스트만을 사용합니다. 의존성을 손쉽게 제어할 수 있는 하나의 모듈 내에서 작업할 때에는 결과와 유연성에 초점을 맞추어 상태 테스트를 활용합니다.
테스트와 생산성
TDD가 제품 코드 뿐만 아니라 테스트 코드를 함께 작성을 해야 해서 작업량이 늘어나 생산성을 저하시킨다고 생각할 수도 있습니다.
이는 소프트웨어 개발을 단편적으로 바라보고 있기 때문에 생기는 오해입니다. 소프트웨어 개발에는 추상적인 소프트웨어 개체를 구성하는 복잡한 개념적 구조를 만드는 일 그리고 이러한 추상적 개체를 코드로 표현하는 일이 필요합니다.
테스트는 제품 코드의 실행가능한 명세서입니다. 그렇기에 테스트를 작성하는 것은 제품 코드에 대한 개념적 구조를 설계하는 것이고 추가적인 설계가 필요하지는 않습니다. 오히려 설계를 직접 살펴보며 다듬을 수 있어 설계에 도움을 줍니다. 테스트를 작성하는 것은 오직 테스트를 코드로 표현하는 일이 더 요구됩니다. 그리고 이런 개념 표현에 대한 일은 프로그래밍 언어와 여러 도구의 발전으로 점점 더 쉬워지고 있습니다.
또한 테스트는 반드시 수행이 되어야 하는 활동입니다. 개발자 심지어 사용자가 수동으로 수행하는 테스트는 자동된 테스트 보다 실행 시간이 길고 신뢰성이 낮으며 비용이 큰 작업입니다. 자동화된 테스트는 테스트 수행에 들어가는 비용을 줄입니다.
그러므로 TDD는 테스트를 코드로 표현하는 추가적인 일을 만들지만 테스트를 실행하는 비용을 감소시키고 설계를 빠르게 개선하는데 도움을 주어 전체적인 생산성을 증가시킬 수 있습니다.
좋은 테스트 작성법
훌륭한 테스트는 제품 코드와 함께 진화하며 높은 품질을 유지하면서도 실행 가능한 문서 역할을 수행해야 합니다. 중요한 점은 테스트가 읽기 쉽고 이해하기 쉬워야 한다는 것입니다. 그래서 테스트 코드는 제품 코드와 다른 가치를 두고 작성되어야 할 때도 있습니다.
테스트는 선언적으로 작성되어야 하며, 준비, 실행, 검증하는 과정이 코드상에서 명확하게 표현되어야 합니다. 중복 코드를 최소화하는 것은 좋은 일이지만, 중복 제거를 위한 조건문은 추가적인 분기를 만들고 추상화는 그 내용을 이해하기 위한 추가적인 탐색을 만듭니다. 때로는 중복을 제거하지 않고 그대로 유지하는 것이 코드의 가독성을 높일 수 있습니다.
또한, F.I.R.S.T 원칙을 따르는 것은 테스트의 품질을 높이는데 도움을 줍니다.
Fast (빠르게): 느린 테스트는 개발자의 생산성을 떨어뜨릴 뿐만 아니라 개발/배포 시간을 증가 시켜 CI/CD 파이프라인의 효율성에도 영향을 미칩니다.
Independent (독립적으로): 한 테스트가 실패하더라도 다른 테스트는 영향을 받지 않고 독립적으로 실행될 수 있어야 합니다. 테스트가 다른 테스트에 의존적이면 어떤 테스트로 인해 실패가 발생했는지 파악하는 데 더 많은 시간을 소비할 수 있습니다. 테스트가 여러 가지 이유로 중단될 수 있는 경우 이를 별도의 테스트로 분할해야 합니다.
Repeatable (반복 가능하게): 어떤 환경에서도 테스트가 반복 가능하고 동일한 조건에서 항상 같은 결과를 가져와야 합니다. 반복 가능한 테스트를 수행하려면 직접적으로 제어를 못하는 외부 환경의 모든 것으로부터 테스트를 격리해야 합니다.
Self-Validating (자체 검증 가능하게): 테스트는 사람의 주관이 개입되지 않고 자체적으로 검증이 가능해야 합니다. 테스트 결과를 수동으로 확인하는 것은 시간이 많이 걸리는 프로세스이며 정확도가 낮고 재현이 어렵습니다.
Timely (적시에): 테스트 코드는 제품 코드와 동일한 속도로 발전해야 합니다. 적시에 테스트를 작성하면 런타임 오류와 버그를 빠르게 감지하고 수정할 수 있습니다.
TDD와 애자일
TDD는 그 자체로 멋진 방법론이지만 애자일, 특히 익스트림 프로그래밍(XP)의 다른 실천방법들과 결합되면 큰 영향을 만들어 냅니다.
Whole Team (전체 팀): 이는 팀 내의 각 구성원이 프로젝트의 모든 측면에 참여하고, 통합적인 역할을 수행해야 한다는 개념입니다. TDD는 테스트를 작성하는 과정에서 기능과 설계를 구체화시키기 위해 개발자, 테스터, 그리고 비즈니스 담당자들이 긴밀하게 협력하도록 장려합니다.
Planning Game (계획 게임): TDD를 통한 개발은 작은 단위로 나누어진 작업을 수행하므로, 계획 게임에서 각 작업의 우선순위를 결정하고 빠르게 반영할 수 있습니다. 즉, 계획이 유연하게 조정되며, 작업의 진척 상황을 실시간으로 확인할 수 있습니다.
Customer Tests (고객 테스트): 애자일에서는 고객의 요구 사항을 중시하며, TDD는 이를 테스트로 구현합니다. 개발자가 고객의 요구사항을 받아 테스트로 먼저 작성하고 이를 통과시키면서 요구 사항을 충족시키는 코드를 만들게 되므로, 고객의 기대치에 더 적합한 결과물을 생산할 수 있습니다.
Small Releases (작은 릴리스): TDD는 작은 단위로 코드를 작성하고 테스트하며, 자동화된 테스트를 제공합니다. 이는 지속적인 배포를 가능하게 하여 작은 주기로 소프트웨어를 릴리스할 수 있습니다. 작은 릴리스는 사용자 피드백을 빠르게 반영하면서 서비스를 성장시킬 수 있습니다.
Simple Design (간단한 설계): TDD는 테스트를 통과 시킬수 있을 정도의 최소한의 코드만을 작성하며 설계를 간단하게 유지하도록 장려합니다. 간단한 설계는 유지보수성이 높아지고 변화에 대응하기 쉬워집니다. 코드의 가독성과 유지보수성을 고려하여 최소한의 복잡성으로 문제를 해결하는 것이 TDD와 애자일의 핵심 가치입니다.
Pair Programming (페어 프로그래밍): TDD는 테스트 작성과 코드 작성을 번갈아가며 진행하므로, 두 명의 프로그래머가 협력하는 Pair Programming과 자연스럽게 결합됩니다. 서로의 아이디어를 공유하고 코드를 실시간으로 리뷰함으로써 더 나은 설계와 구현을 이끌어냅니다.
TDD는 다른 애자일 실천 방법과 같이 사용이 되며 애자일의 가치를 실천합니다.
댓글남기기