TDD와 Xcode에서 XCTest 활용하기

Jake Yeon·2020년 12월 15일
6

ETC

목록 보기
1/3
post-thumbnail

TDD의 개념

테스트 주도 개발은(TestDrivenDevelopment)은 매우 짧은 개발 사이클을 반복하는 software 개발 프로세스 중 하나이다. 개발자는 새로운 함수를 정의하는 자동화된 TestCase를 먼저 작성한다. 이후에 TestCase를 통과하기 위한 최소한의 양의 코드를 생성한다. 그리고 마지막에 해당 코드들을 표준에 맞도록 refactoring 하는 것이 바로 TDD 개발 방법이다. - wiki백과

사전적인 설명만으로는 충분한 이해가 되지 않을 수 있다.
기존의 개발 프로세스와 TDD의 차이를 간단하게 아래의 그림으로 비교해보자.

기존의 개발의 프로세스들은 설계를 한 뒤 코드를 작성하고 이후에 테스트를 진행하고 나서 다시 설계를 수정하는 방식이었다면, TDD 프로세스의 경우에는 설계를 한 뒤 테스트 코드를 작성하고 테스트 하기 때문에 설계의 문제로 인한 오류 개선 속도가 기존 개발 프로세스보다 빠르다고 할 수 있다. 하지만 이것만으로는 아직까지 정확히 TDD가 무엇이고, 왜 사용하면 좋은지에 대해서는 잘 모르겠다. 앞으로의 포스팅에서 알아보도록 하자.

TDD의 3가지 절차

TDD는 아래의 그림과 같은 cycle을 따른다. 하나씩 알아보도록 하자.

Red(실패) : Writing a Failing Test

TDD 개발에서의 첫 번째 단계는 실패이다. 즉, 실패하는 테스트 케이스를 작성하는 것이다. 이때 테스트 케이스는 프로젝트 전체의 모든 기능에 대해서 모든 케이스를 작성하는 것이 아니라, 먼저 구현할 기능에 대해서 테스트 케이스를 하나씩 작성해 나가는 것이다.
❖ 물론 상황에 따라서 한 번에 여러 개의 테스트 케이스를 작성하는 경우도 있다.

Green(성공) : Write just enough code to pass it

두 번째 단계는 성공이다. 실패 단계에서 작성한 테스트 케이스를 통과시키기 위한 최소한의 코드를 작성하는 단계이다.

Blue(리팩토링): Change code for better without chaning the behavior

세번째 단계는 리팩토링이다. 성공 단계에서 작성한 코드에 중복되는 코드나, 개선시킬 방법이 있다면 리팩토링을 진행해준다. 리팩토링을 진행하면서 매 단계마다 테스트 케이스를 통과하는지 확인해야한다. 이 단계까지 구현이 완료되었다면 다시 첫번째 단계로 새로운 실패하는 테스트 케이스를 작성하면 된다.

