Unit Test without networking

yim2627·2022년 1월 7일
4

Swift

목록 보기
33/38
post-thumbnail

네트워크와 실제로 통신하며 테스트..

실제 서버와 네트워킹하는 과정은 뭐 어떻게 테스트 하는걸까?

예를 들면 서버로부터 아래와 같은 JSON 파일을 받아오고 이를 테스트를 하는 것이겠지..

{
    "name": "Serval",
    "latin_name":"Leptailurusserval",
    "animal_type":"Mammal",
    "active_time":"Nocturnal",
    "length_min":"2.3",
    "length_max":"3.3",
    "weight_min":"7.5",
    "weight_max":"41",
    "lifespan":"13",
    "habitat":"Savannah",
    "diet":"Game birds,
    rodents, small ungulates",
    "geo_range":"Africa",
    "image_link":"https://upload.wikimedia.org/wikipedia/commons/7/70/Leptailurus_serval_-Serengeti_National_Park%2C_Tanzania-8.jpg",
    "id":161
}

만일 실제 서버와 통신하며 테스트한다면 아~ 서버랑 잘 통신도 되고 데이터도 잘 받고 요청도 잘 먹히네 할 수 있겠다.

근데.. 아래에서도 더 설명할 것이지만.. 내가 무심코한 테스트가 서버에 영향을 끼칠 수 있다.

같은 요청을 보내도 다른 결과가 나올 수 있는 것이다.

왜? 내가 삭제 테스트를 했을 수 도 있으니깐..

이를 멱등하지 않다. 즉, 비멱등성이라고 한다.

HTTP Method는 POST를 제외하고 "멱등"을 중요시한다.

https://developer.mozilla.org/ko/docs/Glossary/Idempotent

그럼 우째야할까.. 밑에서 더 알아보자

네트워크와 무관한 테스트를 하는 목적

우리는 Unit Test를 진행할 때 테스트 케이스를 만들며 마음대로 데이터를 넣고 불러오며 내가 원하는 결과가 도출되는지를 본다.

첫번째 이유로는..

만일 서버, 네트워크 관련 테스트가 아니라면 무슨 값을 넣든 상관 없겠지만 실제 서버단과 통신하며 테스트를 하게된다면 어떤 결과를 야기할까 생각해보자

서버쪽은 뭐.. 음식 관련 데이터를 갖고 있다고 예를 들어보자

근데 내가 실제 서버와 통신을 하고 테스트를 진행하는데, 데이터를 막 넣고 뺀다면?

음식 데이터 갖고 있는데 거따가 뭐 막 먹지도 못하는 거 넣고선, 서버가 본래 가졌던 데이터로 깔끔하게 복구를 해놓지 못한다면?

다른 사람이 해당 서버를 사용하여 데이터를 불러올 때 음식 불러왔드만 갑자기 뭔 라이터 이런게 나오면?

서버를 나 혼자 쓰는 것도 아니기에 분명히 혼란을 야기할 것이다.

두번째 이유로는..

Unit Test를 진행할 때 실제 서버와 통신한다면, 실제 서버의 컨디션에 따라 테스트의 결과가 달라질 수 있다.

실제 서버에 의존을 하게 되기에, 독립적인 테스트를 진행할 수 없게되어 테스트의 신뢰성을 떨어뜨린다.

네트워크와 무관한 URLSession Test

과정을 간략하게 정리해보면 다음과 같다.

  1. 특정 프로토콜 (여기선 URLSessionProtocol)로 URLSession 타입을 추상화 한다.
  2. 기존에 URLSession을 사용하여 네트워킹하는 타입이 URLSession에 의존하는 것이 아닌 URLSessionProtocol에 의존하게 하고, init을 생성하여 생성자 주입을 받게 한다.
  3. MockURLSessionDataTask 생성 후 URLSessionDataTask를 상속받아 resume을 override하고, 네트워킹 하는 타입에서 taskresume될 때 호출될 클로저 구현
  4. MockURLSession 생성 후 URLSessionProtocol 채택 및 Request 성공/실패 플래그 init 생성
  5. URLSessionProtocol에 정의되어있던 dataTask 구현
  6. dataTask 내부엔 우리가 임의로 만들 request에 따른 response를 생성해준다.
  7. task.resume을 호출하면 MockURLSessionDataTask에 재정의한 resume이 호출되고 resume 내부의 클로저가 호출
  8. 이때 성공/실패 플래그에 따른 CompletionHandler 호출

