[iOS] TDD와 Unit Test

2021. 2. 6. 16:59iOS

반응형

TDDTest-Driven Development라는 뜻으로 한국어로 번역하면 테스트 주도 개발이라는 개발을 할 때 어떤 방법으로 진행하는지를 정의한 방법론입니다.

 

즉, 개발을 진행함에 있어서 테스트 코드를 매우 중요하게 여긴다는 뜻입니다. 그렇다면 어떻게 이 테스크 코드로 주도해 나갈까요? 🤔

 

테스트 코드를 먼저 작성함으로 테스트 코드를 이용해서 개발을 이어나간다는 뜻입니다. 밑의 그림을 보면 TDD의 프로세스를 표현한 그림인데, 이 프로세스를 짧은 주기로 반복하며 프로그램을 개발해 나가는 방법이 바로 TDD - Test-Driven Development라고 합니다.

그렇다면 각 단계는 어떤 것을 의미할까요?

 

RED

  • 실패하는 단계
  • 실패하는 테스트 케이스를 먼저 만든다. 프로젝트 전체로 생각하지 말고 우선 구현해야할 기능 하나씩의 테스트 케이스를 작성한다.

GREEN

  • 작성된 테스트를 통과하는 코드 작성
  • 일단은 통과에만 의의를 두고 빠르게 작성한다.

REFACTOR

  • 녹색 단계에서 작성한 코드에서 변수나 중복되는 코드들을 새롭게 리팩토링 하는 단계

 

그렇다면 이 각 단계들에서 실제 어떻게 코드를 작성하고 코드를 수정하고 다음 단계로 나아갈까요..? RED의 프로세스가 가장 이해가 되지 않았는데요. (왜 굳이 실패하는 테스트 케이스를 작성하는거지..?, 한 번에 성공하는 케이스를 작성하면 되지 않을까?) 실제 TDD 방식으로 구현해 나가는 것의 예시를 보았을 때, RED 단계는 정말 실패하는 케이스라기 보다는 개발해 나가는 과정에서 추상화시켜 작성한다..(?)로 이해를 했습니다.

 

한 번, 실제 간단한 예시들로 적용해보면서 알아보겠습니다.

각 Unit Test를 위해 모두 given - when - then 이라는 구조를 거칠 것입니다.

given - 테스트를 준비하기 위해 변수를 초기화 및 입력하거나 정의하는 단계입니다.
when - given 단계에서 준비한 변수를 활용해 실제 액션을 실행하는 단계입니다.
then - 마지막으로 XCTest의 함수를 이용해서 테스트를 진행하는 단계입니다.

 

 

TDD의 예시

모바일 개발자의 입장에서 이런 구현 사항이 있을수도 있고 없을수도 있지만 매우 단편적인 예시로 한번 TDD 프로세스에 대한 이해를 도와보겠습니다.

 

우선 현재 개발의 요구 사항은 어떤 게임에서 점수의 최댓값, 최솟값 구할 수 있게해서 화면에 보여주고 UI를 구현하라는 요구 사항입니다.

 

그렇다면 현재 디자인이 나오기 전까지 개발자가 할 수 있는 역할은 무엇일까요?

바로 최댓값, 최솟값, 평균값을 구하는 로직을 구현하는 것입니다. 여기서 TDD를 이용해서 각 기능 단위를 구현해보고 고쳐나가겠습니다.

  • RankingManager.swift - 랭킹을 관리하기 위해 만든 객체
  • RankingManagerTests.swift - 랭킹을 관리하는 객체의 동작을 Test하는 객체

 

최댓값 구하기

우선 RED 단계의 프로세스를 거치기 위해 코드를 작성해줍니다. 실패하는 테스트 케이스를 만들 것입니다. 당연히 실패할 수 밖에 없습니다.

 

RankingManagerTest.swift

func testFindMax() {
    // given
    let rankingManager = RankingManager()
    let scores: [Int] = [10, 40, 30, 60]
    
    // when
    let max = rankingManager.findMax(from: scores)
    
    // then
    XCTAssertEqual(60, max, "FindMax Function doesn't work well")
}

당연히 실패할 것입니다. 아직 findMax라는 함수를 구현하지 않았죠? 여기서 RED 단계는 거쳐갔다고 생각합니다. 이제 GREEN 단계로 들어가서 코드를 작성하여야 합니다.

 

