TDD는 Kent Beck
이 제안하고 정리한 개념으로, 매우 짧은 개발 사이클을 반복하는 소프트웨어 개발 프로세스 중 하나입니다. 유닛 테스트를 먼저 수행(Unit Test First
)으로써 단순한 설계를 장려하고 코드에 자신감을 불어 넣어주는 기법으로 자주 소개됩니다. TDD의 수행 순서를 간단히 표현하면 아래 도식과 같습니다.
1. 먼저 실패하는 테스트 케이스를 작성합니다.
2. 테스트 케이스를 통과하기만 하는 코드를 작성합니다.
3. 2
의 과정을 완료한 코드를 보기 좋고 이해하기 용이하도록 다시 작성합니다 (리팩터; 코드개선). 리팩터 과정에서는 2
에서 작성한 코드의 논리 흐름을 바꾸지 않습니다.
4. 리팩터 과정에서 실패 테스트 케이스를 추가적으로 발견할 경우 1
의 과정으로 돌아갑니다.
그럼 TDD의 수행 방법까지 알아보았는데, 듣기만 하니 왜 이것이 필요한지 잘 모르겠습니다. 이런 의문점들이 있네요.
TDD 질문 list
- TDD가 지향하는 바는 도대체 뭐지?
- 테스트를 하면 뭐가 좋지?
- 테스트를 나중에 작성하면 안되나?
- 그러면 어떤 테스트를 작성해야 할까?
- 현업에서는 왜 TDD 적용을 어려워할까?
의문을 가졌으니 하나씩 살펴볼까요?
TDD의 목적은 Clean Code That Works
. 즉, 작동하는 깨끗한 코드
를 표방하고 있습니다.
테스트는 훌륭한 스펙 정의 문서
가 됩니다. 어떤 테스트를 수행했는지를 보면 어떤 기능을 하는지, 한계점이 무엇인지를 파악할 수 있기 때문입니다. 예를 들어, 덧셈 기능은 한 자리 수끼리 연산, 두 자리 수끼리 연산, 한 자리와 세 자리 수 간 연산 등 여러 케이스가 있을 수 있습니다.
추가적으로는 아래와 같은 이점이 있습니다.
저와 같은 초보자는 스펙을 구체적으로 표현하기 어렵다는 이유로 일단 코드를 작성해보자는 생각을 가지기 쉽습니다. 하지만 테스트를 먼저 작성하면 아래와 같은 이점들을 얻을 수 있습니다.
다음 의문에 대한 답을 찾아보기 전에 Unit Test
의 개념을 먼저 알아볼까요?
컴퓨터 프로그래밍에서 소스 코드의 특정 모듈이 의도된 대로 정확히 작동하는지 검증하는 절차입니다. 테스트 코드는 작성한 코드가 의도대로 동작하는지 검증하기 위한 코드입니다.
예를 들어, 아이폰을 충전기와 연결했는데 충전되지 않는 상황이 일어났다고 가정해보겠습니다. 만약 충전용 어댑터 (콘센트 연결부)와 어댑터에 연결하기 위한 케이블, 그리고 아이폰이 일체형이라면 어떤 부분이 문제인지 쉽게 알아낼 수 있을까요? 아닐 것입니다. 이처럼 아래와 같이 기능을 분리함으로써 테스트를 용이하게 할 수 있습니다.
충전용 어댑터 (source: Wikimedia commons)
테스트를 작성할 때는 FIRST
와 Right - BICEP
이라는 키워드를 기억해두면 좋습니다. 하나씩 알아봅시다.
그러면 이렇게 좋은 점이 많은데, 현업에서 TDD를 적용하는 곳이 적을까요? 먼저, 테스트를 수행하기 위한 활동 또한 비용으로 인식되기 때문입니다. 오직 코드가 동작하는지 여부에만 관심이 있기에 테스트와 이를 위한 활동들이 낭비라고 생각하는 경우가 있습니다. 하지만 테스트를 수행하지 않으면 이후에 더 큰 비용을 감당해야할 수 있습니다. 예를 들어, 테스트를 작성하지 않으면 향후에 문제가 발생했을 때 어느 부분이 문제인지 알 수 없어 유지보수에 굉장히 많은 시간과 비용이 드는 경우를 고려해볼 수 있습니다. 심지어 새로 코드를 작성하게 되는 일도 생길 수 있죠. 반면에, 테스트 코드를 작성하면 이전에 작성한 내 코드를 돌아보지 않아도 됩니다. 새로 작성한 코드로 인해 프로그램이 잘못 작동한다면 새로 작성한 코드를 의심해볼 수 있겠죠.
TDD 예시를 살펴보기 전에 코드를 작성할 때 참고할 점들을 가져왔습니다. 실생활에서 지키기는 어렵겠지만 연습할 때는 극단적으로 해야 실생활에서 반이라도 하겠죠..? 저에게 하는 말입니다..
예시로써 Queue
를 구현해보도록 하겠습니다. Queue
는 Stack
과는 달리 먼저 삽입된 요소가 먼저 인출되는 구조를 가지고 있습니다 (선입선출). 저는 동일한 속도로 진행하고 있으면 먼저 진입한 사람이 먼저 나오게 되는 터널 통과하기
에 비유하고 싶습니다.
테스트를 하려면 프로젝트를 먼저 만들어야겠죠. iOS 앱용 프로젝트를 생성하면 아래 창과 같이 Include Tests
라는 체크박스를 선택할 수 있습니다. 테스트를 포함하는 프로젝트를 만들고 싶으시다면 체크하고 진행하시면 되겠습니다.
그럼 체크박스를 해제하고 만들었다면 어떻게 테스트를 추가할 수 있을까요? 프로젝트 파일을 눌러봅시다.
그럼 위와 같은 창이 보일텐데요. 중앙에서 좌측을 보시면 PROJECT
와 TARGETS
라고 표현된 부분이 있습니다. 해당 탭에서 아랫 부분을 보시면 +
와 -
, Filter
가 보이실겁니다. +
를 눌러볼까요?
그럼 아래와 같은 창이 표시될겁니다. iOS 탭에 있는 Unit Testing Bundle
을 선택하여 파일을 테스트 파일을 생성해줍니다. Target to be Tested
에 테스트할 대상이 들어가있는지 확인해주세요.
저는 아래와 같이 MyQueue.swift
라는 파일을 만들어 내부에 Queue
구현을 해보도록 하겠습니다.
위의 요구사항을 충족시키기 위해 아래와 같이 코드를 작성해보았습니다.
class MyQueue<Item> where Item: Numeric {
private(set) var items: [Item]
init(items: [Item]) {
self.items = items
}
convenience init() {
self.init(items: [])
}
}
인스턴스 이니셜라이징이 잘 되는지 알아보기 위해 테스트를 작성해볼까요? 앞서 만든 테스트 파일로 이동해봅시다!
테스트를 위해 아래와 같이 @testable import "module name"
키워드를 작성해줍니다.
테스트 파일은 아래와 같이 템플릿이 작성되어 있습니다.
import XCTest
@testable import MyQueue
class MyQueueTests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func testExample() throws {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
func testPerformanceExample() throws {
// This is an example of a performance test case.
measure {
// Put the code you want to measure the time of here.
}
}
// 여기에 테스트를 위한 메서드를 작성
}
setUpWithError()
와 tearDownWithError()
메서드는 전체 테스트를 시작하고 종료할 때 실행될 메서드이며입니다. testExample()
과 testPerformanceExample()
메서드는 테스트 시 실행할 테스트케이스들과 이들의 성능을 측정하기 위한 메서드입니다. 테스트를 수행하기 전에 먼저Scheme
이 설정되어 있는지 확인해주어야 합니다.
위와 같이 저는 MyQueueTests
가 Build
와 Test
에 잘 정의되어 있네요. 이 Scheme
이 없으신 분들은 New Scheme...
을 클릭하셔서 Target
을 테스트 대상으로 지정해주시면 됩니다. 새로운 Scheme
을 만드신 분들은 테스트 할 때 해당 Scheme
으로 이동하셔서 테스트하셔야 되는 점을 잊지마세요~!
잠시 샛길로 다녀왔네요. 인스턴스의 이니셜라이징이 잘 되는지 확인해보기로 했으니 계속해서 코드를 작성해볼까요?
class MyQueueTests: XCTestCase {
var sut: MyQueue<Int>!
override func setUpWithError() throws {
try super.setUpWithError()
}
override func tearDownWithError() throws {
try super.tearDownWithError()
sut = nil
}
func testExample() throws {
test_initializing_MyQueue_withNoItem()
test_initializing_MyQueue_withSomeInts()
}
func test_initializing_MyQueue_withNoItem() {
sut = MyQueue<Int>()
XCTAssertEqual(sut.items, [])
}
func test_initializing_MyQueue_withSomeInts() {
sut = MyQueue<Int>(items: [1, 2, 3])
XCTAssertEqual(sut.items, [1, 2, 3])
}
}
sut
는 System Under Test
의 의미로 사용하였습니다. 편의 이니셜라이저를 통해 빈 큐를 생성하는 test_initializing_MyQueue_withNoItem()
테스트 케이스와 정수 세 개를 큐에 넣어 이니셜라이징하는 test_initializing_MyQueue_withSomeInts()
테스트 케이스를 만들어 봤습니다. 테스트가 끝날 때는 sut
에 nil
을 할당하여 디이니셜라이징을 시켜주었습니다. 그럼 테스트를 해봅시다! 아래 그림과 같이 좌측 상단에 있는 재생 버튼을 1초 정도 누르고 계셔보시면 Test
버튼이 나타날겁니다. 이 버튼을 통해 테스트하셔도 되고, ⌘(cmd) + u
를 통해 단축키로 테스트를 실행하셔도 됩니다.
그럼 곧 테스트 성공 표시와 함께 기분 좋은 효과음이 들리고 초록색 체크 마크가 성공한 테스트 옆에 표시될 것입니다.
이로써 MyQueue
타입의 이니셜라이징이 성공적으로 이루어지는지 테스트해보았습니다. 계속해서 Queue
에 요소를 추가하는 enqueue
기능을 만들어보겠습니다.
enqueue
기능 구현하기enqueue
기능 요구사항queue
에 요소를 하나 추가할 수 있다.queue
에 요소를 한 번에 여러 개 추가할 수 있다.MyQueue<Item>
클래스에 enqueue()
메서드를 아래와 같이 초안을 작성했습니다.
func enqueue(_ items: Item...) {
self.items.append(contentsOf: items)
}
테스트 메서드들을 아래와 같이 작성해서 바로 테스트를 해볼까요?
func test_enqueue_oneItem() {
sut = MyQueue<Int>()
sut.enqueue(1)
XCTAssertEqual(sut.items, [1])
}
func test_enqueue_multipleItems() {
sut = MyQueue<Int>()
sut.enqueue(1, 2, 3)
XCTAssertEqual(sut.items, [1, 2, 3])
}
테스트가 아래와 같이 성공하였습니다!
지금은 items
라는 프로퍼티가 읽기가 가능하여 이런식으로 확인이 가능하지만, 읽기가 불가능한 프로퍼티라면 어떤식으로 처리해줄 수 있을까요? 아래 방식들을 고려해볼 수 있겠네요.
1.enqueue(_:)
메서드에서 자신(인스턴스)의 items
프로퍼티를 반환하는 방식
2. Result
타입을 반환하는 방식
보통 enqueue(_:)
메서드를 사용하며 반환값이 있다고 기대하지 않을 것입니다. 사용할 때마다 주의 문구를 표시하지 말아달라는 의미로 @discardableResult
키워드를 사용하였습니다.
@discardableResult
func enqueue(_ items: Item...) -> [Item] {
self.items.append(contentsOf: items)
return self.items
}
그럼 테스트는 이런식으로 하면 되겠군요.
func test_enqueue_oneItem() {
sut = MyQueue<Int>()
XCTAssertEqual(sut.enqueue(1), [1])
}
func test_enqueue_multipleItems() {
sut = MyQueue<Int>()
XCTAssertEqual(sut.enqueue(1, 2, 3), [1, 2, 3])
}
테스트 성공!
2번 방식으로도 해봅시다. 먼저 에러 타입을 열거형으로 작성해볼까요? 아직 실패하는 경우가 없으니 타입을 비워두겠습니다.
enum QueueError: Error { }
계속해서 메서드를 작성하겠습니다.
@discardableResult
func enqueue(_ items: Item...) -> Result<[Item], QueueError> {
self.items.append(contentsOf: items)
return .success(self.items)
}
테스트 방식은 items
를 반환해주는 방식과 동일하지만 아래와 같이 결과값이 Result
타입으로 표현될겁니다.
func test_enqueue_oneItem() {
sut = MyQueue<Int>()
XCTAssertEqual(sut.enqueue(1), .success([1]))
}
func test_enqueue_multipleItems() {
sut = MyQueue<Int>()
XCTAssertEqual(sut.enqueue(1, 2, 3), .success([1, 2, 3]))
}
테스트 성공!
계속해서 dequeue
기능을 구현해봅시다!
dequeue
기능 구현하기dequeue
기능 요구사항이를 만족하는 코드를 작성해보겠습니다. 먼저 비워두었던 QueueError
타입에 Queue
가 비었을 때 dequeue(_:)
를 실행하는 경우 일어날 수 있는 에러 케이스를 추가하고 dequeue(_:)
메서드를 작성하겠습니다.
enum QueueError: Error {
case noItemInQueue
}
@discardableResult
func dequeue(_ number: Int = 1) -> Result<[Item], QueueError> {
guard !self.items.isEmpty else {
return .failure(.noItemInQueue)
}
var dequeued = [Item]()
for _ in 1...number {
dequeued.append(self.items.removeFirst())
}
return .success(dequeued)
}
테스트!
매개변수에 기본값을 주지 않아 테스트에 실패했네요. 메서드 선언부를 dequeue(_ number: Int = 1)
로 교체하여 다시 테스트해 보겠습니다.
테스트 성공!
마지막으로 reset
기능을 구현해보겠습니다.
reset
기능 구현하기reset
기능 요구사항Queue
를 비울 수 있습니다.구현에서 테스트까지 한 번에 가봅시다! 빈 배열을 반환할 수도 있고 리셋한 배열을 반환할 수도 있지만 리셋의 결과인 빈 배열을 반환해보겠습니다.
func reset() -> Result<[Item], QueueError> {
self.items.removeAll()
return .success(self.items)
}
// 테스트 케이스
func test_reset_queueWithNoItem() {
sut = MyQueue<Int>()
XCTAssertEqual(sut.reset(), .success([]))
}
func test_reset_queueWithOneItem() {
sut = MyQueue<Int>(items: [1])
XCTAssertEqual(sut.reset(), .success([]))
}
func test_reset_queueWithMultipleItems() {
sut = MyQueue<Int>(items: [1, 2, 3])
XCTAssertEqual(sut.reset(), .success([]))
}
Yes! WE ARE ON FIRE!!
테스트 하기 좋은 코드가 항상 좋은 코드를 의미하는 것은 아니니 상황에 맞춰 적용하는 것이 중요합니다.