테스트 주도 개발 (Test-Driven Development; TDD)

Ryan (Geonhee) Son·2021년 4월 4일
0

Study Stack

목록 보기
1/34
post-thumbnail

TDD는 Kent Beck이 제안하고 정리한 개념으로, 매우 짧은 개발 사이클을 반복하는 소프트웨어 개발 프로세스 중 하나입니다. 유닛 테스트를 먼저 수행(Unit Test First)으로써 단순한 설계를 장려하고 코드에 자신감을 불어 넣어주는 기법으로 자주 소개됩니다. TDD의 수행 순서를 간단히 표현하면 아래 도식과 같습니다.

TDD Cycle


1. 먼저 실패하는 테스트 케이스를 작성합니다.
2. 테스트 케이스를 통과하기만 하는 코드를 작성합니다.
3. 2의 과정을 완료한 코드를 보기 좋고 이해하기 용이하도록 다시 작성합니다 (리팩터; 코드개선). 리팩터 과정에서는 2에서 작성한 코드의 논리 흐름을 바꾸지 않습니다.
4. 리팩터 과정에서 실패 테스트 케이스를 추가적으로 발견할 경우 1의 과정으로 돌아갑니다.

TDD의 목적은 무엇이고 적용하면 어떤 이점이 있나요?

그럼 TDD의 수행 방법까지 알아보았는데, 듣기만 하니 왜 이것이 필요한지 잘 모르겠습니다. 이런 의문점들이 있네요.

TDD 질문 list

  • TDD가 지향하는 바는 도대체 뭐지?
  • 테스트를 하면 뭐가 좋지?
  • 테스트를 나중에 작성하면 안되나?
  • 그러면 어떤 테스트를 작성해야 할까?
  • 현업에서는 왜 TDD 적용을 어려워할까?

의문을 가졌으니 하나씩 살펴볼까요?

TDD가 지향하는 바는 도대체 뭐지? 🤔

TDD의 목적은 Clean Code That Works. 즉, 작동하는 깨끗한 코드를 표방하고 있습니다.

  • TDD를 적용함으로써 빠르게 실패하여 피드백을 받고 개선할 수 있으며, 작은 실패들을 반복하여 결국 원하는 목표를 이룰 수 있게 됩니다.
  • 큰 단위의 문제를 작은 단위로 나누게 됩니다. 즉, 코드를 테스트 하기 용이하게끔 기능 단위로 분리하게 됩니다.

테스트를 하면 어떤게 좋지?

테스트는 훌륭한 스펙 정의 문서가 됩니다. 어떤 테스트를 수행했는지를 보면 어떤 기능을 하는지, 한계점이 무엇인지를 파악할 수 있기 때문입니다. 예를 들어, 덧셈 기능은 한 자리 수끼리 연산, 두 자리 수끼리 연산, 한 자리와 세 자리 수 간 연산 등 여러 케이스가 있을 수 있습니다.

추가적으로는 아래와 같은 이점이 있습니다.

  • 예외사항을 미리 파악하고 걸러낼 수 있으며, 향후 이 기능의 사용자를 위해 관련 내용을 작성해 둘 수 있습니다.
  • 테스트를 해두면 향후 코드를 수정하는데 자신이 생깁니다. 테스트를 위해 코드를 기능별로 분리해두었기 때문에 어떤 부분이 문제가 되는지 금방 특정할 수 있기 때문이죠.
  • 코드의 로직을 변경하지 않는 리팩터링도 덩달아 용이해집니다.

테스트를 나중에 작성하면 안되나?

저와 같은 초보자는 스펙을 구체적으로 표현하기 어렵다는 이유로 일단 코드를 작성해보자는 생각을 가지기 쉽습니다. 하지만 테스트를 먼저 작성하면 아래와 같은 이점들을 얻을 수 있습니다.

  • 명확하고 구체적인 목표를 가지고 진행할 수 있다 (어떤 것을 만들어 낼 것인지 충분히 이해).
  • 빠른 피드백 (테스트)과 피드백에 대한 대응 (코드 수정)이 가능하다.

다음 의문에 대한 답을 찾아보기 전에 Unit Test의 개념을 먼저 알아볼까요?

Unit Test (유닛 테스트)