GREEN 단계는 성공은 하지만 일단 통과만하는 코드를 빠르게 작성합니다. REFACTOR 단계에서 새롭게 변수명을 바꾼다던가 중복되는 코드를 줄이거나하는 작업을 할 것이기 때문입니다.

 

이제 GREEN 단계입니다.

 

RankingManager.swift

class RankingManager {
    func findMax(from scores: [Int]) -> Int {
        var max = scores[0]

        for index in 0..<scores.count {
            if max < scores[index] {
                max = scores[index]
            }
        }

        return max
    }
}

이제 GRREN 단계에서 빠르게 기능을 구현했기 때문에, 당연히 원하는 결과인 60이 나오는 것을 확인할 수 있고 테스트가 통과했습니다.

 

이제 빠르게 작성한 코드를 어떻게 리팩토링 할 수 있나 고민하는 단계인 REFACTOR 단계입니다. 어떻게 리팩토링 할 수 있을까요. 각자가 생각하는 방법으로 더욱 코드를 깔끔하게 하는 단계입니다.

 

RankingManager.swift

class RankingManager {
    func findMax(from scores: [Int]) -> Int {
        return scores.max() ?? 0
    }
}

한 줄로 줄여주었죠...? 이런식으로 본인이 원하는 방향으로 리팩토링을 진행해 코드를 깔끔하게 다듬어줍니다.

이렇게 마무리해주면 TDD 방식으로 최댓값을 구하는 함수가 완성되었습니다.

 

최솟값 구하기

최솟값 역시 TDD 방식으로 구하는 함수를 작성하고 리팩토링 해보겠습니다. RED 단계를 거칩니다.

 

RankingManagerTest.swift

func testFindMin() {
    // given
    let rankingManager = RankingManager()
    let scores: [Int] = [20, 10, 50, 30]
    
    // when
    let min = rankingManager.findMin(from: scores)
    
    // then
    XCTAssertEqual(10, min, "FindMin Function doesn't work well")
}

이 구문 역시 당연히 실패합니다. 이제 다음 GREEN 단계에서는 성공하는 코드를 작성하는데 역시 리팩토링은 생각하지 않고 동작하는 코드로 빠르게 작성하는 것이 포인트입니다.

 

RankingManager.swift

func findMin(from scores: [Int]) -> Int {
    var min = scores[0]
    
    scores.forEach { score in
        if min > score {
            min = score
        }
    }
    
    return min
}

GREEN 단계에서 당연히 성공하는 로직을 추가해주었기 때문에 성공하는 것을 알 수 있습니다. 이제 최댓값 구하기와 마찬가지로 코드를 더 줄일 수 있으면 줄이고 깔끔하게 다듬는 REFACTOR 단계가 필요합니다.

 

RankingManager.swift

func findMin(from scores: [Int]) -> Int {
    return scores.min() ?? 0
}

findMax 함수와 동일하게 한 줄로 리팩토링 해주었습니다. 이렇게 작성하고 나면 역시 최솟값을 찾는 함수를 TDD 방식을 활용해서 만들어 준 것입니다.

 

 

비동기 Unit Test

보통 네트워킹 작업을 Unit Test하는 과정이 필요할 때, 다음과 같은 방법으로 XCTest 프레임워크에서 기능을 제공해주고 있다.

 

  • XCTestExpectation : 비동기로 진행되는 작업을 테스트하기 위해 제공해주는 메소드이다. 이 객체가 fulfill() 될때 동기적으로 동작함으로 비동기적인 작업을 테스트하는데 사용할 수 있다.
  • wait(_: [XCTestExpectation], _: TimeInterval) : XCTestExpectation이 만족되기를 기다리는 메소드이다. timeout이 될 때까지 XCTExpectation을 기다리고 완료되었을 때, 다음 줄로 코드가 넘어가서 동기적으로 동작한다.
  • XCTestExpecation.fulfill() : XCTestExpectation에서 이 메소드를 호출하면 작업이 완료된 것으로 알고 wait 메소드로 알리고 다음 동작을 할 수 있게 한다.

예시

func testDownloadWebData() {
    // given
    let expectation = XCTestExpectation(description: "Complete Download Well!!")
    let url = URL(string: "https://apple.com")!
    var resultData: Data?
    
    let dataTask = URLSession.shared.dataTask(with: url) { data, _, _ in
        resultData = data
        expectation.fulfill()
    }
    
    // when
    dataTask.resume()
    
    // then
    wait(for: [expectation], timeout: 5)
    XCTAssertNotNil(resultData, "No data was downloaded")
}

