기어가더라도 제대로

[iOS-CoreData] Unit Test 해보기 본문

Swift - 데이터베이스

[iOS-CoreData] Unit Test 해보기

Damagucci-juice 2023. 2. 3. 13:56
1. Unit Test가 지켜야할 FIRST
2. Core Data Store란?
3. 실제 테스트 구성하기
    3.1. Test앱 초기 CoreData 세팅
    3.2. 테스트 전용 Core Data Storage 구현
    3.3. Test 구현
4. 글 맺음

1. Unit Test가 지켜야 할 FIRST

  • F - Fast: 빨라야합니다. 테스트 실행부터 결과까지 빠르게 나와야 합니다.
  • I - Isolated: 독립적이 여야 합니다. 실제 프로젝트에 영향을 주면 안 됩니다. 그리고 다른 테스트에도 영향을 주면 안됩니다.
  • R - Repeatable: 테스트를 실행할 때마다 결과가 같게 나와야 합니다.
  • S - Self-verifying: 테스트는 성공, 실패로 나눠야지, 콘솔이나 로그를 보면서 확인하면 안 됩니다.
  • T - Timely: 테스트를 먼저 작성하고, 추가하려는 기능의 청사진으로 사용하라고 합니다.

근데 외국 문서 읽을 때마다 느끼는 거지만, 정말 약어 만들기 좋아하는 것 같습니다. SOLID, OCP, SRP, DIP, …

그리고 다 동의하는데 T는 TDD라는 기법을 이야기하는 거 같은데 은근 고난도라서, OOP 하듯이 설계를 하고,
Test로 확인하는 정도만 해줘도 충분하다고 생각합니다.

2. Core Data Store란?

CoreData안에서 실질적으로 저장소의 역할을 수행하는 것이 Store입니다.

이 Store의 종류가 다양한데, 실 프로젝트에서는 기본값으로 SQLite를 사용한다고 합니다.

저희는 In-Memory store를 사용할 것입니다. 테스트만 실행하고 휘발될 수 있도록 말이죠!

DB를 다뤄보신 분들은 아시겠지만, 쿼리문이라는 것을 배워서 DB 하고 소통을 해야 합니다.

하지만 CoreData를 사용하시는 분들은 Query문을 작성해 본 경험이 없으실 겁니다.

Swift 명령을 받아서 CoreData에 API 형식으로 요청을 작성하는 것이 Context라는 녀석입니다. 쿼리문으로 바꿔주는 역할인 것이죠. 그리고 모델에서 변화도 감지하고, 저장도 하고 다양한 기능을 합니다.

  • Persistent Container: 하위 모듈을 관리하는 컨테이너
    • Model: 파일 형식으로 CoreData를 만들 것인데, 그 파일을 프로그래밍에서도 접근할 수 있게 만든 대표
    • context: Manged Object들을 조작하거나 변경을 감지하는 객체 공간
    • store: 실질적인 Core Data의 데이터 베이스, 기본형으로 SQLite를 채택하여 사용

출처: Kodeco

3. 실제 테스트 구성하기

3.1. Test앱 초기 CoreData 세팅

초기 앱 생성

  • CoreData 만들지 말고 테스트 앱을 생성해 줍니다.

Unit Testing Bundle(Target) 생성

  • File - New - Target에서 Test라고 검색하면 Unit Testing Bundle 이 나오는데 생성해 줍니다.

  • 넥스트 넥스트 피니시

Core Data Model을 생성

file - new - data model

엔티티 모델을 생성

  • 맨 아래 Add Entity 눌러서 엔티티를 생성합니다.
  • NumberEntity로 이름 변경 후에 Property하나만 추가할게요

CoreDataStorage 구성

import CoreData

enum CoreDataStorageError: Error {
    case readError
}

class CoreDataStorage {

        /// Test 할 때에도 동일한 CoreDataModel을 사용합니다. 
        //MARK: - 위에 생성한 CoreDataModel 을 찾기 위해 만들어주는 코드
    public static let modelName = "CoreDataModel"

    public static let model: NSManagedObjectModel = {
      let modelURL = Bundle.main.url(forResource: modelName, withExtension: "momd")!
      return NSManagedObjectModel(contentsOf: modelURL)!
    }()

    static let shared = CoreDataStorage()

    init() {}

