raywenderlich - iOS Unit Testing and UI Testing Tutorial
테스트는 앱이 거대하고 복잡해짐에 따라 버그를 숨기지 않고 안전하게 확장되도록 하는 중요한 장치이다. 이번 튜토리얼을 통해 다음 사항들을 테스트 해본다.
1. 앱의 Model 즉, 중심이 되는 논리나 기능.
2. Asynchronous methods (비동기적으로 진행되는 작업).
3. 라이브러리나 시스템 객체와의 상호작용을 가짜로 흉내내서 테스트하기 (Stubs and mocks).
4. UI Test.
5. Performance Test.
6. Code coverage tool (테스트를 거치는 실제 코드들을 확인하기).
어떤 것을 테스트할지 파악하기
테스트가 무엇을 위해, 그에 따라 어떤 부분에 필요한지 파악하는 것이 좋다. 보통은 다음을 위해 테스트를 작성한다.
1. 코드의 기능 (Model 의 classes and methods, controller 와의 작용)
2. UI 의 동작
3. 경계 조건
4. 디버깅
효과적인 테스트를 위해 기억할 FIRST
Fast: 테스트는 빨리 끝나야 한다.
Isolated: 테스트는 서로의 상태를 공유하면 안된다. (항상 초기 상태로 시작!)
Repeatable: 매번 같은 결과를 가져야 한다.
Self-validating: 테스트는 완전히 자동화되는 것이 좋고, 결과는 "통과" 혹은 "실패"로 간결하게.
Timely: 테스트 코드를 먼저 작성하고 개발하는 것이 좋다. (Test-driven development)
Unit Test 준비하기
앱의 Model 즉, 핵심 기능을 테스트 해보자. 게임이 점수를 제대로 계산하는지를 테스트 해본다.
XCode 의 navigatior 에서 6번째 탭인 Test navigator를 선택한 뒤 좌측 하단의 + 버튼을 통해 Testing framework 인 XCTest 의 기본 template 이 작성된 class 를 생성할 수 있다.
먼저, 해당 파일에 아래의 선언을 통해 테스트가 게임 내부의 자료형과 기능에 접근할 수 있도록 한다.
@testable import BullsEye
다음을 class 내부에 선언해 테스트를 적용할 대상, 객체를 선언한다.
var sut: BullsEyeGame!
sut 는 System Under Test 의 약자로 현재 class 에서 test 하는 대상이 된다.
setUpWithError() 함수는 각 테스트가 진행되기 전 실행되는 함수이다.
override func setUpWithError() throws {
try super.setUpWithError()
sut = BullsEyeGame()
}
BullsEyeGame 객체를 생성하여 test class 에서 해당 객체의 프로퍼티와 메소드에 접근할 수 있게 한다.
setUpWithError() 를 작성함과 동시에 tearDownWithError() 에서 객체에 대한 참조를 제거해준다.
이 함수는 각 test 가 수행된 후 호출된다.
override func tearDownWithError() throws {
sut = nil
try super.tearDownWithError()
}
현재 class 의 각 test 가 수행될 때 항상 초기의 상태로 시작하기 위해 두 함수를 동시에 작성하는 습관을 들이는게 좋다.
첫번째 Test 작성
사용자가 짐작한 값에 대해 맞는 점수를 계산하는지 확인하는 test 를 작성한다.
func testScoreIsComputedWhenGuessIsHigherThanTarget() {
// given: Set up any values needed.
let guess = sut.targetValue + 5
// when: Execute the code being tested.
sut.check(guess: guess)
// then: Assert the result you expect with a message that prints if the test fails.
XCTAssertEqual(sut.scoreRound, 95, "Score computed from guess is wrong")
}
test 함수는 test 로 시작해서 어떤 test 인지에 대한 설명으로 작명한다.
test 를 given, when, then 의 흐름으로 작성하면 좋다.
given: 어떤 값이 주어지고,
when: 어떤 동작이 실행될 때,
then: 그에 따른 결과는 이러해야 한다.
우리가 작성한 test 를 대입해보면
given: 사용자가 짐작한 값이 타겟보다 5 클 때,
when: 사용자 짐작 값으로 점수를 매긴다면,
then: 95 점이 나와야 한다.
함수명 왼편의 다이아몬드 부분을 눌러 test 를 진행할 수 있다.
비동기적 수행을 검사하기 위해 XCTestExpectation 을 사용해보자
이 게임은 매 라운드에 타겟 값을 난수로 생성하기 위해 URLSession 을 사용하는데, URLSession 은 값을 반환한 뒤에도 종료되지 않고 남아있는 비동기적인 수행이다.
따라서 XCTestExpectation 을 사용해 test 가 비동기적 수행이 끝나도록 기다리게 하자. 비동기 test 는 대부분 느려서, 빠른 unit test 들과 분리하는 것이 좋다.
새로운 test class 를 작성하자.
var sut: URLSession!
override func setUpWithError() throws {
try super.setUpWithError()
sut = URLSession(configuration: .default)
}
override func tearDownWithError() throws {
sut = nil
try super.tearDownWithError()
}
URLSession 에 대한 test 이므로 해당 타입의 sut 를 선언하고 초기화 작업 등을 한다.
다음 test 를 작성해 올바른 요청이 상태 코드 200을 반환하는지 확인한다.
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")
// 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 {
promise.fulfill()
} else {
XCTFail("Status code: \(statusCode)")
}
}
}
dataTask.resume()
wait(for: [promise], timeout: 5)
}
expectation 을 선언하고 원하는 조건이 만족되었을 때 fulfill() 을 호출해 테스트의 성공을 알린다. wait(for:, timeout:) 으로 timeout 동안 조건이 만족되지 않으면 test 에 실패하도록 한다. 즉, timeout 동안은 조건이 만족되지 않는 한 test 가 계속 진행되도록 한다.
위와 같이 비동기 test 를 작성할 수도 있지만 위의 test 는 원하는 상태 코드를 받았을 때 fulfill() 을 호출하므로 원하는 코드를 받지 않으면
꼼짝없이 wait() 의 timeout 시간동안 기다려야 한다. test 의 형태를 약간 변형해 실패하는 경우에도 즉시 종료할 수 있게 바꿀 수 있다.
func testApiCallCompletes() 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: "Completion handler invoked")
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)
}
상태 코드를 받아오는 것 만으로 fulfill() 을 호출한 뒤 해당 코드가 원하는 코드인지 XCTAssertEqual() 을 통해 검사하는 방식으로 바꾼다면 실패하더라도 timeout 을 기다리지 않는다.
조건에 따라 test 건너뛰기
만약 인터넷 연결이 안돼 있다면 URLSession 에 대한 test 는 해보나 마나니까... 인터넷 연결이 있을 때만 해당 함수를 실행하도록 하자.
아래의 network monitor 를 선언한 뒤
let networkMonitor = NetworkMonitor.shared
다음을 네트워크 연결이 필요한 테스트의 도입부에 선언하면
try XCTSkipUnless(networkMonitor.isReachable, "Network connectivity needed for this test.")
Network 가 Reachable 하지 않는 한 test 를 skip 한다.
Stubbed data or Mock object 를 통해 test 하기
XCTestExpectation 을 통해 URLSession 의 비동기적인 수행에 대한 test 를 마쳤다. URLSession 으로 가져온 데이터를 통해 UserDefaults 등의 정보가 제대로 갱신되는지도 역시 확인하고 싶을 것이다.
대부분의 앱은 시스템 객체 혹은 라이브러리 객체와 상호작용한다. 이들은 우리가 직접 통제하지 않는 객체인데 이런 객체들과의 작업을 테스트하는 것은 느릴뿐더러 매번 같은 결과를 보장하지도 않는다. (앞서 언급한 효율적 테스트를 위한 조건에 두 가지나 위배된다!)
대신에, 이런 객체들을 가짜로 만들어내서 테스트한다면 통제 가능하고 빠를 터이다.
Stubbed data 로 모의 입력 만들어내기
getRandomNumber() 함수가 session 을 통해 받은 데이터를 제대로 가져오는지 확인하자. Stubbed data 로 모의 session 을 만들어 테스트를 진행한다.
새로운 Unit test 를 생성하고 다음을 선언한다.
@testable import BullsEye
class 내부에 다음과 같이 set up 한다.
var sut: BullsEyeGame!
override func setUpWithError() throws {
try super.setUpWithError()
sut = BullsEyeGame()
}
override func tearDownWithError() throws {
sut = nil
try super.tearDownWithError()
}
이제, 모의 session 객체를 생성하고 이를 sut 에 주입하자.
func testStartNewRoundUsesRandomValueFromApiRequest() {
// given
// 1: Set up the fake data and response.
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)
// Create the fake session object.
let urlSessionStub = URLSessionStub(
data: stubbedData,
response: stubbedResponse,
error: nil)
// Inject the fake session into the app as a property of sut.
sut.urlSession = urlSessionStub
// Still need to be an asynchronous method.
// Since stub is pretending to be an asynchronous method.
let promise = expectation(description: "Value Received")
// when
sut.startNewRound {
// then
// 2
XCTAssertEqual(self.sut.targetValue, 1)
promise.fulfill()
}
wait(for: [promise], timeout: 5)
}
임의로 생성한 모의 session 을 통해 게임의 타겟 값을 가져오도록 작성한 뒤 실제로 가져온 값이 우리가 임의로 제공한 데이터와 같은지 XCTAssertEqual() 을 통해 확인한다.
이처럼 서비스 전체가 아닌 특정 행동에 대한 테스트를 진행할 때, 해당 동작을 흉내내는 데이터를 모의로 만들어내고 이를 입력으로 준 뒤 결과에 대해 Assert 한다.
여기에서의 모의 데이터를 Stubbed data 라고 표현한다.
모의 객체를 통해 update 테스트하기
이 게임에는 두 가지 게임 스타일이 있는데 게임 화면의 segment control 버튼을 통해 조절할 수 있다. 이 버튼을 조작할 때, 올바르게 게임 스타일 정보를 UserDefaults 에 저장하는지 알아보자.
이 게임은 사용자가 선택한 게임 스타일을 UserDefaults 에 저장하는데 이는 사용자가 직접 통제할 수 없는 시스템 객체이다.따라서, 이를 모방한 객체를 선언하고 이것을 활용해 정보가 제대로 저장되는지 알아보자.
테스트를 새로 생성하고 다음을 선언한다.
@testable import BullsEye
class MockUserDefaults: UserDefaults {
var gameStyleChanged = 0;
override func set(_ value: Int, forKey defaultName: String) {
if defaultName == "gameStyle" {
gameStyleChanged += 1
}
}
}
그리고, test class 내부에서 다음과 같이 set up 한다.
var sut: ViewController!
// Mock object
var mockUserDefaults: MockUserDefaults!
override func setUpWithError() throws {
try super.setUpWithError()
sut = UIStoryboard(name: "Main", bundle: nil)
.instantiateInitialViewController() as? ViewController
mockUserDefaults = MockUserDefaults(suiteName: "testing")
// Inject the mock object as a property of the SUT.
sut.defaults = mockUserDefaults
}
override func tearDownWithError() throws {
sut = nil
mockUserDefaults = nil
try super.tearDownWithError()
}
마지막으로, 다음과 같이 테스트 메서드를 선언한다.
func testGameStyleCanBeChanged() {
// given
let segmentedControl = UISegmentedControl()
// when
XCTAssertEqual(mockUserDefaults.gameStyleChanged, 0,
"gameStyleChanged should be 0 before sendActions")
segmentedControl.addTarget(
sut,
action: #selector(ViewController.chooseGameStyle(_:)),
for: .valueChanged)
segmentedControl.sendActions(for: .valueChanged)
// then
XCTAssertEqual(
mockUserDefaults.gameStyleChanged,
1,
"gameStyle user default wasn't changed")
}
segmented control 에서 ViewController.chooseGameStyle() 로 신호를 보내면 모의 객체에서 선언한대로 gameStyleChanged 가 증가되어야 한다.
이런 유사한 테스트에서 gameStyleChanged 는 Bool 변수인 경우가 많지만 Int 로 선언하면 해당 함수가 몇 번 호출되었는지 등의 정보를 더 알 수 있다는 장점이 있다.
UI testing
UI test 를 통해 화면의 UI 요소들과의 상호작용을 확인해 볼 수 있다. 쿼리를 통해 UI 객체를 찾아낸 뒤, 해당 UI 에서 일어날 event 를 만들어 UI 객체에 보내고 event 를 받은 UI 가 존재하는지 확인하는 절차를 거쳐 test 를 완료한다.
새로운 UI test 를 생성하고 다음과 같이 set up 한다.
var app: XCUIApplication!
override func setUpWithError() throws {
try super.setUpWithError()
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
app = XCUIApplication()
app.launch()
}
다음과 같이 test 메서드를 작성한 뒤
func testGameStyleSwitch() {
}
함수 내부에 커서를 두고 xcode 좌측 하단의 빨간 원을 눌러 recording 을 진행한다. 시뮬레이터 상에서 test 할 UI component 에 원하는 동작 (touch, swipe, etc)을 하면 커서 위치에 해당 동작들이 코드로 나타난다.
게임 스타일 segmented control 과 상단의 label 을 터치한 뒤 기록된 코드이다.
func testGameStyleSwitch() {
let app = XCUIApplication()
app.buttons["Slide"].tap()
app.staticTexts["Get as close as you can to: "].tap()
}
app.buttons["Slide"].tap() 은 app 의 "Slide" 버튼이 tap 되었다는 의미이다. (buttons 에 생기는 메뉴를 열어보면 segmentedControls 로 변경할 수 있다. 우리는 segmented control 을 test 해야 하므로 바꾸자.)
이제 .tap() 을 지우면 app.segmentedControls.buttons["Slide"] 가 남는데 추후에 .tap() 동작을 코드로 실행한 뒤 원하는 UI 변화가 일어나는지 확인하면 된다.
segmented control button 이 "Slide" 로 되어 있다면 상단의 label 은 "Get as close as you can to: " 가 되고 segmented control button 이 "Type" 으로 되어 있다면 상단의 label 은 "Guess where the slider is: " 가 된다.
위의 test 메서드를 다음과 같이 작성하자.
func testGameStyleSwitch() {
// given
let slideButton = app.segmentedControls.buttons["Slide"]
let typeButton = app.segmentedControls.buttons["Type"]
let slideLabel = app.staticTexts["Get as close as you can to: "]
let typeLabel = app.staticTexts["Guess where the slider is: "]
if slideButton.isSelected { // when
// then
XCTAssertTrue(slideLabel.exists)
XCTAssertFalse(typeLabel.exists)
typeButton.tap() // when
// then
XCTAssertTrue(typeLabel.exists)
XCTAssertFalse(slideLabel.exists)
}
else if typeButton.isSelected { // when
// then
XCTAssertTrue(typeLabel.exists)
XCTAssertFalse(slideLabel.exists)
slideButton.tap() // when
// then
XCTAssertTrue(slideLabel.exists)
XCTAssertFalse(typeLabel.exists)
}
}
button 과 label 이 위와 같이 주어질 때,
button 이 slide 로 선택되어 있다면 slide label 이 존재해야 하고 여기서 type 을 tap() 한다면 type label 이 존재해야 한다.
button 이 type 으로 선택되어 있다면 type label 이 존재해야 하고 여기서 slide 를 tap() 한다면 slide label 이 존재해야 한다.
Testing performance
애플의 공식 문서에 따르면,
performance test 는 평가할 코드 조각을 10번 실행하고 평균 실행 시간과 표준 편차를 구하는 것.
이를 통해 실행 능력에 대한 하한을 구해 이후의 테스트와 이를 대조해보고 성공과 실패를 가를 수 있다.
라고 되어 있다.
performance test 는 몇 줄의 코드로 충분하다.
func testScoreIsComputedPerformance() {
measure(
metrics: [
XCTClockMetric(),
XCTCPUMetric(),
XCTStorageMetric(),
XCTMemoryMetric()
]
) {
sut.check(guess: 100)
}
}
XCTClockMetric : 경과 시간을 측정한다.
XCTCPUMetric : CPU time, cycle, 명령어 수를 포함한 CPU 활동을 추적한다.
XCTStorageMetric : 테스트 코드가 저장 공간을 얼마나 사용하는지 알려준다.
XCTMemoryMetric : 실제 사용된 physical memory 를 알려준다.
test 를 실행한 뒤 measure 함수명 왼편의 아이콘을 누르면 통계에 대한 수치를 확인할 수 있다. Set Baseline 을 누르면 test 의 performance 에 대한 기준이 정해지고 이후에 있을 performance 에 대해서 비교해 볼 수 있다.
실행 장비에 따라 Baseline 이 각각 기록되므로 여러 장치에 대해서 실행해 보는 것이 유용하다.
Code Coverage
xcode 에서 제공하는 code coverage tool 을 통해 test 에서 실행한 코드가 실제로 어떤 부분인지 시각적, 수치적으로 보여준다. 즉, test 를 받지 않은 부분은 어디인지 직관적으로 알 수 있는 것 이다.
xcode 의 toolbar > product > scheme > edit scheme 의 Test 탭에서 code coverage 를 선택할 수 있다.
Command-U 를 통해 모든 test 를 실행하고 navigator 의 9번째 탭에서 Coverage 를 선택해 진행 중인 test 의 coverage 현황을 알 수 있다.
많은 경우 현실적으로 100% 의 coverage 는 아주 어렵다. 이에 대해 마지막 10%-15% 의 coverage 는 의미 없다는 부류도 있고 어렵기 때문에 중요한 것이라는 의견도 있다.
'iOS 개발 > App 개발 관련' 카테고리의 다른 글
[iOS] TDD(Test Driven Development) Tutorial (0) | 2021.07.16 |
---|---|
[iOS] WWDC 2019 Testing in Xcode (0) | 2021.07.12 |
[iOS] MVC 구조의 앱을 MVVM 으로 바꾸기 (0) | 2021.06.25 |
[iOS] 최고의 디자인 패턴, MVC 에 대한 오해 (0) | 2021.06.24 |
[iOS] 어떤 클로저에 [weak self] 해야 할까? (0) | 2021.04.12 |