이제 진행해보자.. 드럽게 어렵다

먼저 우리가 실제로 서버와 통신할 때 사용할 URLSession을 만들어줘야한다.

이 곳에서 URLSession을 바꾸고, task를 바꿔줌으로써 네트워크와 무관한 테스트가 가능한 것이다.

그니깐 한마디로 Session을 가짜 Session (Mock)으로 바꿔치기 해준다는 것이다.

enum APIError: Error {
    case error
}

class APIService {

    func fetchData(completion: @escaping (Result<AnimalData, APIError>) -> Void) {
        let url = URL(string: "https://zoo-animal-api.herokuapp.com/animals/rand")
        let request = URLRequest(url: url!)
        
        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            guard error == nil else {
                completion(.failure(.error))
                return
            }
            
            guard let response = response as? HTTPURLResponse, (200...299).contains(response.statusCode) else {
                completion(.failure(.error))
                return
            }
            
            if let data = data {
                let parsedData = try? JSONDecoder().decode(AnimalData.self, from: data)
                completion(.success(parsedData))
                return
            }
            completion(.failure(.error))
        }
        task.resume()
    }
}

아 근데 함 되나 대충 실행해봤다.

class ViewController: UIViewController {

    
    @IBOutlet weak var image: UIImageView!
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let api = APIService()
        api.fetchData { result in
            switch result {
            case .success(let data):
                let animalImageURL = URL(string: data.imageLink)
                let animalData = try! Data(contentsOf: animalImageURL!)
                DispatchQueue.main.async {
                    self.image.image = UIImage(data: animalData)
                }
            case .failure(.error):
                print("error")
            }
        }
    }
}

뭐여 앵무새여?

일단.. 서버로부터 온 데이터는 잘 나온다는 것을 확인했다

위에서 만든 코드가 잘 만들어졌다는 뜻 ㅎㅎ

이제 저 코드르 활용하여.. 네트워크와 무관한 테스트를 진행해보자

만들어준 URLSessionMockURLSession을 추상화하여 Protocol을 구현해보자

우리는 다음 단계 얘기긴 하지만.. URLSession에 프로토콜을 채택할 것이고 해당 프로토콜에 dataTask 메서드가 없으면 컴파일 에러를 내기 때문에 해당 프로토콜에 dataTask를 정의해준다.

이렇게 컴파일 에러냄

그니깐 아래처럼

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

그리고 해당 프로토콜을 URLSession과 곧 생성할 MockURLSession에 채택해주어 본래 fetchData 메서드 내부에서 쓰인 Session을 바꿔치기 해줄 것이다!!!!!!!!

그러려면 URLSessionProtocol을 채택한 session 프로퍼티를 생성하고 우리가 원하는 세션을 넣어줄 것이기 때문에 이니셜라이저를 구현하여 Session생성자 주입 해준다.

extension URLSession: URLSessionProtocol { }

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

이제 세션을 바꿀 준비는 끝났다..

드디어.. 지옥문이다

MockURLSessionDataTaskMockURLSession을 만들어주자

우리는 MockURLSession을 통해 APIService 타입 내부의 session을 바꿔줄 것임을 기억하자

class MockURLSessionDataTask: URLSessionDataTask {
    var resumeDidCall: () -> Void = {}
    
    override func resume() {
        resumeDidCall()
    }
}

class MockURLSession: URLSessionProtocol {
    var isSuccess: Bool
    let sessionDataTask = MockURLSessionDataTask()
    
    init(isSuccess: Bool = true) {
        self.isSuccess = isSuccess
    }

먼저 URLSessionDataTask를 대신할 MockURLSessionDataTask 생성하여준다.

물론 방금 말했듯이 URLSessionDataTask를 대신할 것이기 때문에 MockURLSessionDataTaskURLSessionDataTask를 상속받는다.

이후 APIService 내의 fetchData에서 반환될 taskresume 해주어야 내부 클로저가 실행되게 된다.

하지만 우린 MockURLSessionDataTask!!!!resume시켜야하기 떄문에 resume재정의하여 우리가 만들어준 클로저(resumeDidCall)를 실행시키도록 한다.

이제.. MockURLSession을 만들어주고 방금 말한 우리가 만들어준 클로저(resumeDidCall)!!!!가 할 작업을 설정해줘야한다.

만들어보자