        /// 프로젝트용 컨테이너
        /// 테스트용 컨테이너는 이 변수에 테스트 스토리지에서 만든 컨테이너를 주입할 예정
    // MARK: - Core Data stack
    public lazy var persistentContainer: NSPersistentContainer = {
        let container = NSPersistentContainer(name: CoreDataStorage.modelName)
        container.loadPersistentStores { _, error in
            if let error = error as NSError? {
                // TODO: - Log to Crashlytics
                assertionFailure("CoreDataStorage Unresolved error \(error), \(error.userInfo)")
            }
        }
        return container
    }()

    func saveContext() {
        let context = persistentContainer.viewContext
        if context.hasChanges {
            do {
                try context.save()
            } catch {
                // TODO: - Log to Crashlytics
                assertionFailure("CoreDataStorage Unresolved error \(error), \((error as NSError).userInfo)")
            }
        }
    }

    func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) {
        persistentContainer.performBackgroundTask(block)
    }
}
  • saveContext() : 이 코드는 현재 viewContext의 문맥에 변화를 감지하면 그것을 저장하는 코드입니다.
  • performBackgroundTask(_ block: @escaping () -> Void) : write transaction을 수행하는 코드입니다.
    • 비동기적으로 처리를 하나 보내요.
  • CoreDataStorage의 역할: CoreDataModel과 소통하기 위한 API를 마련해 놓은 것이라고 보면 됩니다.

Number Storage 프로토콜(인터페이스)과 CoreDataNumberStorage 생성

protocol NumberStorage {
    func get() async throws -> [Number]
    func save(_ object: Number) async -> Bool 
}

class CoreDataNumberStorage {
    private let coreDataStorage: CoreDataStorage

        init(coreDataStorage: CoreDataStorage) {
            self.coreDataStorage = coreDataStorage
        }
}

//MARK: - Public
extension CoreDataNumberStorage: NumberStorage {
    func get() async throws -> [Number] {
        try await withCheckedThrowingContinuation { continuation in
            get { result in
                switch result {
                case .success(let numbers):
                    continuation.resume(returning: numbers)
                case .failure(let error):
                    continuation.resume(throwing: error)
                }
            }
        }
    }

    func save(_ object: Number) async -> Bool {
        await withCheckedContinuation { continuation in
            save(number: object) { isSuccess in
                continuation.resume(returning: isSuccess)
            }
        }
    }
}

//MARK: - Private
extension CoreDataNumberStorage {
    private func get(_ completion: @escaping (Result<[Number], Error>) -> Void) {
        coreDataStorage.performBackgroundTask { context in
            do {
                let request: NSFetchRequest = NumberEntity.fetchRequest()
                let result = try context.fetch(request).map { $0.toDomain() }
                completion(.success(result))
            } catch {
                completion(.failure(CoreDataStorageError.readError))
            }
        }
    }

    private func save(number: Number, _ completion: @escaping (Bool) -> Void) {
        coreDataStorage.performBackgroundTask { context in
            do {
                _ = NumberEntity(number: number, insertInto: context)
                try context.save()
                completion(true)
            } catch {
                completion(false)
            }
        }
    }
}
  • CoreData에서 Number 타입을 다루기 위해서 전용 레포지토리를 하나 만들어줍니다.
  • get()으로 Number의 배열을 가져오며, save() 하고 결과를 Bool 타입으로 반환받습니다.

도메인 모델 ↔ 엔티티 모델 사이 변환 작업 코드

protocol DomainEntity {
    associatedtype ModelEntity

    func toDomain() -> ModelEntity
}

extension NumberEntity: DomainEntity {
    convenience init(number: Number, insertInto: NSManagedObjectContext) {
        self.init(context: insertInto)
        self.value = number.value
    }

    func toDomain() -> Number {
        return .init(value: self.value)
    }
}

struct Number: Equatable {
    var value: Int64
}
  • 이런 프로토콜을 만들어주고 Number를 받아서 NumberEntity를 만들 수 있게 이니셜라이저를 선언합니다.
  • toDomain() : NumberEntity에서 Number로 만들 수 있도록 합니다.

3.2. 테스트 전용 Core Data Storage 구현

  • 위에 만든 CoreDataStorage 의 Test에서만 사용할 버전을 만든다고 생각하면 쉽습니다.
  • 실제 프로젝트에 미치는 영향을 없이 하기 위해서 이런 버전을 만들어줍니다.
import CoreData
@testable import ExampleCoreDataTesingApp

