XLOG

[XCTest] TDD? Testable? 업무 효율 높이기 본문

Swift/Etc

[XCTest] TDD? Testable? 업무 효율 높이기

X_PROFIT 2023. 8. 1. 22:56

TDD(Test Driven Development) 는 테스트 주도 개발이다. 초기 공부를 시작하다보면 자연스럽게 접하게 되는 용어이다. 왜 그런걸까?

Swift 를 공부하면서 XCTest를 알기전, View를 만들고 확인하고 내부 로직을 만들어 View 붙이고를 반복했다.

메인 페이지라면 크게 문제는 없었지만 프로젝트 규모가 커지고, 내부에 view들을 확인하기 위해 simulator 를 돌리고, 원하는 페이지까지 이동해서 확인하고, 문제가 있으면 수정해서 다시 로직을 수정하고를 반복했다.

가끔은 Playground 를 만들어 테스트 해보고 프로젝트에 적용하기도 했다. 상당히 비효율적이다.

XCTest 를 사용하면 이와 같은 문제를 해결할 수 있다.

각종 테스트를 할 수 있는 프레임워크다.

프로젝트 생성시 Include Tests 를 체크하면 Test 파일이 자동으로 만들어 진다.

import XCTest
@testable import PracticeXCTest

final class PracticeXCTestTests: XCTestCase {}

내부 코드를 보게 되면 XCTest 와 testable 어노테이션이 붙은 프로젝트가 import 되어 있다.

그리고 XCTestCase 를 상속하는 클래스가 정의 되어 있다.

XCTestCase 는 test cases, methods, performance tests 를 정의하는 기본적인 클래스이다.

여기엔 setUp(), tearDown() 함수가 있는데 이것은 viewDidLoad, viewDidDisAppear 와 같은 기능을 한다고 보면 된다.

우선 Test를 위해 몇가지 코드를 준비했다.

import Foundation
import SwiftUI
import CoreLocation

struct Landmark: Hashable, Codable, Identifiable {
    var id: Int
    var name: String
    var park: String
    var state: String
    var description: String

    private var imageName: String
    var image: Image {
        Image(imageName)
    }

    private var coordinates: Coordinates
    var locationCoordinate: CLLocationCoordinate2D {
        CLLocationCoordinate2D(
            latitude: coordinates.latitude,
            longitude: coordinates.longitude)
    }

    struct Coordinates: Hashable, Codable {
        var latitude: Double
        var longitude: Double
    }
}
import Foundation

class ModelData {
    func load<T: Decodable>(_ filename: String) -> T {
        let data: Data

        guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
            else {
                fatalError("Couldn't find \(filename) in main bundle.")
        }

        do {
            data = try Data(contentsOf: file)
        } catch {
            fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
        }

        do {
            let decoder = JSONDecoder()
            return try decoder.decode(T.self, from: data)
        } catch {
            fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
        }
    }
}

위의 코드는 SwiftUI 기본 튜토리올에 landmarks.json 파일을 불러와 List로 보여주는 코드이다.

이 예제를 선택한 이유는 가장 많이하는 행위가 네트워크 통신을 통해 json 파일을 불러와 변화시켜 우리 View에 뿌려주는 것이라고 생각해서 이다. ModelData 의 load 에서 Bundle.main.url 부분이 네트워크 통신을 통해 데이터를 다운받는 것이라고 생각하면 된다.

이제 테스트를 진행해보자.

final class PracticeXCTestTests: XCTestCase {
    // Test할 Unit
    // 보통 sut으로 네이밍을하는데 이는 system under test의 약자이다.
    var sut: ModelData!

	// = viewDidLoad() 
    override func setUp() {
        super.setUp()
        sut = ModelData()
    }
	// = viewDidDisAppear() 
    override func tearDown() {
        sut = nil
        super.tearDown()
    }
    