    func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
        let successResponse = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)
        let failureResponse = HTTPURLResponse(url: request.url!, statusCode: 400, httpVersion: "2.0", headerFields: nil)
        
        if isSuccess {
            sessionDataTask.resumeDidCall = {
                completionHandler(Animals.data, successResponse, nil)
            }
        }
        else {
            sessionDataTask.resumeDidCall = {
                completionHandler(nil, failureResponse, nil)
            }
        }
        
        return sessionDataTask
    }
}

URLSessionProtocol을 채택한 MockURLSessiondataTask를 구현한 부분이 이 곳이다.

request를 성공시킬지, 실패시킬지에 대한 플래그 isSuccess를 만들어 성공/실패에 따른 response를 보낸다.

위에서 말한 우리가 만들어준 클로저(resumeDidCall)가 할 작업 내부에서 입맛대로 설정해준 response, data, error를 가진 completionHandler를 호출해준다.

그럼 우리가 설정해준 response, data, errorfetchData 내부 dataTask의 클로저에서 받아 각각 조건문을 거치게되는 것이다.

그럼 이제 조건을 거친 결과를 받아 비교, 테스트할 테스트 코드(Unit Test)를 작성해줘야겠지?

import XCTest
@testable import NetworkTest

class NetworkTestTests: XCTestCase {
    var sut: APIService!
    
    func test_Success() {
        sut = APIService(session: MockURLSession(isSuccess: true))
        let response = try? JSONDecoder().decode(AnimalData.self, from: Animals.data)
        
        sut.fetchData { result in
            switch result {
            case .success(let data):
                guard let animal = try? JSONDecoder().decode(AnimalData.self, from: data) else { return }
                XCTAssertEqual(animal.name, response?.name)
            case .failure(let error):
                XCTFail("fail!!!!!!!!!!!")
            }
        }
    }
    func test_Failure() {
        sut = APIService(session: MockURLSession(isSuccess: false))
        let response = try? JSONDecoder().decode(AnimalData.self, from: Animals.data)
        
        sut.fetchData { result in
            switch result {
            case .success(_):
                XCTFail("fail!!!!!!!!!")
            case .failure(let error):
                XCTAssertEqual(error, APIError.error)
            }
        }
    }
}

조건을 거친 결과에 따른 Completion이 호출되어 유닛 테스트 코드로 던져지게 되는 것이다.

이렇게 네트워크와 무관한 테스트를 한다면 네트워크에서 발생할 문제들을 배제시키고 순수 로직만을 테스트할 수 있으며, 그말인 즉슨 Testable한 코드를 만들 수 있다는 것이 요점이다.

우리는 실제로 Testable한 코드를 작성하기 위해 의존성을 역전시키고, 주입하는 과정을 거쳤다.

MockURLSession 전체 코드

class MockURLSession: URLSessionProtocol {
    var isSuccess: Bool
    let sessionDataTask = MockURLSessionDataTask()
    
    init(isSuccess: Bool = true) {
        self.isSuccess = isSuccess
    }
    
    func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
        let successResponse = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)
        let failureResponse = HTTPURLResponse(url: request.url!, statusCode: 400, httpVersion: "2.0", headerFields: nil)
        
        if isSuccess {
            sessionDataTask.resumeDidCall = {
                completionHandler(Animals.data, successResponse, nil)
            }
        }
        else {
            sessionDataTask.resumeDidCall = {
                completionHandler(nil, failureResponse, nil)
            }
        }
        
        return sessionDataTask
    }
}

MockURLSessionDataTask 전체 코드

class MockURLSessionDataTask: URLSessionDataTask {
    var resumeDidCall: () -> Void = {}
    
    override func resume() {
        resumeDidCall()
    }
}

진짜 이해하는데 3일 넘게 걸린 것 같다. 안 ㅣ아직도 다 이해 못한 듯

전체 코드

Example(git)

profile
여러 사람들과 함께 많은 것을 배우고 나누리

0개의 댓글