TDD의 장점

  1. 객체지향적인 코드 개발
    테스트 코드를 먼저 작성한다면 명확한 기능과 구조를 설계할 수 있게된다. 또한 테스트의 용이성을 위해 복잡한 기능들을 하나의 함수에 모두 구현할 경우 테스트 방식이 복잡해지고 시간이 오래 걸리며 코드 수정이 되는 경우 테스트 코드를 재 사용할 수 없게되므로 TDD 프로세스를 따르는 코드의 경우에는 재사용성을 고려하며 작성하게 된다.
  2. 설계 수정 시간의 단축
    기존 개발 프로세스와의 TDD 프로세스를 비교한 그림에서 언급하였듯이 테스트 코드를 먼저 작성하고 바로 테스트를 해보기 때문에 설계의 구조적인 문제를 바로 찾아날 수 있게 된다. 그리고 빠르게 피드백을 주고 받으며 수정을 하고 추가로 다시 테스트를 진행하므로 개발이 완료된 시점에서 설계의 문제점을 발견하고 설계를 수정한 뒤 코드 전체를 수정하지 않아도 된다.
  3. 디버깅 시간의 단축
    단위 테스트 기반의 테스트 코드를 작성하기 때문에 추후에 프로그램에 문제가 발생하였을 때에도 각각의 모듈 별로 테스트를 진행해보면 문제의 지점을 쉽게 찾아낼 수 있게된다. 만약 기존 개발 프로세스라면 버그를 찾기 위해서 프로그램 전체 코드를 봐야할 수도 있는데, 프로그램이 큰 경우 DB, Application, Data, Memory 영역 등으로 다양한 경우에는 버그를 찾아내기가 어려워진다. 이런 경우 TDD 프로세스를 따른다면 각각의 단위 테스트를 진행하기 때문에 영역을 분할하여 쉽게 버그를 찾아낼 수 있다.
  4. 유지 보수의 용이성
    개발이 완료된 시점에서 어떠한 기능을 추가하거나 수정하는 경우에 가장 고려되는 부분이 수정하거나 추가한 코드가 전체 프로그램에 어떠한 영향을 미칠지 모른다는 점이다. 따라서 단순한 기능이라도 수정되거나, 추가되는 경우에는 모든 기능들을 처음부터 다시 테스트를 해야한다. 하지만 TDD 프로세스를 따르는 경우에는 자동화된 유닛 테스팅을 전재하므로 이러한 테스트 기간 역시 줄어들며, 기능을 수정하거나 추가할 때의 부담을 줄일 수 있다.
  5. 테스트 문서의 대체 가능
    기존의 개발 프로세스를 따르는 경우에는 테스트 정의서에 어떠한 요소들이 테스트 되었는지에 대해서 적혀있다. 그러나 이러한 내용은 단순 통합테스트 문서에 지니지 않는다. 즉, 내부의 있는 모듈들의 각각 어떠한 방식으로 테스트 되었는지 알 수 없다. 하지만 TDD 프로세스를 따르는 경우에는 테스팅을 자동화 시킴과 동시에 정확한 테스트의 근거를 산출할 수 있다.

단위테스트(Unit Test)

단위테스트(Unit Test)는 컴퓨터 프로그래밍에서 소스 코드의 특정 모듈이 의도된 대로 정확히 작동하는지 검증하는 절차이다. 즉, 모든 함수에 대한 테스트 케이스(TC; TestCase)를 작성하는 절차를 말한다. 각 테스트 케이스는 서로 분리 되어야 한다. 이를 통해서 언제라도 코드 변경으로 인해 문제가 발생할 경우, 단시간 내에 이를 파악하고 바로 잡을 수 있도록 해준다. -wiki백과

TDD를 설명하다가 갑자기 분위기 단위테스트라니 뜬금 없을 수도 있겠지만, 사실은 TDD의 첫 단계인 실패하는 테스트 코드를 작성하는 단계에서 기능 단위의 테스트 코드를 작성하는 것이 단위테스트라고 할 수 있다. 물론 단위테스트는 TDD처럼 꼭 테스트 코드를 먼저 작성하거나, 리팩토링을 하지는 않는다. 단지 순수하게 테스트 코드를 작성하는 것을 의미한다.

이러한 단위테스트를 작성하는 것에는 여러 장점이 있는데 간단히 알아보자.

단위테스트 장점

  • 문제점 발견 → 프로그램의 각 모듈들을 고립시켜서 각각의 부분들이 정확하게 동작하는지 확인하는 것이다. 따라서 문제가 발생시 어느 부분이 잘못되어있는지 정확하게 확인할 수 있고 디버깅 시간을 단축시킬 수 있다.
  • 변경이 쉽다 → Unit Test를 믿고 refactoring을 언제든지 할 수 있고, 변경 후에 해당 모듈이 잘 작동하는지 확인하는 unit test인 회귀 테스트를 통해서 변경된 모듈을 확인할 수 있다.
  • 통합이 간단 → 유닛 자체의 불확실성을 제거해주므로 bottom-up(상향식) 테스트 방식에서 유용하다. 각 모듈을 검증하고 모듈들을 합쳐서 다시 검증하는 통합 테스트에서 더욱 효율적이다.

