[iOS] Dependency Injection (의존성 주입) - DI

2021. 4. 5. 00:36iOS

반응형

이전 글에서 Unit Test에 대해 썻는데, 오늘은 Dependency Injection을 이용해서 모킹한 객체로 테스트 코드를 작성하는 법에 대해 알아보려고 합니다~~

 

이전 글을 보고 오면 더욱 이해하는데 도움이 될 것 같은데요.

 

[iOS] Unit Test

iOS에서 Unit Test를 무엇일까..? 굳이 왜 Unit Test를 실행해야하고 이러한 것을 함으로서 얻는 이점은 무엇이고 힘든 점은 무엇일까요? 저도 크게 이러한 것에 대해 정확히는 모르고 어림잡아만 알고

dongminyoon.tistory.com

이번 포스팅에서 해볼 것은 Dependency Injection을 이용해서 특정 모듈의 의존성을 떨어뜨리고 이를 이용해 Mock 객체를 주입하고 네트워크 요청에 대한 테스트를 진행해 볼 것입니다.

 

우선 Dependency Injection에 대해 알아보도록하겠습니다.

 

 

DI(Dependency Injection) - 의존성 주입

우선 의존성 주입이라고하면 크게 어려울 것은 없습니다.

 

A 클래스 안에 B 클래스가 프로퍼티로 선언되어서 초기화되어 있습니다. 이러한 관계를 A 클래스 안에서 B 클래스를 초기화하는 것이 아니고 외부에서 초기화해서 A 클래스 안에 할당해주는 것입니다.

 

그리고 주입을 진행할 때, B 클래스를 추상화시켜서 추상화한 변수에 할당할 수 있도록 할 것입니다.

 

이렇게 말하면 잘 이해가 되지 않을수도 있지만 코드로 예를 들어 보겠습니다.

// Dependency Injection을 이용하지 않은 경우
class NetworkManager {
    let session: URLSession = URLSession.shared
}

// Dependency Injection을 이용한 경우
class NetworkManager {
    let session: URLSessionProtocol
    
    init(session: URLSessionProtocol = URLSession.shared) {
        self.session = session
    }
}

이렇게 위의 코드처럼 기본적으로 NetworkManager 안에서 초기화를 하던 session을 더 추상화한 URLSessionProtocol이라는 레퍼런스에 외부에서 초기화를 초기화를 해주는 것입니다.

 

이게 Dependency Injection의 핵심이라고 할 수 있습니다.

 

근데, 굳이 안에서 초기화하면 될 것을 왜 밖에서 초기화하고 주입할까요...?

 

첫번째로 모듈간의 Dependency가 떨어질 수 있겠죠?? 만약 다른 모듈이 변경되면 다른 모듈은 이에 영향을 받게 되는데, 이렇게 추상화를 한 모듈에 의존하게 되면서 A 모듈은 B 모듈이 어떤 모양을 가진것만 알기 때문에, 의존성이 떨어지게 됩니다.

 

두번째로 가장 중요한 목표인 테스트가 용이해지는 코드가 작성이 됩니다. 이렇게 추상화를 한 모듈에 의존하게 되면서 자연스럽게 해당 프로토콜을 채택한 Mock 객체를 이용한 테스트를 진행할 수 있게 됩니다.

 

세번째로 추상화된 모듈에 의존하게 되면서 자연스럽게 프로그램 구조에서 Coupling이 작아지게 되고 확장성이 좋은 유연한 프로그램 구조를 가진 프로그램으로 설계됩니다.

 

이러한 장점이 생기게 되는데요.

그렇다면 한번 이 Dependency Injection을 이용해서 Network 테스트를 Mocking해서 해보도록 하겠습니다.

 

 

Mock 객체를 이용한 Network 테스트

이제 Dependency Injection을 이용해 Mocking한 객체를 주입하고 Network에 대한 테스트를 진행해볼 것입니다.

근데, 왜 굳이 Mocking한 객체를 주입해서 할까요?

 

기존에 URLSession을 이용해서 네트워크 요청을 보내고 받아온 데이터에 대한 테스트를 진행하는 코드가 있다고 해보겠습니다. 근데 만약 여기서 Wifi, 셀룰러 연결이 끊기면 어떻게 될까요..?

 

당연히 잘 작성된 Unit Test 코드라도 무조건 실패하는 테스트코드가 되버리겠죠...?

func test_async() {
    let expectation = XCTestExpectation(description: "API Request")
    let url         = URL(string: "https://apple.com")!
    var resultData: Data?
    
    let dataTask    = URLSession.shared.dataTask(with: url) { data, _, _ in
        resultData = data
        expectation.fulfill()
    }
    
    dataTask.resume()
    
    wait(for: [expectation], timeout: 5)
    XCTAssertNotNil(resultData)
}

이런 경우가 되겠죠..?

 

자 그렇다면 이제 하나의 상황을 가정하고 예시로 된 코드에서 Dependency Injection을 실행하고 이에 Mocking한 객체를 주입하고 네트워크 요청을 보내는 상황을 흉내내보겠습니다.

 

기존 코드는 다음과 같을 것입니다.

class NetworkManager {
    let session: URLSession = URLSession.shared
    
