Test Driven Development (테스트 주도 개발)
테스트 주도 개발
테스트
사용자에게 제공되기 전에 소프트웨어의 품질, 성능 등을 확립하기 위한 절차
수동 테스트의 비효율성
제품이 출시될 때 실제로 필요한 코드가 아닌 코드를 만들어야 하는 경우.
자동화된 테스트
소프트웨어로 다른 소프트웨어를 자동화
테스트 주도 개발 (Test Driven Development)
테스트 코드를 먼저 만들어놓고, 테스트하기 쉬운 소프트웨어 설계
예를 들어
- 개발자가 아닌 기획자도 경우의 수, 요구사항을 먼저 다 정의해놓고 시작.
- 필요한 모델을 먼저 구상
- 거기에 필요한 것을 하나씩 채워가며 ‘테스트’
특징
단, 높은 응집력과 낮은 결합도를 가진 컴포넌트로 구성된 소프트웨어를 구축하게 됨
요구사항에 대한 이해도 향상에 도움이 됨
구현이 완료된 후 테스트를 작성하는 것보다 먼저 작성하는 것이 더 좋은데, 해당 동작에 대한 구현이 없을 때 테스트가 실패하는 것을 보면 향후 회귀를 잡아낼 수 있다는 것을 신뢰할 수 있기 때문
구현 후 테스트를 시작하면, 내가 생각한 경우의 수만 생각할 수 있기 때문!
3의 배수, 5의 배수 그리고 3과 5의 공배수인지 아닌지 각각 알려주는 함수를 테스트한다고 해보자.
func fizzBuzz(_ number: Int) -> String {
let divisibleBy3 = number % 3 == 0
let divisibleBy5 = number % 5 == 0
switch (divisibleBy3, divisibleBy5) {
case (false, false): return "\(number)"
case (true, false): return "fizz"
case (false, true): return "buzz"
case (true, true): return "fizz-buzz"
}
}
이에 대해서 단순하게 하드 코딩, 모든 경우의 수에 대해서 작성한다면 아래와 같을 것이다.
func testFizzBuzz() {
if fizzBuzz(3) == "fizz" {
print("PASSED")
} else {
print("FAILED")
}
if fizzBuzz(5) == "buzz" {
print("PASSED")
} else {
print("FAILED")
}
if fizzBuzz(15) == "fizz-buzz" {
print("PASSED")
} else {
print("FAILED")
}
// 경우의 수를 계속 추가해야 함...
}
testFizzBuzz()
이렇게 하면 테스트 케이스를 추가할 때마다 동일한 패턴을 계속 작성해야 한다.
이로 인해 확장성 부족, 유지보수가 어려워지게 되고 코드의 가독성또한 떨어질 수 있다.
이렇게 하드 코딩하는 대신, 파라미터로 함수를 작성하면 반복되는 if-else 구조를 피할 수 있다.
func test(value: String, matches expected: String) {
if value == expected {
print("PASSED")
} else {
print("FAILED")
}
}
func testFizzBuzz() {
test(value: fizzBuzz(1), matches: "1")
test(value: fizzBuzz(3), matches: "fizz")
test(value: fizzBuzz(5), matches: "buzz")
test(value: fizzBuzz(15), matches: "fizz-buzz")
}
그러나 이렇게 생각할 수 있다. 이렇게 하는 게 과연 의미가 있는가?
테스트 주도 개발 (TDD)은 구현 전에 ‘테스트’를 먼저 작성하는 것에 있다.
코드가 복잡해지고, 리팩토링을 하는 과정에서 생각해보지 못한 경우에서 테스트 실패할 수도 있다.
그런데, 테스트 코드를 미리 작성해두었다면, 리팩토링을 할 때도 테스트 코드와 함께 가지고 가므로, 실수를 미연에 방지할 수 있다.
테스트 코드를 먼저 작성해본 경우
func test(value: String, expacted: String) {
if value == expacted {
print("PASS value: \(value)")
} else {
print("FAIL value: \(value)")
}
}
func FizzBuzz(_ number: Int) -> String {
if number % 15 == 0 {
return "fizz-buzz"
} else if number % 3 == 0 {
return "fizz"
} else if number % 5 == 0 {
return "buzz"
} else {
return "\(number)"
}
}
func testFizzBuzz() {
test(value: FizzBuzz(1), expacted: "1")
test(value: FizzBuzz(3), expacted: "fizz")
test(value: FizzBuzz(5), expacted: "buzz")
test(value: FizzBuzz(15), expacted: "fizz-buzz")
test(value: FizzBuzz(7), expacted: "7")
}
testFizzBuzz()
XCTest
(1) test target 추가
방법 - 1 (새 프로젝트를 추가하는 경우)
방법 - 2 (기존 프로젝트에 추가하는 경우)
UI Testing 은 회원가입, 로그인 페이지와 같은 서비스에 있어서 ‘필수적인 부분’만 하는 것은 괜찮지만, 다른 이벤트 페이지에 모두 추가하는 것은 권장 x (디자인, UI가 수시로 바뀔 수 있기 때문.)
일반적으로 Unit Testing을 추가한다.
윤년 테스트 프로젝트
윤년 계산 로직
윤년은 다음과 같은 규칙이 있다.
- 연도가 4로 나누어 떨어지면 윤년이다.
- 단, 연도가 100으로 나누어 떨어지면 윤년이 아니다.
- 단, 연도가 400으로 나누어 떨어지면 다시 윤년이다.
assert 의 의미
assert(1 + 1 == 2, "Math is broken!") // 조건이 참이므로 아무 일도 일어나지 않음
assert(1 + 1 == 3, "Math is broken!") // 조건이 거짓이므로 프로그램 중단 및 메시지 출력
assert는 프로그램에서 특정 조건이 참(true)인지 확인하는 데 사용됩니다. 조건이 참이 아니면 프로그램이 중단되고, 오류 메시지가 출력됩니다. 주로 디버깅 목적으로 사용되며, 조건이 항상 참이어야 하는 상황에서 코드가 올바르게 동작하는지를 확인하기 위해 사용됩니다.
XCTAssert
XCTAssert 에서도 이와 비슷한데, XCTAssert는 Swift의 XCTest 프레임워크에서 제공하는 함수들로, 단위 테스트를 작성할 때 사용된다.
XCTAssertTrue와 XCTAssertFalse, XCTAssertEqual는 특정 조건이 참인지 또는 거짓인지 확인하는 데 사용된다.
XCTest 코드를 작성할 때는 크게 세 가지 단계로 작성하면, 코드의 가독성을 향상시킬 수 있다.
- Arrange: 입력 준비
- Act: 테스트 대상을 진행
- Asser:t 출력 확인
특정 카테고리의 제품 가격 합계를 계산하는 sumOf 함수에 대한 테스트를 해보자.
struct Product {
let category: String
let price: Double
}
func sumOf(_ products: [Product], withCategory category: String) -> Double {
return products
.filter { $0.category == category }
.reduce(0.0) { $0 + $1.price }
}
final class SumOfProductsTests: XCTestCase {
func testSumOfEmptyArrayIsZero() {
// Arrange
let category = "books"
let products = [Product]()
// Act
let sum = sumOf(products, withCategory: category)
// Assert
XCTAssertEqual(sum, 0, "Sum of an empty array should be zero.")
}
func testSumOfOneItemIsItemPrice() {
// Arrange
let category = "books"
let products = [Product(category: category, price: 3)]
// Act
let sum = sumOf(products, withCategory: category)
// Assert
XCTAssertEqual(sum, 3, "Sum of an array with one item should be the price of that item.")
}
func testSumIsSumOfItemsPricesForGivenCategory() {
// Arrange
let category = "books"
let products = [
Product(category: category, price: 3),
Product(category: "movies", price: 2),
Product(category: category, price: 1),
]
// Act
let sum = sumOf(products, withCategory: category)
// Assert
XCTAssertEqual(sum, 4, "Sum of items prices for the given category should be the total of those prices.")
}
}
이렇게 이러한 방식으로 각 테스트는 특정 조건에서 sumOf 함수가 올바르게 동작하는지 확인하며, 테스트 실패 시 명확한 메시지를 제공할 수도 있다.
어디서부터 시작할 것인가?
가장 어려운 작업 부터? 혹은 이미 알고있는 간단한 작업부터?
=> 보통 TDD는 모델 정의 등 가장 어려운 작업부터 수행
UnitTest 코드 설명
import XCTest
@testable import Albertos // "Albertos"라는 프로젝트의 Production 코드를 사용할 수 있게 함.
final class MenuGroupingTests: XCTestCase {
func testMenuWithManyCategoriesReturnsOneSectionPerCategory() {
}
func testMenuWithOneCategoryReturnOneSection() {
}
func testEmptyMenumReturnsEmptySections() {
let menu = [MenuItem]() // arrange
let sections = groupMenuByCategory(menu) // act
XCTAssertTrue(sections.isEmpty) // assert
}
}
@testable import 이 코드를 주의.
import 를 했기 때문에, 프로덕션 코드에서 정의한 MenuItem, groupMenuByCategory 같은 코드를 테스트 코드에도 가져다 쓸 수 있는 것이다.
결론
테스트 주도 개발은 장단점이 있다.
테스트를 먼저 작성하면, 자연스럽게 순수 함수 사용을 이끌어낸다. 쉽게 말해. 테스트 코드를 작성할 때, 그 테스트를 패스하기 위해서 관련된 코드와는 응집력이 향상되지만(밀도 있는 코드가 작성됨).
그리고 다른 모듈(관계가 없는 코드)와는 독립적으로 작동하도록 설계할 수 있다.