단위테스트는 어떻게 작성해야할까?

단위테스트를 작성하는데에 있어서 FIRST 속성을 지킨다면 좋은 단위테스트를 작성할 수 있다.

FIRST : 테스트에 대한 모범 사례 (Best Practices for testing)

  • Fast(빠르게) : 테스트는 빠르게 실행되어야 한다.
  • Isolated/ Independent(분리된/ 독립적인) : 테스트는 따로 설정이나 분리를 해서는 안된다.
  • Repeatable(반복가능한) : 테스트 수행할 때 마다 동일한 결과를 얻어야한다. 외부의 데이터 공급자나 동시성 문제로 인해 일시적 오류는 발생할 수 있다.
  • Self - validating(자체 검증) : 테스트는 완전히 자동화 되어야 한다. 로그 파일에 대한 프로그래의 해석보다는 "pass" or "fail" 출력
  • Timely(적시에) : 이상적인 테스트는 테스트한 생산 코드를 작성하기 전에 작성해야한다.

단위테스는 무엇을 테스트 해야할까?

개발자들이 직접 작성한 코드라고 하더라도 단순히 메서드, 클래스의 코드들을 보면서 숨어있는 버그를 찾아내는 것은 거의 불가능하다. 물론 코드를 직접 읽어보면서 찾는 것도 좋은 방법이긴 하지만 효율적인 단위테스트를 통해서 버그를 찾아내는 것이 훨씬 효율적인 방법이 아닐까 싶다. 그렇다면 효율적인 단위테스트를 위해서는 어떠한 것을 고려해야할까?
바로 좋은 테스트 케이스들이 있다면 효율적으로 코드들을 테스트 할 수 있을 것이다.
아래의 Right - BICEP는 무엇을 테스트할지에 대해서 쉽게 선별할 수 있도록 도와준다.

Right - BICEP

Right - 결과가 올바른가?

  • Boundary : 모든 경계 조건이 correct 한가? (경계값들의 조건)
  • Inverse : 역 관계를 확인할 수 있는가?
  • Cross-Check: 다른 수단을 사용해서 결과를 교차 확인할 수 있나?
  • Error Condition: 에러 조건을 강제로 만들 수 있나?
  • Performance: 성능 특성이 한도내에 있나?

Xcode에서 XCTest 사용하기

Xcode에서 Unit Test 프로젝트 만들기

Xcode에서 TDD 프로세스에 맞춰서 개발을 하고 싶은 경우에는 프로젝트를 생성할 때 부터 아래의 그림처럼 Include Tests에 체크를 하면 된다.

그럼 아래의 그림과 같이 Unit test class들이 생성된 채로 프로젝트가 생성되는 것을 확인할 수 있다.

만약 처음에는 테스트를 할 생각이 없어서 체크하지 않고 프로젝트를 만들었어도 나중에 Unit test class와 target을 추가할 수 있다. 아래의 그림에서 Xcode Test Navigator를 누르고 아래의 + 버튼을 눌러서 New Unit Test Target을 설정하면 된다.

이후에 기본적으로 생성되는 테스트 코드들과 함께 테스트 클래스가 생성된다.

Unit Test 클래스

XCTest 를 import하고 CalculatorTestsXCTestCase 의 하위 클래스로 정의하며, setUp() , tearDown() 과 같은 테스트 메소드를 정의한다.
이때 테스트 클래스를 실행하는데에는 3가지 방법이 있다고 한다.

  1. Product\Test 또는 Command + U 를 사용해서 모든 테스트 클래스를 실행한다.
  2. 테스트 네비게이터에서 화살표 버튼을 클릭한다.
  3. 거터(gutter: 배수로, 홈통) 에서 다이아몬드 버튼을 클릭한다.

모든 테스트가 성공하면, 다이아몬드는 녹색으로 바뀌게 되고 체크 표시가 나타난다. testPerformanceExample() 의 끝에 있는 회색 다이몬드를 클릭하면 성능 결과를 확인해 볼 수 있다.

