[iOS/Swift] Swift의 UnitTest 살펴보기

앱을 개발하다보면 UI에 문제는 없는지, 혹은 특정 기능이 잘 작동하는지 검증을 해야 한다.

규모가 작은 앱이라면 기기에 빌드해서 전체 기능을 직접 테스트 해볼 수는 있..겠으나,

기능이나 화면이 너무 많은 경우나, 여기에 작은 기능 하나만 추가되었을 때 

A부터 Z까지 전부 다 직접 테스트하는것은 비효율적일 것이다.

 

그래서 Swfit는 XCTest(혹은 SwiftTesting)를 통해 UI/기능을 테스트할 수 있는 프레임워크를 제공한다.

 

이번 글에서는 Test에 대한 소개와 함께 Testable한 코드에 대해서 알아보도록 하겠다.

 


Test 알아보기

먼저 Test의 목적과 종류에 대해 알아보겠다.

Test는 말 그대로 "앱을 사용함에 있어 문제가 없는지를 확인"하는 과정이다.

이러한 개념은 당연히 iOS에만 있는 개념은 아니며, Test Pyramid라는 개념에서 Test의 종류를 확인해 볼 수 있다.

이 개념은 소프트웨어 테스트 전반에서 사용되는 개념으로,

피라미드 상단 -> 하단으로 내려갈수록 개발자에게 가까운 영역이고,

상단으로 올라갈수록 사용자에게 가까운 영역이라고 생각하면 된다.

 

  • Unit Test
    • 메서드 각각을 테스트 하는 것을 말한다.
    • 속도가 빠르고, 디버깅이 용이하다.
  • Intergration Test
    • 여러 메서드를 통합적으로 테스트 하는 것을 말한다.
    • 상대적으로 느리고, 설정이 복잡하다.
  • User Interface Test
    • 사용자의 시나리오대로 앱이 동작하는지 테스트 하는 것을 말한다.
    • UI Test를 포함하며, 가장 느리고 디버깅이 어렵다.

 

Swift에서 XCTest 프레임워크를 통해 테스트를 진행하면 UnitTest와 UITest 코드를 작성하게 될텐데,

각각 피라미드의 가장 하단, 가장 상단에 해당한다고 보면 된다.

 

UnitTest는 특정 메서드 등 뷰와 관련없는 기능만 검증하는 테스트이며,

UITest는 기능과는 절대적으로 무관한 UI와 UX에 대해 검증하는 테스트이다.


UnitTest 코드 살펴보기

테스트 코드는 다음과 같은 형태로 이루어져있다.

  • `setUpWithError()`: 테스트하기 전에 초기화하는 메서드
  • `tearDownWithError()`: 테스트 이후에 다른 조건의 변화가 생기지 않도록 초기화하는 메서드
  • `test~()`: 실제 테스트 메서드 -> test라는 접두어가 붙어야 함
  • `Assert`: 테스트 메서드 마지막에 성공, 실패를 판단하기 위한 메서드
  • `testPerformanceExample()`: 실제 퍼포먼스가 얼마나 걸리는지 확인하는 메서드

 

다음과 같이 덧셈 및 뺄셈 로직을 테스트하는 코드가 있다고 해보자.

var number = 3

// ...

