Unit Test
항상 공부하고싶은 주제였던 UnitTest를 이제 시작합니다!
참고자료는 게시글 맨 하단에 위치하여있고, 학습 파일은 사이트에서 쉽게 다운로드할 수 있습니다.
자료의 전반적인 정리와 강의에서 다루지 않는 상세한 내용들을 정리해볼게요.
Unit Test는 프로그램의 기본 단위인 모듈을 테스트합니다.
소스코드에서 "특정 모듈, 클래스가 개발자가 의도한 대로 정확하게 작동하는지 테스트하는 과정"입니다.
Unit Test구조가 잡혀있으면 추후에 리팩토링 후 확인이 쉬워 변경이 쉽다고 하는데, Unit Test 구조를 잡기위해 TDD(테스트주도개발) 방법이 존재한다고 하는데, 나중에 다뤄보겠습니다.
테스트 대상 파악하기
어떤 테스트를 작성하든지, 무엇을 테스트 해야하는지 알아야 하겠죠
일반적으로 테스트는 아래를 포함해야 합니다.
- 핵심기능: Model Class와 Methods 및 컨트롤러와의 상호작용
- 가장 일반적인 Ui workflows
- Boundary conditions(경계조건)
- bugfixes(버그수정)
테스트 모범사례 이해
"FIRST"라는 약어는 효과적인 유닛테스트를 위한 기준을 설명합니다.
- Fast: 테스트는 빠르게 실행되어야 합니다.
- Independent(독립적) / Isolated(분리됨) : 테스트는 서로 상태를 공유해서는 안되며 독립적이어야한다.
- Repeatable(반복할 수 있는): 테스트를 실행할 때 마다 동일한 결과를 얻어야합니다. 외부 데이터 제공자 또는 동시성 문제로 인해 간헐적 오류가 발생할 수 있습니다.
- Self-Validating(자체검증): 테스트는 완전히 자동화되어야 합니다. 출력은 프로그래머가 로그파일에 의존하는게 아닌 "통과", "실패"중 하나여야 한다.
- Timely: 이상적으로 테스트의 대상이 될 코드를 먼저 작성하는것이 아닌 테스트를 먼저 작성해야합니다. (TDD인가요?)
FIRST 원칙을 따르면 테스트를 명확하고 유용하게 유지할 수 있습니다 !
Unit Testing in Xcode
테스트 네비게이터는 테스트 작업을 쉽게하는 방법들을 제공합니다. 이것을 사용하여 테스트 대상을 생성하고 앱에대한 테스트를 실행하게 됩니다.
New Unit Test Target을 통해 Test를 생성하면 Default로 생성되는 setUpWithError(), testDownWithError() 및 예제 테스트 메서드들을 포함하고 있습니다.
Unit Test 클래스
위의 사진을 보면 XCTest를 import하고, XCTestCase를 상속받아 BullsEyeTests가 정의되었습니다. 이는 Test를 생성하면 만들어지는 클래스인데요. 하나씩 알아가봅시다.
일단, Xcode 11.4버전 부터 Unit test를 생성하면 기본적으로 setUpWithError(), tearDownWithError()로 생성이됩니다. 이 전엔 setUp(), tearDown()이라고 하구요.
setUp(), tearDown()또한 override하여 사용할 수 있다고 합니다. 둘의 차이는 이름에 나와있듯 바로 Error를 던질 수 있냐/없냐의 차이라는데요, 코드중에서 throws를 할 수 있는 코드들을 do-catch문, try?를 사용하지 않고 try를 사용하여 WithError()에서 error를 throw하게 해준다고 합니다.
XCTest의 호출 순서: setUpWithError() -> setUp() -> tearDown() -> tearDownWithError()
입니다. 그럼 각 메서드가 무슨 역할을 하는지 봅시다.
func setUpWithError()
- 각 테스트 메서드가 실행 되기 전에 호출됩니다. 테스트 준비를 위한 코드를 여기에 작성합니다. 예를 들면 테스트에 필요한 객체를 생성하거나 코드를 추가할 수 있습니다.
var systemUnderTest: MyClass!
override func setUpWithError() throws {
super.setUpWithError()
systemUnderTest = MyClass()
}
func tearDownWithError()
- 각 테스트 메서드가 실행된 후 호출됩니다. 테스트 후 처리를 위한 코드를 여기에 작성합니다. 예를 들면, 테스트에 사용된 객체를 해제하거나 추가적인 정리 작업을 수행합니다.
override func tearDownWithError() throws {
systemUnderTest = nil
super.tearDownWithError()
}
func testExample()
- 실제 Unit Test를 실행하는 메서드입니다. 이름이 test로 시작하는 모든 메서드는 테스트 메서드로 간주되어 자동으로 실행됩니다.
func testAddition() throws {
let result = systemUnderTest.add(1, 2)
XCTAssertEqual(result, 3, "1 + 2 should equal 3")
}
XCTAssertEqual를 잠깐 짚고 넘어가겠습니다.
XCTAssertEqual(
_ expression1: @autoclosure () throws -> T, // 테스트 대상의 실제 결과값
_ expression2: @autoclosure () throws -> T, // 예상되는 결과값
_ message: @autoclosure () -> String = default, // 두 값이 같지 않을경우 출력되는 오류메시지
file: StaticString = #file,
line: UInt = #line
)
let word = "hello"
XCTAssertEqual(word, "hello") // 성공: 실제 문자열 "hello"와 예상 문자열 "hello"가 같다.
XCTAssertEqual(word, "HELLO", "The words should be the same.") // 실패: 대소문자가 다르다. 오류 메시지로 "The words should be the same." 출력.
func testPerofrmanceExample()
- 성능 테스트를 위한 메서드입니다. 'measure'블록 내부의 코드 실행 시간을 측정하여 성능 측면에서 변경사항을 감지합니다.
func testPerformanceOfMyFunction() throws {
measure {
_ = systemUnderTest.myFunction()
}
}
모든 test를 위해 setup과 down이 이루어집니다. 각각의 test 전에 setup, down이 호출되게됩니다.
그렇죠?
이는 모든 테스트가 깨끗한 상태에서 실행되도록 합니다.
테스트 실행 방법
1. Product > Test or Command-U ==> 모든 test class를 실행합니다
2. 테스트 네비게이터에서 화살표 버튼을 클릭합니다.
3. 코드 옆 다이아몬드 버튼을 실행합니다.
Using XCTAssert to Test Models
먼저, 참고자료의 사이트에서 제공해준 BuulsEye의 모델의 핵심 기능을 SCTAssert함수로 테스트 할 것입니다.
BullsEyeTests.swift에서 다음을 추가해주었습니다.
@testable import BuulsEye
@testable를 사용하면 테스트 대상 모듈 내부의 internal 접근수준을 가진 심볼에 접근할 수 있게 해줍니다. (UITest에선 적용되지 않습니다)
final class BullsEyeTests: XCTestCase {
var sut: BullsEyeGame!
override func setUpWithError() throws {
try super.setUpWithError()
sut = BullsEyeGame()
}
override func tearDownWithError() throws {
sut = nil
try super.tearDownWithError()
}
위에 설명한대로 각 테스트를 깨끗한 상태로 실행하기 위해 setup에서 테스트를 위한 세팅을 해주었고, tearDown에서 해제를 해주었습니다. 이는 각 테스트마다 세팅, 해제가 반복될것이에요.
Writing Your First Test
첫 번째 테스트 코드 작성
func testScoreIsComputenWhenGuessIsHigherThanTarget() {
// given
let guess = sut.targetValue + 5
// when
sut.check(guess: guess)
// then
XCTAssertEqual(sut.scoreRound, 95, "Score computed from guess is wrong")
}
1. given: 필요한 값, 조건을 설정합니다. 이 예제에서는 guess값을 생성하여 targetValue와의 차이를 지정하고 있습니다.
2. when: 여기에서는 테스트 받을 코드를 작성합니다. check(guess:)를 호출하는데, 이 함수의 내부 로직은 참고자료를 다운받아보시기 바랍니다.
3. then: 여기에선 테스트가 실패한 경우 메시지와 함께 예상한 결과를 나타내는 섹션입니다.
XCTAssert에 대한 애플 공식문서는 아래이다. 필요하거나 궁금한것을 찾아보세요.
https://developer.apple.com/documentation/xctest#2870839
Debugging a Test
BullsEyeGame에 일부러 버그르 내장해뒀다고 하는데, 찾는 연습을 해볼것입니다.
func testScoreIsComputedWhenGuessIsLowerThanTarget() {
// given
let guess = sut.targetValue - 5
// when
sut.check(guess: guess)
// then
XCTAssertEqual(sut.scoreRound, 95, "점수가 잘못 계산되었습니다")
}
BreakPointNavigator에서 Test Failure Breakpoint를 설정해봅시다.
그럼 Test Fail시 Breakpoint가 걸리면서, 해당 값에 대한 자세한 내용을 볼 수 있습니다.
더 자세한 내용을 보려면 해당 테스트가 작동하는 메서드의 끝부분에 브레이크 포인트를 걸고 값을 하나하나 확인할 수 있습니다.
그럼 실제로는 105인데, 95로 예측을 했네요
비동기 작업 테스트를 위해 XCTestExpectation 사용하기.
실제 네트워킹 테스트를 작성하기 전에 기존의 API가 지원되지 않아 해당 API를 사용해보세요.
"https://csrng.net/csrng/csrng.php?min=0&max=100"
struct Res: Decodable {
let status: String
let min: Int
let max: Int
let random: Int
}
본격적으로 비동기 코드 테스트를 시작해봅시다.
BullsEyeGame은 다음 게임의 목표로 난수를 얻기위해 URLSession을 사용합니다.
URLSession 메서드는 비동기적입니다. 즉, 즉시 반환되지만 나중에 실행이 완료됩니다.
비동기 메서드를 테스트하려면 XCTestExpectation을 사용하여 테스트가 비동기 작업이 완료될 때까지 리턴되지 않도록 해야합니다.
예제를 진행해 보겠습니다.
final class BullsEyeTests: XCTestCase {
var sut: URLSession!
override func setUpWithError() throws {
try super.setUpWithError()
sut = URLSession(configuration: .default)
}
override func tearDownWithError() throws {
sut = nil
try super.tearDownWithError()
}
}
이 전과 비슷하게 설정해줍니다. 이정도는 위의 테스트와 같기 때문에 설명하지 않겠습니다.
테스트 코드를 작성해줍니다.
func testValidApiCallGetsHTTPStatusCode200() throws {
// given
let urlString = "http://www.randomnumberapi.com/api/v1.0/random?min=0&max=100&count=1"
let url = URL(string: urlString)!
// 1
let promise = expectation(description: "Status code: 200")
// when
let dataTask = sut.dataTask(with: url) { _, response, error in
// then
if let error = error {
XCTFail("Error: \(error.localizedDescription)")
return
} else if let statusCode = (response as? HTTPURLResponse)?.statusCode {
if statusCode == 200 {
// 2
promise.fulfill()
} else {
XCTFail("Status code: \(statusCode)")
}
}
}
dataTask.resume()
// 3
wait(for: [promise], timeout: 5)
}
하나씩 알아가봅시다. 이 테스트는 유효한 요청을 보낼 때 statusCode로 200을 반환하는지 확인하는 테스트입니다.
1. expectation(description): 'XCTestExpectation'을 반환하며, 'promise'에 저장됩니다.
2. promise.fulfill(): 비동기 작업의 completionHandler의 성공 조건에서 promise.fulfill()을 호출하여 XCTestExpectation(promise)에게 비동기 작업이 성공적으로 완료되었음을 알립니다.
3. wait메서드는 주어진 배열의 요소들(XCTestExpectation객체들)이 모두 만족될 때 까지 테스트의 리턴을 timeout(단위: 초)동안 중지합니다.
XCTestExpectation
XCTestExpectation은 비동기 테스팅을 위해 사용됩니다. 조금 더 알아볼까요?
expectation(description)은 "XCTestExpectation"을 반환한다고 했죠? description에는 테스트 실패시 출력되는 메세지에 사용됩니다. 어떤 기대값을 설정했는지 알 수 있게 도움을 주죠
즉 XCTestExpectation은 비동기 작업이 예상대로 완료될것이라는 "기대"를 표현합니다. 테스트는 이러한 기대치가 만족될 때 까지 대기하며, 주어진 시간 내에 만족되지 않으면 실패합니다.
XCTFail
XCTFail을 호출하면 XCTest프레임워크에서 제공하는 함수입니다.
현재 실행중인 테스트 메서드를 즉시 실패로 표시하는데 사용됩니다.
파라미터로 문자열을 제공할 수 있는데 이 문자열은 실패 원인을 설명하기 위해 사용되며 테스트 결과에서 확인할 수 있습니다.
위의 코드를 변형해볼까요?
func testValidApiCallGetsHTTPStatusCode200() throws {
// given
let urlString =
"http://www.randomnumberapi.com/api/v1.0/random?min=0&max=100&count=1"
let url = URL(string: urlString)!
let promise = expectation(description: "Status code: 200")
var statusCode: Int?
var responseError: Error?
// when
let dataTask = sut.dataTask(with: url) { _, response, error in
statusCode = (response as? HTTPURLResponse)?.statusCode
responseError = error
promise.fulfill()
}
dataTask.resume()
wait(for: [promise], timeout: 5)
// then
XCTAssertNil(responseError)
XCTAssertEqual(statusCode, 200)
}
위의 코드와 차이점을 비교해보면 좋을 것 같습니다.
일단, 핸들러는 실행됩니다. 그리고 statusCode와 responseError가 각 변수에 저장이 됩니다.
그리고, completionHandler가 실행되면 promiss.fulfill()됩니다. 그럼 wait이 종료되겠죠.
이 후 XCTAssertNil과 XCTAssertEqual이 실행되는데, XCTAssertNil을 볼까요
XCTAssertNil은 함수를 사용해 특정 값을 체크하고, 해당 값이 nil이 아닌 경우 테스트는 실패로 표시됩니다.
그럼 위의 코드에서 responseError가 nil이 아니라면 XCTAssertNil이 테스트를 실패로 표시하겠네요.
조건부 실패하기.
일부 상황에서는 테스트를 실행하는 것이 큰 의미가 없을 수 있어요.
예를 들어 testValidApiCallGetsHTTPStatusCode200()가 네트워크 연결 없이 실행될 때 어떻게 될까요
당연히 통과해서는 안되며 200 상태코드도 받지 못할거에요.
그러나 테스트하지 않았기 때문에 실패해서도 안됩니다.
Apple은 사전조건이 실패할 때 테스트를 건너뛸 수 있는 "XCTSkip"을 제공합니다.
이렇게 사용할 수 있어요
final class BullsEyeTests: XCTestCase {
var sut: URLSession!
let networkMonitor = NetworkMonitor.shared
...
func testValidApiCallGetsHTTPStatusCode200() throws {
try XCTSkipUnless(
networkMonitor.isReachable,
"Network connectivity needed for this test"
)
XCTSkipUnless는 네트워크에 연결되지 않았을 때 테스트를 건너뜁니다.
XCTSkipUnless를 알아볼까요
XCTSkipUnlesss는 주어진 조건이 false인 경우 현재 실행중인 테스트를 스킵하는데 사용됩니다.
실패도, 성공도 아닌 스킵을 제공하며, false일경우 테스트 스킵의 설명을 제공할 수 있습니다.
예를 들어 특정 기능이 iOS14이상에서 동작한다면 해당 테스트코드를 iOS 14 이하 버전에서는 실행되지 않도록 만들 수 있습니다.
이렇게도 사용할 수 있겠네요.
try XCTSkipUnless(
ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 14,
"Test requires iOS 14 or higher"
)
가짜 객체와 상호작용
비동기 테스트는 코드가 비동기 API에 올바른 입력을 하는지 확인하는데 도움이 됩니다.
또한 URLSession에서 응답을 제대로 처리하는지, 혹은 'UserDefault'또는 'ICloud' 컨테이너가 올바르게 업데이트 되는지 확인하고 싶을거에요.
URLSession
많은 앱들이 시스템이나 라이브러리 객체와 상호작용합니다. 이들은 우리가 제어할 수 없어요.
이러한 객체와 상호작용하는 테스트는 느리고 일관되지 않을 수 있어, 이는 FIRST원칙을 위반하게 됩니다.
이러한 작용을 스텁(Stub)에서의 입력이나 가짜 객체(Mock)의 업데이트로 대체할 수 있습니다.
만약 코드가 시스템, 라이브러리에 의존성을 가지고 있을 때, 이 의존성을 가짜로 대체할 수 있습니다. 이러한 가짜 객체를 만들어 코드에 주입하는 것입니다.
BullsEye에는 URLSessionStub라는 파일이 포함되어있네요. 내용은 간단하니 확인해보시면 쉽게 이해하실겁니다.
func testStartNewRoundUsesRandomValueFromApiRequest() throws {
// given
// 1
let stubbedData = "[1]".data(using: .utf8)
let urlString = "http://www.randomnumberapi.com/api/v1.0/random?min=0&max=100&count=1"
let url = URL(string: urlString)!
let stubbedResponse = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: nil,
headerFields: nil)
let urlSessionStub = URLSessionStub(
data: stubbedData,
response: stubbedResponse,
error: nil)
sut.urlSession = urlSessionStub
let promise = expectation(description: "Valu Received")
// when
sut.startNewRound {
// then
// 2
XCTAssertEqual(self.sut.targetValue, 1)
promise.fulfill()
}
wait(for: [promise], timeout: 5)
}
이 테스트의 목적은 startNewRound 메서드를 테스트하기 위함입니다.
startNewRound() 메서드 내부에서는 getRandomNumber() 를 사용하고, 그 내부에선 urlSession을 활용합니다.
만약 getRandomNumber()메서드의 테스트를 작성했다고 하는 경우 또다시 startNewRound()메서드를 테스트하기위해 getRandomNumber()를 또 활용해야할까요.
불필요한 중복 네트워킹을 막기 위해 가짜 urlSession을 활용하여 네트워킹하는 "척"을 해서 startNewRound()만을 테스트할 수 있을 거에요.
참고자료
https://www.kodeco.com/21020457-ios-unit-testing-and-ui-testing-tutorial