    // 함수명 맨 앞에 test를 붙이면 XCTest는 이것이 테스트해야할 코드라고 인식을 한다.
    func testExample() throws {
        var landmarks: [Landmark] = sut.load("landmarkData.json")
        let id = landmarks[0].id
        
        XCTAssertEqual(id, 1001)
    }
}

코드 주석에도 적혀있지만 함수명의 prefix로 test를 사용하면 xcode 는 테스트를 할 수 있다고 표시를 해준다. 41번 라인의 마름모를 클릭하게 되면 테스트가 진행된다.

테스트가 성공할 경우
테스트 실패시

XCTAssert를 통해 우리는 값을 예상하고 비교하여 결과를 얻는다. 내가 사용한 것을 동등한 값인지 확인하여 테스트를 진행했다.

위처럼 테스트 성공과 실패는 한눈에 알아볼 수 있게 표시를 해준다.

내가 맨처음 공부할 때 보통 계산기 기능을 통해 많이 예시를 보여줬다. 사실 맨 처음 받아들일 때, 너무 뻔한걸 왜 테스트 하나 생각이 들었다. 하지만 이걸 잘 응용하면 개발 시간을 단축 시킬 수 있다.

앞서 얘기했듯이, 네트워크 통신을 하여 json 데이터를 받아오고, 그걸 내가 정의한 class로 캐스팅을 한다면, 그 과정을 테스트 돌려 정상적으로 작동하는지 확인할 수 있다. 그리고 만약 Unit 단위로 잘 쪼갠 과정이 정상적으로 작동하지 않는다면  어디서 문제가 발생하는지simulator를 돌리지 않더라도 확인이 가능하다.


import UIKit

protocol Sample: AnyObject {
    func setUpLayer() -> Bool
    func dataLoad() -> [String]
}

class MakeSample: Sample {
    func setUpLayer() -> Bool {
    	print("잘 불러왔다면 이 함수가 실행이 되어야 한다.")
        return true
    }
    func dataLoad() -> [String] {
        return []
    }
}

final class ViewController: UIViewController {

    var sample: Sample!
    
    init(sample: Sample = MakeSample()) {
        self.init()
        self.sample = sample
    }
    
    required init?(coder: NSCoder) {
        print("")
        super.init(coder: coder)
    }
    
    override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
        super.init(nibName: nil, bundle: nil)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    func something() {
        guard let sample = sample else { return }
        if !sample.dataLoad().isEmpty {
            sample.setUpLayer()
        }
    }
}

만약 이러한 ViewController를 테스트 한다면,

import XCTest
@testable import PracticeXCTest

final class NewTests: XCTestCase {
    
    var sut: ViewController!
    var mockSample: MockFalseSample!

    override func setUp() {
        super.setUp()
        mockSample = MockFalseSample()
        sut = ViewController(sample: mockSample)
    }
    
    override func tearDown() {
        sut = nil
        super.tearDown()
    }
    
    func testExample() throws {
        sut.something()
        XCTAssertTrue(mockSample.setup)
    }
}

class MockTrueSample: Sample {
    
    var setup = false
    
    func setUpLayer() -> Bool {
        setup = true
        return setup
    }
    
    func dataLoad() -> [String] {
        return ["true"]
    }
}


class MockFalseSample: Sample {
    
    var setup = false
    
    func setUpLayer() -> Bool {
        setup = true
        return setup
    }
    
    func dataLoad() -> [String] {
        return []
    }
}

이런식으로 MockSample을 만들어 data가 불려오는지, load 에 성공했을 때  그 다음 동작들은 잘 동작하는지 체크할 수 있다. ( 아키텍쳐 흐름이 잘 이루어지는지 체크 가능)

물론 이 테스트로 앱이 정상적으로 돌아간다고 100% 신뢰할 순 없지만, 동작을 확인하는 시간을 단축 시킬 수 있으며, 어디서 문제가 발생하는지 알아내기 쉬워진다고 생각한다. 또한 Unit Test를 고려하여 코드 구조를 짠다면 조금 더 좋은 코드 구조를 만들게 되지 않을까 생각한다.