func testPlus() throws {
    print(#function)
    
    let a = 2
    let b = 2
    
    XCTAssertEqual(a + b, number, "덧셈 결과가 같지 않음") // 무조건 실패하는 케이스
}

// ...

func testMinus() throws {
    print(#function)
    
    let a = 5
    let b = 2
    
    XCTAssertEqual(a - b, number, "뺄셈에 문제가 있음") // 무조건 성공하는 케이스
}

여기서 `testPlus()`는 무조건 실패하는 케이스이며,

`testMinus()`는 무조건 성공하는 케이스이다.

 

그런데 만약 테스트 외부에서 number라는 변수를 조작할 수 있다면, 

이 테스트의 신뢰도는 하락할 것이다.

 

다음 코드를 살펴보자.

func testPlus() throws {
    print(#function)
    
    let a = 2
    let b = 2
    number = 4 // 여기!!
    
    XCTAssertEqual(a + b, number, "덧셈 결과가 같지 않음") // 테스트 성공
}

// ...

func testMinus() throws {
    print(#function)
    
    let a = 5
    let b = 2
    
    XCTAssertEqual(a - b, number, "뺄셈에 문제가 있음") // 테스트 실패
}

`testPlus()`코드에서 number에 새로운 값을 할당하고 있다.

이로 인해 `testMinus()`메서드에 영향을 끼치게 된다.

 

이러한 상황을 방지하기 위해서 `setUpWithError()`와 `tearDownWithError()`에서 값을 초기화 해주는 과정이 필요한 것이다.

// 테스트하기 전 초기화
override func setUpWithError() throws {
    print(#function)
    number = 3
}

// 모든 작업이 끝나면 nil로 바꿔줌
// => 테스트 이후에 다른 조건들이 변경이 생기지 않도록, 테스트 후에 초기화되는 메서드
override func tearDownWithError() throws {
    print(#function)
    number = 3
}

 

테스트 코드의 실행 순서는 다음과 같다.

`setUpWithError()` -> `testPlus()` -> `tearDownWithError()` ->

`setUpWithError()` -> `testMinus()` -> `tearDownWithError()`

 

이제 각 테스트는 서로 독립적인 환경을 가지게 된다.


Testable한 코드란?

다음과 같은 화면이 있고, 이 화면의 로직을 테스트 한다고 가정해보자.

 

이중에 아이디 조건에 문제가 없는지 확인하는 메서드인 `isVaildID()`를 테스트하려면 다음과 같이 테스트 코드를 작성할 수 있을 것이다.

import XCTest
@testable import TestProject

final class LoginValidation: XCTestCase {
    
    // system under test: 이 시스템에서 테스트 하려는 대상이 어떤 파일인지를 명시
    var sut: LoginViewController!

    override func setUpWithError() throws {
        // LoginViewController 초기화 (인스턴스 생성)
        let sb = UIStoryboard(name: "Main", bundle: nil)
        let vc = sb.instantiateViewController(withIdentifier: "LoginViewController") as! LoginViewController
        sut = vc
        sut.loadViewIfNeeded() // 뷰를 띄워주는 메서드

        //sut = LoginViewController() // 런타임 이슈
        print(#function)
    }

    // 테스트의 고유성, 독립성을 보장하기 위해 nil로 초기화 (인스턴스 해제)
    override func tearDownWithError() throws {
        sut = nil
    }

    // id의 조건에 문제가 없는지 성공 케이스 테스트
    func testLoginViewController_ValidID_ReturnTrue() throws {
        print(#function)
        // Given
        let value = "feather@test.com"
        // When
        sut.idField.text = value
        // Then
        XCTAssertTrue(sut.isValidID(), "@가 없거나 6글자 미만임")
    }
    
    // @가 없으면 실패하는 실패 케이스 테스트
    func testLoginViewController_ValidID_ReturnFalse() throws {
        print(#function)
        // Given(잘못된 조건)
        let value = "feather"
        // When
        sut.idField.text = value
        // Then
        XCTAssertFalse(sut.isValidID())
    }

    func testPerformanceExample() throws {
        // This is an example of a performance test case.
        self.measure {
            // Put the code you want to measure the time of here.
        }
    }
}

 

이 테스트 코드는 다음과 같이 구성되어있다.

 

  • `@Testable import TestProject`를 통해 테스트 할 프로젝트를 import해줌
  • LoginViewController가 스토리보드이기 때문에 `setUpWithError`에서  뷰컨을 초기화 해줌
  • `testLoginViewController_ValidID_ReturnTrue()`는 id조건에 맞아서 `XCTAssertTrue`가 항상 true로 나오는 메서드
  • `testLoginViewController_ValidID_ReturnFalse()`는 id조건에 맞지 않아서 항상 `XCTAssertFalse`가 항상 true로 나오는 메서드

위에서 설명했던 순서대로라면 이 테스트코드는

뷰컨을 초기화 해주고 -> 테스트를 진행하고 -> 뷰컨을 nil로 만들고

이 작업을 반복할 것이다.

 

결론부터 말하자면 이 코드는 전혀 Testable한 코드라고 볼 수 없다.

 

UnitTest는 뷰와 관련 없이 로직만 테스트 하는 것이다.

그러나 이 코드는 매번 뷰컨을 가져와서, 뷰컨 안에 있는 뷰 객체의 값을 가지고 테스트를 하고 있다.

 

이게 바로 MVVM이나 DI/DIP가 Testable한 코드인 이유이기도 하다.

즉, 로직이 완전히 분리되어있거나, 의존성이 낮은 코드일수록 UnitTest를 하기에 더 적합하다는 의미이다.

만약 위의 뷰가 MVVM구조로 되어있었다면, sut로 뷰컨을 가져오는게 아닌 ViewModel만 가져와서 테스트할 수 있었을 것이다!

 

위 뷰컨에 있었던 메서드를 다음과 같이 분리해보자.

struct User {
    let email: String
    let password: String
    let check: String
}

struct Validator {
    
    func isValidEmail(email: String) -> Bool {
        return email.contains("@") && email.count >= 6
     }
    
    func isValidPassword(password: String) -> Bool {
        return password.count >= 6 && password.count < 10
    }
    
    func isEqualPassword(password: String, check: String) -> Bool {
        return password == check
    }
    
}

 

이 코드는 위의 뷰컨에 있던 이메일 유효성 로직 등을 별도의 구조체로 빼놓은 것이다.

뷰와 로직을 분리해놓은 형태라고 볼 수 있다.

 

이 상태에서 테스트코드는 다음과 같이 작성할 수 있을 것이다.

import XCTest
@testable import TestProject

// unittest >> 기능만 테스트! (View는 신경쓰지 않음)
final class UpgradeValidatoinTest: XCTestCase {
    
    var sut: Validator!
    
    // 성공 케이스와 실패 케이스
    let validUser = User(email: "feather@naver.com", password: "123456", check: "123456")
    let invalidUser = User(email: "feather", password: "123", check: "16")

    override func setUpWithError() throws {
        sut = Validator()
    }

    override func tearDownWithError() throws {
        sut = nil
    }

    func testValidator_ValidID_RetrunTrue() throws {
        let value = validUser.email
        let valid = sut.isValidEmail(email: value)
        XCTAssertTrue(valid)
    }
    
    func testLoginViewController_ValidID_ReturnFalse() throws {
        let value = invalidUser.email
        let valid = sut.isValidEmail(email: value)
        XCTAssertFalse(valid)
    }

    func testPerformanceExample() throws {
        // This is an example of a performance test case.
        self.measure {
            // Put the code you want to measure the time of here.
        }
    }
}

이제 뷰컨과는 전혀 상관 없이 로직만 가져와서 테스트를 할 수 있게 되었다.


여기서 성공 케이스와 실패 케이스를 User라는 모델의 인스턴스로 임의의 값을 넣어서 테스트하고있다.

다른 경우에는, 예를 들어 네트워크 통신을 할때는 실제 네트워크 통신이 아닌 목데이터를 사용해서 테스트 할 수도 있을 것이다.

 

다음 글에서는 이렇게 목데이터, 더미데이터들을 일컫는 `Test Double`에 대해 알아보도록 하겠다.