본문 바로가기

iOS 개발/App 개발 관련

[iOS] TDD(Test Driven Development) Tutorial

raywenderlich - Test Driven Development Tutorial for iOS

 

목차 (눌러서 이동)

 

    TDD 의 장점

    Test code 는 Production code 의 작성과 함께 계속 수정되므로 개발 내용을 계속해서 따라간다. 그래서 작성한 Test 자체가 앱의 동작 방식에 대해 기술한 개발 문서가 된다.

     

    TDD 방식으로 개발할 때 Test code 의 Code coverage 가 당연히 더 좋고, 추후에 중대한 update 를 하게 될 경우 훨씬 쉽다.

     

    한 명이 Test code 를 작성, 다른 한 명이 Production code 를 작성하며 Pair-programming 을 적용하기 쉽고, 이로 인해 개발 속도는 빠르지만 튼튼한 앱을 만들게 된다.

    Red - Green - Refactor cycle

    TDD 개발은 보통 다음과 같은 흐름으로 개발한다.

     

    가장 먼저 Red 단계로 Failing Test 를 작성한다. 원하는 동작이 구현됐을 때 통과해야할 테스트를 작성하는 것이다. 한 번에 하나의 동작만 테스트하는 것이 좋다.

     

    Green 단계에서는 작성된 Failing Test 를 통과할 수 있도록 Production Code 를 작성한다. Test 되지 않는 코드를 최소화해야 하므로 Test 를 겨우 통과할 수 있을 정도로만 작성해야 한다. 

     

    Test 를 통과하여 원하는 동작이 구현되면 Refactoring 과정을 통해 여러 Test 들에서 중복되는 코드를 함수로 따로 정리하는 등 최적화 과정을 거친다.

     

    모든 use case 를 처리할 수 있을 때 까지 위의 과정을 반복한다.

    TDD 시작하기

    숫자 변경에 대한 코드(Converter)를 작성하기 전에 실패할 Test code 를 먼저 작성한다. 새로운 Unit test class 를 선언하고 다음과 같이 작성한다.

     

    import XCTest
    @testable import Numero
    
    class ConverterTests: XCTestCase {
    	let converter = Converter()
    
    	override func setUpWithError() throws {}
    	override func tearDownWithError() throws {}
    
    	func testConversionForOne() {
    		let result = converter.convert(1)
    
    		XCTAssertEqual(result, "I", "Conversion for 1 is incorrect")
    	}
    }

     

    1 을 로마 숫자로 변환하면 "I" 가 나와야 한다.그러므로, 이에 해당하는 Assert 구문을 작성한다.

     

    이는 convert() 메서드를 아직 작성하지 않은 현재 상황에서실패하는 Test code 이고, 앞으로 실제 convert() 메서드가 어떻게 작성되어야 하는지에 대한 Documentation 이 된다.

     

    따라서, 지금 작성한 "실패하는 Test" 를 만족시키는 방향으로 실제 Code 를 작성해 나가면 된다. 위의 Assert 구문을 겨우 통과할 정도의 Code 만 작성하자.

     

    convert() 함수가 "I" 을 반환하도록 한 뒤 Test 를 실행하면 모두 통과된다.

     

    이후에도 각 숫자들에 대해서 Failing test 를 작성한 뒤, 이를 통과시키기 위한 convert() 메서드를 작성해 나간다.

     

    2 에 대한 converter test

     

    func testConversionForTwo() {
    	let result = converter.convert(2)
    	XCTAssertEqual(result, "II", "Conversion for 2 is incorrect")
    }

     

    숫자만큼 "I" 를 반복하도록 작성, 3 까지 이런 방식으로 변환 가능하다.

     

    func convert(_ number: Int) -> String {
    	return String(repeating: "I", count: number)
    }

     

    5에 대한 converter test (4는 특별한 처리가 필요해서 일단 건너 뛴다.)

     

    func testConversionForFive() {
    	let result = converter.convert(5)
    	XCTAssertEqual(result, "V", "Conversion for 5 is incorrect")
    }

     

    마찬가지로 이번에 작성한 Test에 대해서만 통과할 수 있을만큼 작성한다.

     

    func convert(_ number: Int) -> String {
    	if number == 5 {
    		return "V"
    	} else {
    		return String(repeating: "I", count: number)
    	}
    }

     

    6에 대한 converter test

     

    func testConversionForSix() {
    	let result = converter.convert(6)
    	XCTAssertEqual(result, "VI", "Conversion for 6 is incorrect")
    }

     

    5를 넘는 수에 대해 "V"를 먼저 붙인 뒤 나머지에 대해 "I" 반복.

     

    func convert(_ number: Int) -> String {
    	var result = ""
    	var localNumber = number
        
    	if localNumber >= 5 { 
    		result += "V"
    		localNumber = localNumber - 5 
    	}
        
    	result += String(repeating: "I", count: localNumber)
    	return result
    }

     

    10에 대한 converter test

     

    func testConversionForTen() {
    	let result = converter.convert(10)
    	XCTAssertEqual(result, "X", "Conversion for 10 is incorrect")
    }

     

    5 에 대한 변환과 비슷하게 10 을 변환할 수 있다. 

     

    func convert(_ number: Int) -> String {
    	var result = ""
    	var localNumber = number
    
    	if localNumber >= 10 {
    		result += "X"
    		localNumber = localNumber - 10
    	}
    
    	if localNumber >= 5 {
    		result += "V"
    		localNumber = localNumber - 5
    	}
    
    	result += String(repeating: "I", count: localNumber)
    	return result
    }

     

    20 에 대한 converter test

     

                        func testConversionForTwenty() {
                          let result = converter.convert(20)
                          XCTAssertEqual(result, "XX", "Conversion for 20 is incorrect")
                        }

     

    약간의 패턴이 드러난다. 10 을 처리하는 구문에서 if 를 while 로 바꾸어 10마다 반복하게 한다.

     

    func convert(_ number: Int) -> String {
    	var result = ""
    	var localNumber = number
    
    	while localNumber >= 10 {
    		result += "X"
    		localNumber = localNumber - 10
    	}
    
    	if localNumber >= 5 {
            result += "V"
    		localNumber = localNumber - 5
    	}
    
    	result += String(repeating: "I", count: localNumber)
    	return result
    }

     

    4에 대한 converter test

     

    func testConversionForFour() {
    	let result = converter.convert(4)
    	XCTAssertEqual(result, "IV", "Conversion for 4 is incorrect")
    }

     

    func convert(_ number: Int) -> String {
    	var result = ""
    	var localNumber = number
    
    	while localNumber >= 10 {
    		result += "X"
    		localNumber = localNumber - 10
    	}
    
    	if localNumber >= 5 {
    		result += "V"
    		localNumber = localNumber - 5
    	}
    
    	if localNumber >= 4 {
    		result += "IV"
    		localNumber = localNumber - 4
    	}
    
    	result += String(repeating: "I", count: localNumber)
    	return result
    }

     

    9 에 대한 converter test

     

    func testConversionForNine() {
    	let result = converter.convert(9)
    	XCTAssertEqual(result, "IX", "Conversion for 9 is incorrect")
    }

     

    func convert(_ number: Int) -> String {
    	var result = ""
    	var localNumber = number
    
    	while localNumber >= 10 {
    		result += "X"
    		localNumber = localNumber - 10
    	}
    
    	if localNumber >= 9 {
    		result += "IX"
    		localNumber = localNumber - 9
    	}
    
    	if localNumber >= 5 {
    		result += "V"
    		localNumber = localNumber - 5
    	}
    
    	if localNumber >= 4 {
    		result += "IV"
    		localNumber = localNumber - 4
    	}
    
    	result += String(repeating: "I", count: localNumber)
    	return result
    }

    Refactoring

    Red, Green 과정을 거친 후 생산된 code 에서 중복을 제거하고 정리하기 위한 단계로 TDD cycle 에서 필수적인 단계이다.

     

    Green 과정으로 생산된 convert() 메서드를 살펴보자. 중복된 형태가 보이는데,  if 문들을 while 로 바꾸면 완전히 같은 형태의 while 문들이 눈에 띈다.

     

    while 로 바꾼 뒤 한 번 더 Test 를 실행해 해당 변화가 문제를 일으키지 않는지 확인하자. 이렇게 변화에 대해 문제가 없음을 확신할 수 있는 것도 TDD 의 장점이라 할 수 있다.

     

    "I" 에 대한 String(reapeating:,cout:) 구문도 while 문의 형태로 바꾼 뒤 Test 를 진행하자.

     

    func convert(_ number: Int) -> String {
    	var result = ""
    	var localNumber = number
    
    	while localNumber >= 10 {
    		result += "X"
    		localNumber = localNumber - 10
    	}
    
    	while localNumber >= 9 {
    		result += "IX"
    		localNumber = localNumber - 9
    	}
    
    	while localNumber >= 5 {
    		result += "V"
    		localNumber = localNumber - 5
    	}
    
    	while localNumber >= 4 {
    		result += "IV"
    		localNumber = localNumber - 4
    	}
                              
    	while localNumber >= 1 {
    		result += "I"
    		localNumber = localNumber - 1
    	}
    
    	return result
    }

     

    숫자에 대한 로마 숫자를 Tuple 로 저장한 뒤이를 순회하며 각 숫자에 대해 while 문의 형태를 만들자.

     

    func convert(_ number: Int) -> String {
    	var localNumber = number
    	var result = ""
    	
    	let numberSymbols: [(number: Int, symbol: String)] =
    	[(10, "X"),
    	 (9, "IX"),
    	 (5, "V"),
    	 (4, "IV"),
    	 (1, "I")]
    	
    	for item in numberSymbols {
    		while localNumber >= item.number {
    			result += item.symbol
    			localNumber = localNumber - item.number
    		}
    	}
    	
    	return result
    }

     

    Edge case 들에 대해 Test 를 작성하는 것 역시 중요하다. 0 은 로마 숫자로 나타낼 수 없으므로 빈 문자열을 기대하는 Test 를 작성한다. 이는 현재 작성된 convert() 메서드로 통과할 수 있다.

     

    func testConverstionForZero() {
    	let result = converter.convert(0)
    	XCTAssertEqual(result, "", "Conversion for 0 is incorrect")
    }

     

    큰 수에 대한 Test 를 작성해보자.

     

    func testConverstionFor3999() {
    	let result = converter.convert(3999)
    	XCTAssertEqual(result, "MMMCMXCIX", "Conversion for 3999 is incorrect")
    }

     

    천 단위까지의 로마 숫자를 convert() 메서드의 Tuple 에 mapping 시킨다.

     

    let numberSymbols: [(number: Int, symbol: String)] =
    [(1000, "M"),
     (900, "CM"),
     (500, "D"),
     (400, "CD"),
     (100, "C"),
     (90, "XC"),
     (50, "L"),
     (40, "XL"),
     (10, "X"),
     (9, "IX"),
     (5, "V"),
     (4, "IV"),
     (1, "I")]

     

    그런데, 사실 이번 Test 에서는 TDD 의 규칙에 잘 맞지 않는 방식으로 convert() 메서드를 작성 (TDD flow 의 Green 단계) 했다.

     

    Green 단계에서는 Red 에서 작성된 실패한 Test 를 겨우 통과할 만큼만 작성해야 한다.

     

    즉, 3999 에 대한 Test 를 작성하면서 10 부터 1000 까지 여러 로마 숫자를 mapping 했는데, 40, 50, 100, 400, 500 에 대한 정보는 Test 에서 사용되지 않는다. (실제로 해당 원소를 주석 처리하고 Test 해도 통과된다.) TDD 원칙대로 작성한다면 1000, 900, 90 에 대한 mapping 만 추가하면 된다.

     

    현재 작성된 테스트만 통과할 수 있도록 코드를 쓴다면 어떤 부분이 Test 되지 않았는지 고민할 필요도 없고, 단일한 Test 에 대해 코드를 작성하므로 단기적인 개발 목표가 명확해진다.

     

    이런 이유로 TDD 개발이 명료한 개발 문서의 이점과 좋은 Code coverage를 보여주는 장점을 가진다는 것 이다.

     

    주석 처리된 원소들을 Cover 하는 Test 들을 작성하자.

     

    func testConverstionFor40() {
    	let result = converter.convert(40)
    	XCTAssertEqual(result, "XL", "Conversion for 40 is incorrect")
    }
    
    func testConverstionFor50() {
    	let result = converter.convert(50)
    	XCTAssertEqual(result, "L", "Conversion for 50 is incorrect")
    }
    
    func testConverstionFor100() {
    	let result = converter.convert(100)
    	XCTAssertEqual(result, "C", "Conversion for 100 is incorrect")
    }
    
    func testConverstionFor400() {
    	let result = converter.convert(400)
    	XCTAssertEqual(result, "CD", "Conversion for 400 is incorrect")
    }
    
    func testConverstionFor500() {
    	let result = converter.convert(500)
    	XCTAssertEqual(result, "D", "Conversion for 500 is incorrect")
    }