만약 성능 테스트가 필요한 것이 아니라면 testPerformanceExample() 은 삭제해도 된다.

프로젝트에서 Model을 테스트 하기 위해서는 XCTAssert의 여러 함수들을 사용하게 된다. 앞으로 포스팅에서 예시로 나오는 프로젝트는 계산기 프로젝트이다. 이를 염두하고 포스팅을 보면 될 것 같다.
먼저 Calculator 모델의 핵심 기능을 XCTAssert를 사용해서 테스트한다. CalculorTests.swift 파일에 import 구문 아래에 다음과 같은 줄을 추가해준다. 이를 통해서 Unit Test가 Calculator의 클래스와 메소드에 접근할 수 있게 해준다.

@testable import Calculator	

다음은 CalculatorTests 클래스의 프로퍼티를 추가해 준다. 이때 객체를 만드는 부분은 setUp에서, 해제해주는 부분은 tearDown()에서 진행해주면 된다. 그 이유는 다음과 같다.

모든 테스트가 깨끗한 상태(state)로 시작하는지 확인하기 위해서 setUp() 에서 SUT(System Under Test) 객체를 생성하고, tearDown() 에서 해제하는 것이 가장 좋은 방법이기 때문이다.

import XCTest
@testable import Calculator

final class CalculatorTests: XCTestCase {
    private var sut: BianryCalculator!
    
    override func setUp() {
        sut = BianryCalculator()
        super.setUp()
    }

    override func tearDown() {
        sut = nil
        super.tearDown()
    }
}

그렇다면 setUp()tearDown() 은 무엇을 해주는 메소드 일까?
네이밍에서 유추할수 있듯이 setUp() 은 test case에 있는 각각의 test method를 실행하기 전에 모든 상태를 reset해주는 함수이다. tearDown() 은 test case에 있는 각각의 test method들이 끝나고 난 뒤에 cleanup을 수행해주는 함수이다.

Unit Test code 작성

이제는 테스트 코드를 작성해 볼 차례인데, 테스트 메소드의 이름은 항상 test 로 시작해야만하며, 뒤에 무엇을 테스트하는지 설명해준다. 또한 test 코드 안에는 given, when, then 섹션으로 테스트 형식을 지정하는 것이 좋다.

  1. given 섹션에서는 필요한 모든 값을 설정한다.
    calculator에서는 사용자가 입력할 숫자와 연산자들을 셋팅하면 될 것이다.
  2. when 섹션에서는 테스트 중인 코드를 실행한다.
  3. then 섹션에서는 예상한 결과를 확인하며, 이 경우에는 계산 결과가 일치하는지 확인하여 실패하는 경우에 메세지를 출력한다.
import XCTest
@testable import Calculator

final class CalculatorTests: XCTestCase {
    private var sut: DecimalCalculator!
    
    override func setUp() {
        sut = DecimalCalculator()
        super.setUp()
    }

    override func tearDown() {
        sut = nil
        super.tearDown()
    }
    
    func testAddition() throws {
    //1. given (formula : 13 + 5)
    try! sut.enterNumber("1")
    try! sut.enterNumber("3")
    try! sut.add()
    try! sut.enterNumber("5")
        
    //2. when
    let result = try! sut.equal()
    
    //3. then
    XCTAssertEqual(result, "18", " ⚠️ add function is wrong❗️")
    }
}

Given - When - Then 테스트의 구조는 클라이언트 친화적인 행동 주도적인 개발(BDD: Behavior Driven Development)에서 시작되었으며 은어 (low-jargon)의 명칭이다. 이를 대체할 시스템 이름은 Arrange-Act-AssertAssemble-Activate-Assert 라고 할 수 있다.

