앱을 개발하다보면 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`에 대해 알아보도록 하겠다.
'iOS > Swift' 카테고리의 다른 글
| [iOS/Swift] 별도의 프로세스 - PHPicker를 사용할때 권한요청이 필요 없는 이유 (0) | 2025.04.07 |
|---|---|
| [iOS/Swift] Mock과 Stup은 다르다고? - Test Double (0) | 2025.04.03 |
| [iOS/Swift] 컴파일러 최적화 관점에서 본 Opaque Type (0) | 2025.03.28 |
| [iOS/Swift] Kingfisher를 쓰지 않고 이미지 캐싱 구현하기 - NSCache (0) | 2025.03.25 |
| [iOS/Swift] Combine에서 cancellables가 inout파라미터인 이유 (0) | 2025.03.23 |