여기서 XCTestExpectation을 생성해주고 비동기적으로 작업이 실행되고 data를 받아왔을 때, 해당 구문에서 fulfill()을 실행시켜 값이 들어왔음을 알려주는 예시이다.

여기서 Unit Test가 fail이 나는 두 가지 상황이 있다.

  1. 5초가 넘어서 timeout이 발생한 상황, 즉 네트워크 연결이 제대로 되지 않아 시간이 경과한 경우이다.
  2. 네트워크 요청은 성공적으로 갔으나 XCTAssertNotNil에서 nil 값이 들어와 테스트가 fail하는 경우이다.

네트워킹의 경우 Unit Test를 진행할 때, 외부 모듈에 의존적인 경우가 많기 때문에 Mock이라는 객체를 만들어서 인터넷 연결 없이도 순전히 Unit Test만을 위해서 사용하는게 더욱 좋은 방법인 것 같다. 그렇게 코드를 작성하게 되면 더욱 Protocol 지향적이고 외부에 의존적이지 않은 프로그램이 완성될 것 같다.

 

 

느낀점

TDD라는 개발에 대한 방법론은 들어보기만 하고 Unit Test도 역시 완성되어 있는 앱에 적용만 해봤지 이렇게 공부를 하면서 TDD를 해보는 것은 처음이었습니다.

 

TDD를 하면서 몇 가지를 문득 느낀 것이 있는데, 보통 TDD라고 하면 익숙하지 않고 코드를 작성하기 이전에 먼저 테스트 코드를 작성하고 개발에 들어가기 때문에 느리다라는 의견들이 많습니다. 또 그렇기 때문에 대부분의 팀에서 적용하지 않고 개발에 착수하는 경우도 많은 것으로 알고 있습니다. 처음에는 왜 이걸 굳이 사용해야하지..(?) 그냥 바로 코드로도 작성할 수 있을 것 같은데 두 번 일을 하는 것이 아닌가(?) 등의 부정적인 생각이 많았습니다.

 

하지만 예시로 간단한 코드를 작성하면서도 느낀 것은 첫번째로 어쩔 수 없이 기능 단위로 개발을 진행하다보니 모듈이 나뉜다는 것이었습니다. 어쩔 수 없이 SOLID 원칙을 지키게 되는..(?) 일석이조인 상황이 나왔습니다. 그리고 그 모듈은 테스트 케이스를 통화했기 때문에 프로그램 실행 중 에러가 나면 다른 부분을 확인할 수 있는 더 효율적인 상황도 나올 것이라고 생각이 들었습니다.

 

그리고 앱을 개발할 때, 서버를 연결하고 동작을 확인하는 단계에서 앱이 커지고 연결해야하는 API가 많게 되다 보면 앱을 빌드하는데도 시간이 많이 소비되고 API가 잘못되었는지 클라이언트에서 잘못했는지 확인이 어렵고 오히려 시간이 더 많이 걸리게 되는 상황들이 있었습니다. 이런 경우에서는 역설적으로 TDD로 각 API를 연결하는 모듈들이 잘 동작하는지 Unit Test가 끝난 상황이라면 앱을 굳이 빌드하지 않고도 잘 동작하는 것을 확인하고 문제가 있을 시, 다른 부분에서 더 수정을 하고 해결을 할 수 있는 상황이 나올 것 같다고 생각했습니다.

 

하지만 너무 간단한 모듈일 경우에는 TDD를 적용하지 않고 바로 코드를 작성하고 개발을 들어가는게 더욱 효율적일 것 같습니다. 팀의 상황에 맞게 TDD를 적용하고 프로젝트의 모든 코드보다는 복잡도가 높은 뷰의 경우 TDD를 적용하는 유연한 개발이 중요할 것 같습니다.

 

 

결론

  • 모든 프로젝트에 TDD를 적용하려고 하지말자
  • 복잡도가 높은 뷰일수록 TDD를 적용하는 것이 더 개발 시간을 줄여주고 빌드하는 수고를 덜어주는 작업일 수도 있다.
  • TDD를 적용하면 SOLID 원칙도 어느정도 지켜지게 되는 것 같다.

 

 


참고 문서

반응형