XCTAssert 로 시작하는 함수들은 모두 테스트의 정의들이며 이러한 테스트 함수들을 종류가 매우 많다. 대부분 함수 이름을 보면 어떠한 용도인지 파악이 가능할 것이다.

  • XCTAssertNil(a1, format...), XCTAssertNotNil(a1, format...), XCTAssert(a1, format...), XCTAssertTrue(a1, format...), XCTAssertFalse(a1, format...), XCTAssertEqualObjects(a1, a2, format...), XCTAssertEqual(a1, a2, format...), XCTAssertEqualsWithAccuracy(a1, a2, format...), XCTAssertThrows(expression, format...), XCTAssertThrowsSpecific(expression, specificException, format...), XCTAssertThrowsSpecificNamed(expression, specificException, specificName, format...), XCTAssertNoThrow(expression, format...), XCTAssertNoThrowSpecific(expression, specificException, format...), XCTAssertNoThrowSpecificName(expression, specificException, specificName, format...)

setUp, tearDown VS setUpWithError, tearDownWithError

Xcode 11.4 버젼부터 Unit test를 생성하면 기본적으로 setUpWithError() , tearDownWithError() 로 생성이 되게된다. 물론 setUp()과 tearDown()도 override 해서 사용할 수 있다.

XCTest의 호출 순서 : setUpWithError() → setUp() → tearDown() → tearDownWithError()

그렇다면 setUp, tearDown 과 차이점은 무엇일까?

차이점은 바로 함수명에서 나와있듯이 바로 Error를 던질 수 있게 바뀌었다는 점만 빼면 나머지는 다 동일하다고 한다. 즉, 코드 중에서 throws를 할 수 있는 코드들을 do-catch문이나, try? 를 사용하지 않고 try를 사용하여 setUpWithError()에서도 error를 throw하게 해준다는 것이다.

setUpWithError()에서 error를 던지지 않으면 정상적으로 실행되겠지만, error가 존재하는 경우에는 테스트 자체가 skip되게 된다. 따라서 테스트는 전부 실패한 것으로 나오게 되지만 실제로는 실행이 아예 되지 못한 것이다. setUpWithError가 호출되고 에러 코드가 나오고 그 이후에 tearDownWithError도 호출이 되게 된다.

Xcode 11.4 버젼에 같이 새로 나온 친구들이 있는데 바로 XCTSkip()* 이다. 이 XCTSkip() struct는 메소드로 XCTSkipIf() XCTSkipUnless()를 가지고 있는데 이 친구들은 runtime에 조건에 따라서 동적으로 테스트의 스킵을 처리하게 된다. 차례대로 알아보자면

  1. XCTSKipIf(expression, ...) macro는 expression의 값이 true이면 해당 test method를 skip

  2. XCTSkipUnless(expression, ...) macro는 expression의 값이 false 이면 해당 test method를 skip

Code Coverage

code coverage는 테스트의 가치를 측정하는 도구라고 할 수 있다. 이를 통해서 테스터가 의도한 대로 테스트가 잘 되었는지 판단할 수 있는 자료 중 하나다.

설정하는 방법 : Product → Scheme → Edit Scheme → Options 에서 Code Coverage를 체크해주고 target을 설정해주면 된다.

이후에 test를 진행하고 나서 Report navigator에서 가장 최신의 테스한 것으로 Coverage를 클릭하면 다음과 같이 test code의 coverage를 확인할 수 있다.


그럼 각 파일들에 대해서 얼마만큼의 코드들이 실행되었는지 확인할 수 있고 > 버튼을 눌러서 더 자세하게 확인할 수 있다.

이때 해당 변수나 메소드를 클릭하게 되면 파일로 이동하며 이동하면 오른쪽에 몇 번 호출이 되었는지 확인할 수 있다. 또한 호출이 한 번도 되지 않은, 즉 테스트가 되지 않은 코드들은 아래의 그림처럼 빨간색으로 표시가 된다.

참고

  1. TDD의 장점
  2. FIRST 속성
  3. Right-BICEP
  4. Xcode에서 Unit Test 진행, BDD(BehaviorDrivenDevelopment)
  5. setUp(), setUpWithError(), tearDown(), tearDownWithError()
  6. Code Coverage
profile
Hope to become an iOS Developer

0개의 댓글