Модульное тестирование — отличный способ удостовериться, что приложение работает на протяжении многих итераций. Иногда важно убедиться, что всё вместе работает асинхронно и что данные согласованы. Но когда вы не контролируете удаленный источник, такой расклад просто невозможен.

Дальше станет понятнее

Проблема модульного тестирования в динамическом окружении

Если вы проводите модульное тестирование сетевого вызова, скорее всего, вы тестируете, чтобы удостовериться не только в наличии подключения к сети, но и в том, что вы можете сделать что-то значимое посредством этого подключения, а также проверить, что произойдет после. Что-то “после”, как правило, опирается на последовательные данные.

Я хочу подключиться к серверу A, получить некоторые данные, декодировать их, а затем проверить, что данные содержат определённое значение. Такой модульный тест написать достаточно просто, но здесь всплывает неизбежная проблема в самой природе сетей. Все меняется  —  и обычно это хорошо. Но при проведении модульного тестирования необходимо сохранять определенные вещи (например, конкретное значение в данных).

Приложение также нужно тестировать в рабочей среде, то есть асинхронно. С обычными сетевыми тестами в долгосрочной перспективе это просто невозможно (если только данные не будут гарантированно статичными), но…

Встречайте имитационный загрузчик данных

Он опирается на очень простой протокол, который точно подражает URLSession.shared.dataTask. Его также можно использовать для расширения возможностей URLSession, так что вы никогда больше не забудете про .resume().

protocol NetworkLoader {   
 
      func loadData(using request: URLRequest, with completion: @escaping (Data?, URLResponse?, Error?) -> Void)
    }

Теперь, когда в нашем распоряжении есть до крайности простой, но очень мощный протокол, давайте создадим несложный класс, который ему соответствует и использует его. Этот класс мы позже применим в модульном тестировании. Он обладает теми же свойствами, что и метод loadData и включает в себя свойство запроса. В этом примере запрос является приватным, поэтому он не может быть прочитан вне класса, но может быть задан. Если нужно изменить его — пожалуйста. И это никак не скажется на функциональности.

Вы имитируете мои данные???
class MockDataLoader: NetworkLoader {

    let data: Data?
    let response: URLResponse?
    let error: Error?

    init(data: Data?, response: URLResponse?, error: Error?) {
        self.data = data
        self.response = response
        self.error = error
    }

private(set) var request: URLRequest?

func loadData(using request: URLRequest, with completion: @escaping (Data?, URLResponse?, Error?) -> Void) {

//принудительно придает асинхронность, помещая завершение в фоновый поток

           DispatchQueue.main.asyncAfter(deadline: .now() + 0.005) {
               self.request = request //используется для тестирования после завершения процесса
               completion(self.data, self.response, self.error)
        }
    }
}


Вызов помещается в фоновый поток и становится асинхронным

И давайте быстро создадим немного имитационных данных JSON для декодирования через JSONDecoder:

С помощью “”” мы получаем многострочную строку, в которой можем писать специальные символы без экранирования! Мы превращаем эту строку в данные, тщательно следя за тем, чтобы JSON был правильно отформатирован.
let data = """
{
    "money":"1,000"
}
""".data(using: .utf8)!

Теперь давайте напишем тест, который загружает эти данные, декодирует значение “money” (“1000”) и проверяет, можем ли мы преобразовать его в Int.

Помните: чтобы написать асинхронный модульный тест, нужно использовать ожидаемый результат, иначе тест не будет работать… даже если тест сообщит, что прошёл успешно, асинхронная часть на самом деле не будет выполнена.

func testDecodingMockData() { 
   
    let expectation = self.expectation(description: "\(#file), \(#function): WaitForDecodingMockData")    
    //используем константу данных, которую только что создали

    let mockDataLoader = MockDataLoader(
        data: data,
        response: nil,
        error: nil
    )    
    let request = URLRequest(url: URL(string: "www.google.com")!)
    
    mockDataLoader.loadData(using: request) { (data, nil, error) in
        XCTAssertNotNil(mockDataLoader.data)        
        XCTAssertNil(mockDataLoader.response)
        XCTAssertNil(mockDataLoader.error)
        let decoder = JSONDecoder()        
        let dictionary = try? decoder.decode([String:String].self, from:     mockDataLoader.data!)
        let money = dictionary?["money"]
        XCTAssertNotNil(Int(money!))
        expectation.fulfill()
    }
wait(for: [expectation], timeout: 1.0)
}

И вот теперь мы написали согласованный тест, имитирующий состояние нашего приложения и доказывающий, что мы можем преобразовать строку в Int. Тест выполняется асинхронно, поэтому можно проверить, что другие функции сработают после завершения этой.

Как уже упоминалось, такой тест также можно применить для расширения URLSession. Таким образом, можно создать класс с этим свойством и использовать его для переключения между настоящими и имитационными данными.

Класс с этим свойством и инициализатором будет использовать live URLSession по умолчанию, но позволит переопределить dataLoader с помощью MockDataLoader
class NetworkService {
    ///используется, чтобы переключаться между "живыми" и имитационными данными
    var dataLoader: NetworkLoader    init(dataLoader: NetworkLoader = URLSession.shared) {
        self.dataLoader = dataLoader
    }
}

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи: Kenny Dubroff: Unit Testing With Mock Network Calls