Dans les deux premiers articles concernant async await et les tests unitaires, nous avons remarqué que la nouvelle API transforme d’une manière assez radicale la façon de tester (pas d’expectation, il faut ajouter async throws au niveau de chaque méthode de test…). Notre manière de tester ou d’écrire notre code de production change et on devient assez couplé avec la nouvelle concurrency API. Notre objectif, était de trouver une solution qui découple notre code de production de celle de test. Si jamais un jour, on veut s’en passer de async await vers une solution notre de tests ne serra pas cassé. Dans le dernier article, nous avons présenté la première partie de notre solution qui est l’utilisation de Loading system de Apple (appelé aussi URLProtocol). Dans cette dernière partie, je vais parler du Continuation et comment elle peut nous aider pour réaliser notre objectif.

Continuation

La continuation est un objet qui track l’état d’un program à partir d’un point précis. C’est tout simplement une autre présentation d’un thread. On peut dire que le système derrière crée pour chaque appel asynchrone une continuation au lieu d’un thread qui constitue ensuite le point de reprise lorsque le traitement asynchrone se termine.

Prenons par exemple ce code asynchrone à base de async await:

func HelloAsync() async throws {
    try await Task.sleep(nanoseconds: 2_000_000)
    print("Hello Async Await")
}
// Appel de la méthode 
await try HelloAsync()

Lorsque le code asynchrone se bloque pendant 2 second du à l’appel du Task.sleep, le système crée derrière une continuation qui capture l’état du système à ce moment précise. Après deux secondes, le système utilisera la continuation comme point de reprise correcte de l’état afin de continuer l’exécution (c’est pour cela on l’appel continuation).

L’utilité des continuation ne se limite pas seulement à ça, mais elle constitue un bridge entre nos anciens code écrit en utilisant les completions handler ou les delegates et API Async await et c’est pour cela qu’on va l’exploiter :).

Il y a deux types de continuations:

  • CheckedContinuation: mécanisme qui permet la reprise d’un code asynchrone tout en vérifiant que tout se passe bien en runtime.
  • UnCheckedContinuation: même mécanisme mais sans la vérification.

Pour plus de détails sur comment fonctionne les deux types de continuation vous pouvez regarder cette vidéo au WWDC 2021.

Une continuation permet de wrapper l’appel d’une closure et de retourner le résultat de cette dernière en utilisant certaines méthodes:

  • resume(returning:): resume le code asynchrone en cours (closure) et retourne la valeur de même type retourné par la closure.
  • resume(throwing:): resume le code asynchrone en cours (closure) et throw erreur.
  • resume(with:): resumes avec un Result<T?,Error?>, qui retourne une possible valeur ou une potentielle erreur.
  • resume(): ne retourne rien si la closure ne retourne rien.

Application sur un cas réel:

Reprenons notre exemple du URLSession en utilisant Async await. Notre problème que notre code de production et totalement couplé avec async await même en utilisant la technique d’inversion de dépendances citée dans le premier article de cette série

class URLSessionHTTPClient: HTTPClient {

func get(from url: URL) async throws -> HTTPClientResult {
            let (data, response) = try await session.data(from: url)
            if let response = response as?  HTTPURLResponse, response.statusCode == 200 {
                return .success(data, response)
            } else {
                return .failure(UnexpectedValuesRepresentation())
            }
       }
}

L’idée de la continuation comme cité dans ce très bon article de Paul Hudson est de cohabiter async await avec notre ancien code qui n’est pas à la base mais comment le faire?

On va reprendre notre code à base de URLSession avant async await et on va faire appel à notre méthode à base de complétion handler dans une méthode qui utilise la continuation et les différents API qui découlent derrière:

func get(from url: URL, completion: @escaping (HTTPClientResult) -> Void ) {
        session.dataTask(with: url) { data, response, error in
            if let error = error {
                completion(.failure(error))
                return
            }
            if let data = data, let response = response as?  HTTPURLResponse, response.statusCode == 200 {
                return completion(.success(data, response))
            } else {
                return completion(.failure(UnexpectedValuesRepresentation()))
            }
        }.resume()
    }
    
    func get(from url: URL) async throws -> HTTPClientResult {
       try await withCheckedThrowingContinuation { continuation in
            get(from: url) { result in
                switch result {
                case let .failure(error):
                    continuation.resume(throwing: error)
                case .success:
                    continuation.resume(returning: result)
                }
            }
        }
    }

Le code de la première à base de closure (completion handler) me parait compréhensible et nécessite pas beaucoup de connaissance en swift. Cependant, vous remarquez que la deuxième méthode à base de continuation nécessite une explication plus profonde. Pour lancer une continuation, on commence par

try await withCheckedThrowingContinuation

Le checked throwing continuation indique que ce completion handler peut throw une erreur et que le système doit s’assurer que le resume doit se faire une et une seul fois. Ensuite, on appel notre méthode normal à base de completion handler. Selon le résultat retourné on va faire notre continuation.resume(…). Je rappelle qu’on a deux cas selon notre type énumère HTTPClientResult