    func load(from url: URL, completion: @escaping (Result<Data, Error>) -> Void) {
        let task = self.session.dataTask(with: url) { data, response, error in
            guard let response = response as? HTTPURLResponse,
                  (200...399) ~= response.statusCode else {
                completion(.failure(error ?? APIError.unknownError))
                return
            }
            
            guard let data = data else {
                completion(.failure(error ?? APIError.unknownError))
                return
            }
            
            completion(.success(data))
        }
        
        task.resume()
    }
}

이 상황에서는 load라는 메소드를 호출하면 무조건 Network에 요청을 보내는 상황이 되어버립니다.

여기에 저희는 URLSession을 더욱 추상화하고 외부에서 인스턴스를 초기화 시켜줄 것입니다.

class NetworkManager {
    let session: URLSessionProtocol
    
    init(session: URLSessionProtocol = URLSession.shared) {
        self.session = session
    }
    
    // ...
}

그럼 여기서 URLSessionProtocol의 모양은 어떻게 되어야할까요..?!

 

URLSession이 가지고 있는 dataTask라는 메소드를 가지고 있어야겠죠. 그리고 URLSession이 이 메소드를 채택하고 있어야 기본 코드에서 컴파일러 에러가 발생하지 않게 됩니다.

protocol URLSessionProtocol {
    func dataTask(with url: URL, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask
}

extension URLSession: URLSessionProtocol {}

 

 

이제 보통의 경우에 사용할 때는 URLSession.shared를 사용할 수 있겠지만 Unit Test를 실행할 때는 Mokcing을 한 객체를 이용해서 해당 모듈의 테스트를 진행하려고 합니다.

class URLSessionMock: URLSessionProtocol {
    var data: Data?
    var error: Error?
    
    var isFailure: Bool
    
    init(isFailure: Bool = false) {
        self.isFailure = isFailure
    }
    
    func dataTask(with url: URL, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
        let data = self.data
        let error = self.error
        
        let successResponse = HTTPURLResponse(url: url,
                                              statusCode: 200,
                                              httpVersion: "",
                                              headerFields: nil)
                                              
        let failureResponse = HTTPURLResponse(url, url,
                                              statusCode: 404,
                                              httpVersion: "",
                                              headerFields nil)
                                              
        if self.isFailure == true {
            return URLSessionDataTaskMock { completionHandler(nil, failureResponse, error) }
        } else {
            return URLSessionDataTaskMock { completionHandler(data, successResponse, error) } 
        }
    }
}

이렇게 Mock 객체를 만들고 성공했을 시, 넘겨줄 임의의 응답과 실패했을 때, 넘겨주는 임의의 응답을 만들어줍니다.

실제로 네트워크로 요청은 보내지 않고 성공, 실패 시에 임의의 값을 받아서 모듈의 동작만을 확인할 수 있는 Mock 객체가 완성됩니다.

 

이제 다음으로 completionHandler만 실행하는 URLSessionDataTaskMock을 만들어줍니다.

class URLSessionDataTaskMock: URLSessionDataTask {
    private let closure: () -> Void
    
    init(closure: @escaping () -> Void) {
        self.closure = closure
    }
    
    override func resume() {
        closure()
    }
}

이제 이렇게만 작성해주면, URLSessionDataTaskMock은 그저 resume을 했을 때, 실패 네트워크 요청이 아닌 흉내만 내는 Mock 객체로 만들어집니다.

 

이제부터 만든 객체들로 네트워크 상황에 의존적이지 않은 테스트를 진행해보겠습니다.

기본적으로 성공하는 케이스와 실패하는 케이스를 작성할 것입니다.

func test_Success() {
    // given
    let session = URLSessionMock()
    let manager = NetworkManager(session: session)
    
    let url = URL(string: "someURL")!
    let data = """
               {
                    "name": "Some"
               }
               """.data(using: .utf8)!
               
    session.data = data
    
    // when
    manager.load(from: url, completionHandler: { result in
    
    // then
        switch result {
        case .success(let sampleData):
            XCTAssertEqual(sampleData, data)
        case .failure:
            XCTFail()
        }
    })
}

 

다음으로는 실패하는 케이스입니다.

func test_Failure() {
    // given 
    let session = URLSessionMock()
    let manager = NetworkManager(session: session)
    
    let url = URL(string: "someURL")!
    session.isFailure = true
    
    // when
    manager.load(from: url, completionHandler: { result in
    
    // then
        switch result {
        case .success:
            XCTFail()
        case .failure:
            XCTAssert(true)
        }
    })
}

이렇게 작성함으로서 NetworkManager라는 객체를 테스트 함에 있어서 네트워크 상황에 의존적이지 않은 테스트를 진행할 수 있게 됩니다. 그리고 Protocol로 추상화를 함에 있어서 자연적으로 의존성이 줄어들게 되고 순수 NetworkManager만을 테스트 할 수 있게 됩니다.

 

이렇게 Mock을 활용해서 테스트를 진행할 때, Dependency Injection을 이용해서 작성할 수도 있습니다~~

 

오늘 글도 많은 도움이 되었으면 좋겠습니다.. :)

혹시 잘못된 점이나 궁금한 점이 있으면 댓글 달아주세요~

반응형