class TestCoreDataStorage: CoreDataStorage {
    override init() {
        super.init()

                //MARK: - 1번
        let persistentStoreDescription = NSPersistentStoreDescription()
                //MARK: - 2번
        persistentStoreDescription.type = NSInMemoryStoreType

                //MARK: - 3번
        let container = NSPersistentContainer(name: CoreDataStorage.modelName,
                                              managedObjectModel: CoreDataStorage.model)
                //MARK: - 4번
        container.persistentStoreDescriptions = [persistentStoreDescription]

                //MARK: - 5번
        container.loadPersistentStores { _, error in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        }
                //MARK: - 6번
        persistentContainer = container
    }
}
  • 실제 파일이 Test Target에 자리하고 있어서 @testable import ExampleCoreDataTesingApp 을 붙입니다.
  • init 메서드만 구현하는 간단한 객체입니다.
    1. persistentStore 설명서를 생성합니다.
    2. In-Memory 형식의 store라는 것을 밝힙니다.
    3. CoreDataStorage 에 타입 프로퍼티로 만든 Core Data Model을 가리키는 정보를 넣어줍니다. container를 새로 만들어줍니다.
    4. container에 persistentStoreDescription을 넣어줍니다.
    5. 새로 만든 컨테이너에서 PersistentStore를 로드하는 모습입니다.
    6. 부모 클래스의 persistentContainer 속성에 새로만든 컨테이너를 넣어줍니다.
  • 이 결과 기존 프로젝트에 독립하면서 메서드 사용은 그대로 하는 테스트 전용 Core Data Storage 가 생성되었습니다.

3.3. Test 구현

어떤 메서드를 테스트해보는 것이 좋은가 하면, 되는지 안되는지 확인해보고 싶은 메서드를 하면 됩니다.

그중에서도 위에서 만든 protocol NumberStorage 의 메서드들을 테스트해보기로 해요.

이 파일을 보면 테스트 파일이 나옵니다.

여기를 채울 겁니다.

테스트는 일반적으로 실제 프로젝트에 영향을 주지 않는 방향으로 해야 하기 때문에 Mock(가짜) 데이터를 사용합니다. 그래서 위에 여러 프로토콜을 만들어주었는데 Mock 데이터들이 프로토콜만 채택을 하면 테스트환경에서도 실행을 할 수도 있습니다.

위에서 만든 TestCoreDataStorage 가 이런 방식으로 만들어진 가짜 데이터입니다.

  • 테스트 전용으로 setUp 하는 메서드와 tearDown 하는 메서드를 만들어주겠습니다.
    • setUp 은 테스트를 메모리에 올리는 과정
    • tearDown 은 메모리에서 테스트를 내리는 과정
  • 이렇게 하면 테스트를 진행할 준비가 완성이 되었습니다.
  • 이제 get() 메서드와 save() 메서드를 진행할 준비가 끝났습니다.

Save Method Test

  • 모든 테스트는 메서드 이름이 test 로 시작을 해야 테스트를 진행할 수 있습니다.
  • 저장을 성공여부에 따라 Bool 타입을 반환합니다.
  • 그것을 가지고 True이면 테스트가 성공이죠!

Get Method Test

  • Mock 데이터를 만들어서 저장하고
  • fetch 해온 것을 sorting 해줬습니다.
  • 개수와 숫자 순서가 맞는지 확인했습니다.

4. 글 맺음

CoreData Unit 테스트를 알아봤습니다.

Persistent Storage를 테스트하는 건 준비과정이 길어서 그렇지 할만합니다.

그렇지만 튜토리얼은 제발 사 드세요…

레포 첨부

 

GitHub - Damagucci-Juice/ExampleCoreDataTestingApp

Contribute to Damagucci-Juice/ExampleCoreDataTestingApp development by creating an account on GitHub.

github.com

 

출처

 

Unit Testing Core Data in iOS

Testing code is a crucial part of app development, and Core Data is not exempt from this. This tutorial will teach you how you can test Core Data.

www.kodeco.com

 

 

[iOS - Swift] CoreData 용어 정리 및 CRUD 사용법

목차 - core data 는 무엇? - DB 와 다른가? ORM은 무엇인가? - Application's Model Layer - Data Model 만들기 - Entity 란? - Core Data Model로 부터 Swift Class 만들기(3가지 방법) - Core Data Stack 이란? 그리고 생성하기 - NS

damagucci-juice.tistory.com

 

Comments