enum HTTPClientResult {
    case success(Data, HTTPURLResponse)
    case failure(Error)
}

Dans notre code de test rien ne va changer, mais on va faire appel à la méthode async await à base de continuation par exemple de cette manière.

func testGetFromURLShouldReturnHTTPClientResultOnRequestSuccessed() async throws {
        let httpResponse = anyHTTPURLResponse()
        let dataResponse = anyData()
        
        do {
        let result = try await resultValuesFor(data: dataResponse, response: httpResponse, error: nil)
            XCTAssertEqual(result?.response.statusCode, httpResponse.statusCode)
            XCTAssertEqual(result?.data, dataResponse)
        } catch {}
}

// MARK Helpers

private func anyHTTPURLResponse() -> HTTPURLResponse {
   return HTTPURLResponse(url: anyURL(), statusCode: 200, httpVersion: nil, headerFields: nil)!
}
  
private func anyData() -> Data {
   return "any Data".data(using: .utf8)!
}
private func resultValuesFor(data: Data?, response: URLResponse?, error: Error?, file: StaticString = #file, line: UInt = #line) async throws -> (response: HTTPURLResponse, data: Data)? {
        URLProtocolStub.stub(error: error, data: data, response: response)
        var receivedValues: (HTTPURLResponse, Data)?
        do {
            let result = try await makeSUT(file:file,line: line).get(from: anyURL())
            switch result {
            case let .success(receivedData, receivedResponse):
                receivedValues = (receivedResponse, receivedData)
            default:
                XCTFail("Expected failure, got \(result)", file: file, line: line)
            }
        } catch {}
        return receivedValues
 }

Ce code de test permet de s’assurer que lorsque j’effectue un stub à base URLProtocol avec une bonne réponse de type HTTPURLResponse et une Data, on attend à avoir un success. Pour minimiser la taille du code de la méthode de test testGetFromURLShouldReturnHTTPClientResultOnRequestSuccessed(), j’ai opté pour refactoriser l’appel de mon SUT vers la méthode resultValuesFor(data…). Lorsqu’on attend une réponse de success, on retournera un tuple qui contiendra logiquement la data et le HTTPURLResponse. On récupère ces deux valeurs et on effectue ses deux assertions:

XCTAssertEqual(result?.response.statusCode, httpResponse.statusCode)
XCTAssertEqual(result?.data, dataResponse)

Je rappelle toujours qu’on peut pas comparer depuis iOS 14 deux HTTPURLResponse, c’est pour cela je me limite à comparer les status codes.

PS: vous remarquez que je passe deux paramètres  file: StaticString = #filePath, line: UInt= #line au niveau de l’entête de la méthode resultValuesFor(…) ainsi qu’au niveau de notre méthode factory makeSUT(file: StaticString = #file, line: UInt = #line) -> HTTPClient. Ils sont nécessaires pour indiquer le nom de fichier et l’emplacement de la ligne d’erreur vu que ce code n’existe pas dans le corps de la fonction de test mais dans notre méthode privé helper.

Pour notre système à base de Loading System ou URLProtocol, rien à changé dans le code.

private class URLProtocolStub: URLProtocol {
    private static var stub: Stub?
    private struct Stub {
        let error: Error?
        let data: Data?
        let response: URLResponse?
     }
        
     static func stub(error: Error? = nil, data: Data? = nil, response: URLResponse? = nil) {
         stub = Stub(error: error,data: data,response: response)
     }
        
     static func startInterceptingRequests() {
            URLProtocol.registerClass(URLProtocolStub.self)
     }
        
     static func stopInterceptingRequest() {
      URLProtocol.unregisterClass(URLProtocolStub.self)
      stub = nil
     }

     override class func canInit(with request: URLRequest) -> Bool {
        return true
     }
        
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }

    override func startLoading() {
        if let error = URLProtocolStub.stub?.error {
           client?.urlProtocol(self, didFailWithError:error)
        }
       if let response = URLProtocolStub.stub?.response {
         client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
       }
      if let data = URLProtocolStub.stub?.data {
         client?.urlProtocol(self, didLoad: data)
       }
        client?.urlProtocolDidFinishLoading(self)
        
    }
        
     override func stopLoading() {}

  }

Conclusion

Durant ces trois articles concernant async await et les tests unitaires, l’idée principale était de trouver un compromis entre l’utilisation de la nouvelle API Concurency sans casser notre code de production qui généralement sera à la base de completion handler ou de delegates. L’objectif du prochain article c’est d’appliquer ces mêmes idées avec un code à base des bibliothèques réactives Combine ou Moya.

A très bientôt

For each desired change, make the change easy (warning: this may be hard), then make the easy change

Références:

https://www.hackingwithswift.com/quick-start/concurrency/how-to-use-continuations-to-convert-completion-handlers-into-async-functions

https://www.swiftbysundell.com/articles/connecting-async-await-with-other-swift-code/

Modern Concurency in Swift (Marin Todorov)

Kent Beck

Leave A Comment

Solve : *
29 + 2 =