컴퓨터 프로그래밍에서 소스 코드의 특정 모듈이 의도된 대로 정확히 작동하는지 검증하는 절차입니다. 테스트 코드는 작성한 코드가 의도대로 동작하는지 검증하기 위한 코드입니다.

예를 들어, 아이폰을 충전기와 연결했는데 충전되지 않는 상황이 일어났다고 가정해보겠습니다. 만약 충전용 어댑터 (콘센트 연결부)와 어댑터에 연결하기 위한 케이블, 그리고 아이폰이 일체형이라면 어떤 부분이 문제인지 쉽게 알아낼 수 있을까요? 아닐 것입니다. 이처럼 아래와 같이 기능을 분리함으로써 테스트를 용이하게 할 수 있습니다.

  • 충전용 어댑터: C형 또는 F형 플러그 규격으로 220V 60Hz의 전원에 연결되어 전력을 연결된 케이블로 전달한다.
  • 충전용 케이블: 케이블 양 끝이 USB C to lightening 규격으로 이루어져 있으며 어댑터로부터 전달 받은 전력을 아이폰에 전달한다.
  • 아이폰: 전화, 메시지 수신 및 전송, 애플리케이션 사용 등 스마트폰의 기능을 제공한다. 충전용 케이블로부터 전력을 전달받아 충전할 수 있으며, 이를 위한 부품과 배터리가 내장되어 있다.


충전용 어댑터 (source: Wikimedia commons)

그러면 어떤 어떤 테스트들을 작성해야 할까?

테스트를 작성할 때는 FIRSTRight - BICEP이라는 키워드를 기억해두면 좋습니다. 하나씩 알아봅시다.

FIRST

  • FFast: 목표한 기능을 빠르게 검증할 수 있어야 합니다.
  • IIndependent: 테스트의 단위는 다른 테스트와 독립적이어야 합니다. 순수하게 목표한 기능만 테스트합니다.
  • RRepeatable: 반복적으로 테스트를 수행해도 동일한 결과를 얻을 수 있어야 합니다.
  • SSelf-validating: 스스로 검증할 수 있어야 합니다.
  • TTimely: 실시간으로 검증할 수 있어야 합니다.

Right-BICEP

  • RightRight: 결과가 올바른지 판단할 수 있어야 합니다.
  • BBoundary: 모든 경계(Boundary) 조건이 일치해야 합니다.
  • IInverse: 역(Inverse) 관계를 확인할 수 있어야 합니다.
  • CCross-check: 다른 수단을 사용하여 결과를 교차 확인(Cross-check) 할 수 있어야 합니다.
  • EError condition: 에러 조건(Error condition)을 강제로 만들 수 있어야 합니다.
  • PPerformance: 성능(Performance) 특성이 한도 내에 있어야 합니다.

왜 현업에서는 TDD를 적용하는 곳이 적을까?

그러면 이렇게 좋은 점이 많은데, 현업에서 TDD를 적용하는 곳이 적을까요? 먼저, 테스트를 수행하기 위한 활동 또한 비용으로 인식되기 때문입니다. 오직 코드가 동작하는지 여부에만 관심이 있기에 테스트와 이를 위한 활동들이 낭비라고 생각하는 경우가 있습니다. 하지만 테스트를 수행하지 않으면 이후에 더 큰 비용을 감당해야할 수 있습니다. 예를 들어, 테스트를 작성하지 않으면 향후에 문제가 발생했을 때 어느 부분이 문제인지 알 수 없어 유지보수에 굉장히 많은 시간과 비용이 드는 경우를 고려해볼 수 있습니다. 심지어 새로 코드를 작성하게 되는 일도 생길 수 있죠. 반면에, 테스트 코드를 작성하면 이전에 작성한 내 코드를 돌아보지 않아도 됩니다. 새로 작성한 코드로 인해 프로그램이 잘못 작동한다면 새로 작성한 코드를 의심해볼 수 있겠죠.

객체지향 생활 체조 훈련법 9가지

TDD 예시를 살펴보기 전에 코드를 작성할 때 참고할 점들을 가져왔습니다. 실생활에서 지키기는 어렵겠지만 연습할 때는 극단적으로 해야 실생활에서 반이라도 하겠죠..? 저에게 하는 말입니다..

TDD 적용 예시

