- 테스트는 빠르게 실행되어야 합니다.
- 테스트들은 서로의 상태를 공유하지 않아야 합니다.
- 테스트를 실행할 때마다 동일한 결과를 얻어야 합니다.
- 테스트는 완전히 자동화되어야 하며, 스스로 결과물이 옳은지 그른지 판단할 수 있어야 합니다.
- 이상적으로, 테스트는 프로덕션 코드를 작성하기 전에 작성해야 합니다. 이는 Test-driven development (TDD)로 알려져 있습니다.
XCTestCase
를 상속받는 클래스를 만들고,test
로 시작하는 메서드를 정의하여 각각의 테스트 케이스를 작성합니다.assert
함수를 사용하여 예상되는 결과와 실제 결과를 비교합니다. +
버튼을 눌러 New Unit Test Target을 생성합니다.FindNumberTests
이며, 테스팅 프레임워크인 XCTest를 가져오고, XCTestCase의 서브클래스인 FindNumberTests
를 정의하며 setupWithError()
, tearDownWithError()
및 기타 테스트 메서드들을 포함합니다. setUpWithError()
- 해당 메서드는 각 테스트 메서드가 호출되기 전에 실행됩니다.
- 테스트 환경을 설정하는 코드를 여기에 작성합니다.
- 예를 들어, 테스트에 필요한 객체를 초기화하거나 테스트 데이터를 준비하는 작업을 수행합니다.
tearDownWithError()
- 해당 메서드는 각 테스트 메서드의 호출이 끝난 후에 실행됩니다.
- 테스트에서 사용된 리소스를 정리하거나 해제하는 코드를 여기에 작성합니다.
- 예를 들어, 객체를 nil로 설정하거나 파일 시스템에서 테스트 데이터를 삭제하는 작업을 수행합니다.
testExample()
- 해당 메서드는 실제 유닛 테스트 케이스를 구현하는 곳입니다.
XCTAssert
및 관련 함수를 사용하여 테스트 결과가 올바른지 검증합니다.
testPerformanceExample()
- 해당 메서드는 성능 테스트 케이스를 구현하는 곳입니다.
measure
클로저 내에 성능을 측정하고자 하는 코드를 기입합니다.- 해당 메서드는 해당 코드 블록의 실행 시간을 측정하여 성능 분석을 할 수 있게 합니다.
import XCTest
아래에 다음 줄을 추가해야 합니다.@testable import FindNumber
FindNumberTests
함수 최상단에 아래의 변수를 추가해야 합니다.var sut: FindNumberModel!
FindNumberModel
은 테스트 대상(System Under Test, SUT)이며, 이 테스트 케이스 클래스가 테스트하는 객체입니다. setupWithError()
메서드의 내용을 다음으로 대체해야 합니다.try super.setupWithError()
sut = FindNumberModel()
tearDownWithError()
에서 sut
객체를 해제해야합니다. sut = nil
try super.tearDownWithError()
FindNumberModel
클래스에 맞는 테스트를 작성해야 하므로 example 메서드들은 삭제해야 합니다. func test_스테이지_초기화() {
// given
sut.round = 3
sut.currentRecord = 2
sut.bestRecord = 5
// when
sut.resetStage()
// then
XCTAssertEqual(
sut.round,
1,
"Round should be reset to 1"
)
XCTAssertEqual(
sut.currentRecord,
0,
"Current record should be reset to 0"
)
XCTAssertEqual(
sut.bestRecord,
0,
"Best record should be reset to 0"
)
}
test_스테이지_초기화()
함수는 FindNumberModel
의 resetStage
메서드가 제대로 작동하는지 테스트하는 Unit Test입니다. 해당 테스트는 Given-When-Then
형식으로 구성됩니다. Given-When-Then
형식으로 설계하는 것이 좋은 관행입니다. sut
(System Under Test, 테스트 대상 시스템)의 round
, currentRecord
, bestRecord
값을 임의의 값으로 설정 합니다. sut.resetStage()
를 호출합니다.round
, currentRecord
,bestRecord
값을 초기화하는 것으로 기대됩니다. XCTAssertEqual
을 사용하여 sut
의 round
,currentRecord
,bestRecord
값이 각각 1,0,0 으로 재설정되었는지 확인합니다. test
로 시작되고, 테스트하는 내용을 설명합니다. sut
의 타겟 값을 1로 설정하고, 예상되는 값(expectedValue
)도 1로 설정합니다.sut.checkNum(value: expectedValue)
를 호출하여, 사용자의 선택 값이 타겟 값과 동일한 경우를 시뮬레이션합니다.sut
의 currentRecord
, bestRecord
, round
가 각각 1, 1, 2로 증가했는지 확인합니다. 이는 사용자의 선택이 정확했을 때의 기대 결과입니다. func test_사용자_선택번호와_타겟이_같을_경우() {
// given
sut.target = 1
let expectedValue = sut.target
// when
sut.checkNum(value: expectedValue)
// then
XCTAssertEqual(sut.currentRecord, 1, "Current record should be incremented")
XCTAssertEqual(sut.bestRecord, 1, "Best record should be incremented")
XCTAssertEqual(sut.round, 2, "Rount should be incremented")
}
sut
의 타겟 값을 2로 설정하고, 사용자의 선택 값(expectedValue
)을 1로 설정합니다.sut.checkNum(value: expectedValue)
를 호출하여, 사용자의 선택 값이 타겟 값과 다른 경우를 시뮬레이션합니다.sut
의 currentRecord
가 0으로 초기화되고, round
가 1로 초기화된 것을 확인합니다. 이는 사용자의 선택이 틀렸을 때의 기대 결과입니다. func test_사용자_선택번호와_타겟이_다를_경우() {
// given
sut.target = 2
let expectedValue = 1
// when
sut.checkNum(value: expectedValue)
// then
XCTAssertEqual(sut.currentRecord, 0, "Current record should be reset to 0")
XCTAssertEqual(sut.round, 1, "Round should be reset to 1")
}
FindNumberTest
코드를 자세히 보면 UserDefaults
를 사용하여 round
, currentRecord
, bestRecord
값을 저장하고 불러오고 있습니다. 이는 해당 UserDefaults
를 그대로 활용하게 되면 테스트 간 상태 공유 문제, 테스트의 결과값이 동일하지 않은 문제가 발생할 수 있습니다. UserDefaults
에 저장된 값이 첫 번째 테스트 실행 후 변경되기 때문입니다. UserDefaults
에 값을 불러오는 상태로는 실패가 될 가능성이 있습니다. FindNumberModel
에 UserDefaults
인스턴스를 외부에서 주입할 수 있게 코드를 리팩토링 합니다. 이를 통해 실제 앱에서는 기본 UserDefaults
를, 테스트에서는 MockUserDefaluts
를 사용할 수 있습니다. import Foundation
final class FindNumberModel {
var urlSession: URLSessionProtocol = URLSession.shared
var defaults: UserDefaults = UserDefaults.standard
var round: Int {
get {
let initialRound = defaults.integer(forKey: "Round")
return initialRound == 0 ? 1 : initialRound
}
set {
defaults.set(newValue, forKey: "Round")
}
}
var bestRecord: Int {
get {
defaults.integer(forKey: "bestRecord")
}
set {
defaults.set(newValue, forKey: "bestRecord")
}
}
var currentRecord: Int {
get {
defaults.integer(forKey: "CurrentRecord")
}
set {
defaults.set(newValue, forKey: "CurrentRecord")
}
}
var onUpdate: (() -> Void)?
var onError: ((String) -> Void)?
var target: Int = 1
init() {
gameStart()
}
URLSessionProtocol
은 실제 사용할 URLSession
과 테스트 코드에서 실행할 URLSessionStub
을 위한 protocol입니다. URLSessionProtocol
은 URLSession
의 기능을 모방하도록 설계된 protocol입니다. 해당 protocol을 사용함으로써, 실제 네트워크 통신을 수행하는 URLSession
과 테스트를 위한 가짜 네트워크 통신을 처리하는 URLSessionStub
사이의 교체가 용이해집니다. URLSessionStub
은 네트워크 요청에 대한 응답을 모방하여 테스트 중에 실제 네트워크 호출 없이도 네트워크 통신의 결과를 테스트할 수 있게 해줍니다. var urlSession: URLSessionProtocol!
var defaults: UserDefaults!
init(
urlSession: URLSessionProtocol = URLSession.shared,
defaults: UserDefaults = UserDefaults.standard
) {
self.urlSession = urlSession
self.defaults = defaults
gameStart()
}
Mock 객체 사용
FindNumberModel
의 UserDefaults
의존성을 제거하고, 테스트에서 MockUserDefaults
객체를 사용하도록 합니다. 이렇게 하면 테스트 각각이 격리된 환경에서 실행되고, 실제 앱의 UserDefaults
와 독립적으로 작동합니다. MockUserDefaults
클래스를 생성하며 해당 클래스는 FindNumberTests.swift
파일에 생성합니다. final class MockUserDefaults: UserDefaults {
private var storage = [String: Any]()
override func integer(forKey defaultName: String) -> Int {
return storage[defaultName] as? Int ?? 0
}
override func set(_ value: Int, forKey defaultName: String) {
storage[defaultName] = value
}
}
MockUserDefaults
와 기존에 만들어둔 URLSessionStub
을 setupWithError()
메서드에서 sut
이 초기화 되는 시점에 주입 시켜줍니다. override func setUpWithError() throws {
try super.setUpWithError()
let urlSessionStub = URLSessionStub()
let mockUserDefaults = MockUserDefaults(suiteName: "TestDefaults")
sut = FindNumberModel(
urlSession: urlSessionStub,
defaults: mockUserDefaults!
)
}
FindNumberModel
의 resetStage
메서드 및 다른 메서드들을 실제 UserDefaluts
와 URLSession
의 영향 없이 테스트할 수 있으며, 테스트의 격리성과 신뢰성을 높일 수 있습니다. FindNumberModel
의 getNumber
메서드가 API 호출을 통해 올바른 타겟 번호를 받아오는지 테스트 합니다. 이 테스트는 주로 네트워크 요청을 모방하고, 응답을 검증하는 과정을 포함합니다. FindNumberModel
이 응답을 올바르게 처리하는지 확인하는 것 입니다. URLSessionStub
을 사용함으로써, 네트워크 상태나 외부 API 서버의 상태에 영향을 받지 않고 일관된 테스트 환경을 유지할 수 있습니다. func test_타겟번호_받기() {
// given
let stubbedData = "[1]".data(using: .utf8)
let urlString = "http://www.randomnumberapi.com/api/v1.0/random?min=1&max=3&count=1"
let url = URL(string: urlString)!
let stubbedResponse = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)
let urlSessionStub = URLSessionStub(
stubbedData: stubbedData,
stubbedResponse: stubbedResponse,
stubbedError: nil
)
sut.urlSession = urlSessionStub
// when
var receivedNumber: Int?
sut.getNumber { number in
receivedNumber = number
}
// then
XCTAssertEqual(receivedNumber, 1)
}
stubbedData
: 예상되는 API 응답 데이터를 모방합니다. 여기서는 "[1]"
문자열을 데이터 형태로 변환합니다.urlString
, url
, stubbedResponse
: 테스트할 API의 URL과 해당 요청에 대한 가짜 HTTP 응답을 설정합니다.urlSessionStub
: 실제 네트워크 통신 대신 사용될 URLSessionStub
인스턴스를 생성합니다. 이 객체는 네트워크 요청을 모방하며, stubbedData
와 stubbedResponse
를 사용하여 예상되는 응답을 반환합니다.sut.getNumber
: FindNumberModel
의 getNumber
메서드를 호출합니다. 이 메서드는 내부적으로 urlSessionStub
을 사용하여 네트워크 요청을 모방합니다.receivedNumber
: getNumber
메서드의 완료 핸들러에서 반환된 타겟 번호를 저장합니다. 이 값은 비동기적으로 설정되므로, 테스트가 이 값을 올바르게 수신하였는지 확인하는 것이 중요합니다.XCTAssertEqual
: receivedNumber
가 예상되는 값인 1과 동일한지 확인합니다. 이는 getNumber
메서드가 API 응답을 올바르게 처리하고, 정확한 타겟 번호를 반환하는지를 검증합니다.FindNumberSlowTests
라는 target을 새롭게 생성합니다. 이렇게 만들어진 클래스는 실제 네트워크 연결을 사용하여 API 호출을 테스트하는 슬로우 테스트(slow test) 케이스 입니다.import XCTest
@testable import FindNumber
final class FindNumberSlowTests: XCTestCase {
var sut: URLSession!
let networkMonitor = NetworkMonitor.shared
override func setUpWithError() throws {
try super.setUpWithError()
sut = URLSession(configuration: .default)
}
override func tearDownWithError() throws {
sut = nil
try super.tearDownWithError()
}
func test_유효한API_호출후_HTTPStatusCode200_받기() throws {
try XCTSkipUnless(networkMonitor.isReachable, "Network connectivity needed for this test.")
// given
let urlString = "https://www.randomnumberapi.com/api/v1.0/random?min=0&max=3&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: 3)
// then
XCTAssertNil(responseError)
XCTAssertEqual(statusCode, 200)
}
}
URLSession
을 사용하여 API에 HTTP GET 요청을 보내고, 결과를 비동기적으로 받습니다. 이 때 XCTSkipUnless
를 사용하여 네트워크 연결이 없는 경우 테스트를 건너뛸 수 있습니다.responseError
가 nil
인지), 그리고 HTTP 상태 코드가 200인지 검증합니다. 테스트는 네트워크 상태에 의존하므로 완료까지 몇 초가 걸릴 수 있습니다.FindNumber
프로젝트의 테스트 네비게이터에서, UI Test Target
을 추가합니다. FindNumberUITest
class 내부 최상단에 기입 해줍니다. var app: XCUIApplication!
tearDownWithError()
를 제거하주고, setUpWithError()
메서드의 내용을 아래와 같이 변경해줍니다.try super.setupWithError()
continueAfterFailure = false
app = XCUIApplication()
app.launch()
testStageAscent()
메서드를 추가해줍니다. func testStageAscent() {
// given
let button = app.buttons["1번"]
let button2 = app.buttons["2번"]
let reset = app.buttons["다시 시작"]
reset.tap()
button.tap()
button2.tap()
button.tap()
button2.tap()
button.tap()
button2.tap()
button.tap()
button2.tap()
// then
let stageLabel = app.staticTexts["최대: 0번 연속 성공!"]
XCTAssertFalse(stageLabel.exists, "Stage label should be updated to the correct stage number")
}
제가 학습한 내용을 요약하여 정리한 것입니다. 내용에 오류가 있을 수 있으며, 어떠한 피드백도 감사히 받겠습니다.
감사합니다.