예시로써 Queue를 구현해보도록 하겠습니다. QueueStack과는 달리 먼저 삽입된 요소가 먼저 인출되는 구조를 가지고 있습니다 (선입선출). 저는 동일한 속도로 진행하고 있으면 먼저 진입한 사람이 먼저 나오게 되는 터널 통과하기에 비유하고 싶습니다.

프로젝트 생성 - 테스트 만들기

테스트를 하려면 프로젝트를 먼저 만들어야겠죠. iOS 앱용 프로젝트를 생성하면 아래 창과 같이 Include Tests라는 체크박스를 선택할 수 있습니다. 테스트를 포함하는 프로젝트를 만들고 싶으시다면 체크하고 진행하시면 되겠습니다.

그럼 체크박스를 해제하고 만들었다면 어떻게 테스트를 추가할 수 있을까요? 프로젝트 파일을 눌러봅시다.

그럼 위와 같은 창이 보일텐데요. 중앙에서 좌측을 보시면 PROJECTTARGETS라고 표현된 부분이 있습니다. 해당 탭에서 아랫 부분을 보시면 +-, Filter가 보이실겁니다. +를 눌러볼까요?

그럼 아래와 같은 창이 표시될겁니다. iOS 탭에 있는 Unit Testing Bundle을 선택하여 파일을 테스트 파일을 생성해줍니다. Target to be Tested에 테스트할 대상이 들어가있는지 확인해주세요.


테스트 #1 - Queue를 구현할 클래스 타입 만들기

저는 아래와 같이 MyQueue.swift라는 파일을 만들어 내부에 Queue 구현을 해보도록 하겠습니다.

요구사항:

  1. Queue는 Numeric 프로토콜을 따르는 Item을 요소로 받을 수 있다.
  2. Queue의 요소를 담아두는 프로퍼티는 인스턴스 외부에서 수정이 불가능하도록 읽기 전용으로 구현한다.
  3. 인스턴스 생성 시 이니셜라이저를 통해 Queue의 요소를 미리 지정할 수 있다.
  4. 편의 이니셜라이저를 통해 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이 설정되어 있는지 확인해주어야 합니다.




위와 같이 저는 MyQueueTestsBuildTest에 잘 정의되어 있네요. 이 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])
    }
}

sutSystem Under Test의 의미로 사용하였습니다. 편의 이니셜라이저를 통해 빈 큐를 생성하는 test_initializing_MyQueue_withNoItem() 테스트 케이스와 정수 세 개를 큐에 넣어 이니셜라이징하는 test_initializing_MyQueue_withSomeInts() 테스트 케이스를 만들어 봤습니다. 테스트가 끝날 때는 sutnil을 할당하여 디이니셜라이징을 시켜주었습니다. 그럼 테스트를 해봅시다! 아래 그림과 같이 좌측 상단에 있는 재생 버튼을 1초 정도 누르고 계셔보시면 Test 버튼이 나타날겁니다. 이 버튼을 통해 테스트하셔도 되고, ⌘(cmd) + u를 통해 단축키로 테스트를 실행하셔도 됩니다.

그럼 곧 테스트 성공 표시와 함께 기분 좋은 효과음이 들리고 초록색 체크 마크가 성공한 테스트 옆에 표시될 것입니다.

이로써 MyQueue 타입의 이니셜라이징이 성공적으로 이루어지는지 테스트해보았습니다. 계속해서 Queue에 요소를 추가하는 enqueue 기능을 만들어보겠습니다.

테스트 #2 - 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 기능을 구현해봅시다!

테스트 #3 - dequeue 기능 구현하기

dequeue 기능 요구사항

  1. 요소를 한 개 꺼낼 수 있다.
  2. 요소를 한 번에 여러 개 꺼낼 수 있다.

이를 만족하는 코드를 작성해보겠습니다. 먼저 비워두었던 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 기능을 구현해보겠습니다.

테스트 #4 - reset 기능 구현하기

reset 기능 요구사항

  1. 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!!

테스트 하기 좋은 코드가 항상 좋은 코드를 의미하는 것은 아니니 상황에 맞춰 적용하는 것이 중요합니다.

공부할 내용

  • SUT
  • Test Doubles
    • dummy
    • stub
    • fake
    • spy
  • Mock

참고 링크

Test 관련 용어 정리 - 기계인간 John Grib

profile
합리적인 해법 찾기를 좋아합니다.

0개의 댓글