Test unitaires avec Async Await – comment rendre votre client HTTP indépendant de l’infrastructure? (Partie 1)
Introduction:
Dans cette article, qui constitue une continuation de mon dernier article sur l’injection et l’inversion de dépendances à base de async await, j’essayerai de vous présenter les méthodes qui existent pour faire des tests unitaires de valeurs à base de async await et comment ensuite rendre nos tests indépendamment de async await ou d’autres API réseau que notre clientHTTP peut l’implémenter.
Approches pour tester un client HTTP
En effet, ils existent trois approches pour effectuer des tests unitaires d’un client HTTP:
- Mocking à travers l’héritage
- Mocking à base des protocoles
- URL Loading system
Rappelons tout d’abord notre code de production
enum HTTPClientResult {
case success(Data, HTTPURLResponse)
case failure(Error)
}
protocol HTTPClient {
func get(from url: URL) async throws -> HTTPClientResult
}
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())
}
}
}
Notre URLSessionHTTPClient constitue une implémentation à base de URLSession de notre HTTPClient afin de donner la possibilité de changer notre clientHTTP en respectant toujours le même contrat avec la couche supérieur qui va utiliser notre client HTTP (on respecte l’inversion de dépendances ici). Cependant vous remarquez surement ici que notre protocole est devenu dépendant de async await. On est dans l’obligation ajouter async throws dans la déclaration de notre méthode get(from url). Alors, on respecte pas un autre principe qui est l’OCP (open close principle) vu qui si nous voulons par exemple retourner à utiliser les completions handlers, nous devons toucher à notre protocole et casser un code qui marche et tout nos tests. Mais continuons de cette manière, je vais vous montrer dans la deuxième partie une technique qui permet de remettre de l’ordre aux deux principes OCP et DI 🙂 .
Tout d’abord, la question qui se pose on veut tester quoi ?. On veut tester le comportement de notre méthode get(From url) par rapport aux différents résultats possibles de la communication avec un serveur distant. Je peux imaginer plusieurs tests unitaires qui permettent par exemple de vérifier :
- Si ma méthode get(from url: URL) throw une erreur au moment que session.data(from: url) déclenche une exception.
- Si ma méthode get(from url: URL) me retournera un success dans le cas ou session.data(from: url) retourne une data plus une URLResponse avec un status code == 200.
- etc
Il y a plusieurs cas de tests qu’ils faut penser à coder, je me limiterai à ces cas.
Pour réaliser des tests unitaires correctes, on doit pas lancer la méthode session.data(from: url) car on aura un appel réel à ce moment et on parlera plutôt d’un test end to end. D’autre part, imaginons que notre back n’est prêt jusqu’à maintenant mais seulement on a un contrat d’interface avec le payload de retour back. Il est recommandé de créer une méthode double (mocker, stuber…).
PS: Afin de comprendre la différence entre les différents types de Doubles, je vous laisse avec cet article de Martin Fowler
public func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse)
Mocking à travers l’héritage
Tester URLSession version Async Await à travers la technique de redéfinition ne peut pas s’effectuer pour la simple raison que la méthode data(from url, delegate: URLSessionTaskDelegate? = nil) n’est open 🙁 alors on peut la redéfinir
Le compilateur ne permet de redéfinir une fonction ou méthode non open
Cette méthode reste possible si on veut par exemple utiliser la version non async await de URLSession par exemple ici:
private class URLSessionSpy: URLSession {
private static var stub: Stub?
private struct Stub {
let error: NSError?
let data: Data?
let response: URLResponse?
}
static func stub(error: NSError? = nil, data: Data? = nil, response: URLResponse? = nil) {
stub = Stub(error: error,data: data,response: response)
}
override func dataTask(with url: URL, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
/// Je peux ici faire le stub d'une erreur et ma méthode retournera la méme erreur que je passerai dans mon test plus un fake de URLSessionDataTask
if let error = URLSessionStub.stub?.error {
completionHandler(nil,nil,error)
}
return URLSessionDataTaskFake()
}
private class URLSessionDataTaskFake: URLSessionDataTask {}
}
Dans mon test je peux faire écrire ce scénario
func testGetFromURLShouldThrowErrorOnRequestError() {
let url = URL(string: "http://anyURL.com")!
let error = NSError(domain: "any error", code: 1)
let sessionSpy = URLSessionSpy()
URLSessionSpy.stub(error: error, data: nil, response: nil)
let sut = URLSessionHTTPClient(session: sessionSpy)
let exp = expectation(description: "waiting for error")
sut.get(from: url) { result in
switch result {
case let .failure(receivedError as NSError):
XCTAssertEqual(receivedError , error)
default: break
}
exp.fulfill()
}
wait(for: [exp], timeout: 1.0)
}
PS: il faut bien sur à ce moment modifier la méthode get(from url) pour ne pas être en forme async await et retourner un completion handler de cette manière
protocol HTTPClient {
func get(from url: URL, completion: @escaping (HTTPClientResult) -> Void)
}
Vous remarquez surement l’impact de choix de async await ou non sur nos tests et notre code de production et qu’un passage à cette technologie peut casser notre code de production ainsi que nos tests qui sont couplés avec notre code de production.
Mocking à base de protocole
Le mocking à base de protocole nous permet d’écrire nos tests unitaires à base de async await. Dans le dernier article, on a parlé de cette technique qui permet d’inverser la dépendance à async await en créant un URLSessionProtocol qui contiendra la même signature de méthode data(from url) de API Async Await
Au niveau de notre code de production on aura
protocol URLSessionProtocol {
func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse)
}
/// étendre notre protocole pour fournir une implémentation par défaut de ou on fait appel à la méthode data(from url) de Async Await
extension URLSessionProtocol {
func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse) {
try await data(from: url, delegate: nil)
}
/// enfin étendre URLSession pour se confirmer au protocole
/// URLSessionProtocol
extension URLSession: URLSessionProtocol {}
Au niveau de code test, on aura tout d’abord un URLSessionSpy qui respectera le protocole URLSessionProtocole:
private class URLSessionSpy: URLSessionProtocol {
private static var stub: Stub?
private struct Stub {
let error: NSError?
let data: Data?
let response: URLResponse?
}
static func stub(error: NSError? = nil, data: Data? = nil, response: URLResponse? = nil) {
stub = Stub(error: error,data: data,response: response)
}
func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse) {
if let error = URLSessionSpy.stub.error {
throw error
}
}
reformulons notre test un peu maintenant:
func testGetFromURLShouldThrowErrorOnRequestError() async throws {
let url = URL(string: "http://anyURL.com")!
let error = NSError(domain: "any error", code: 1)
URLProtocolStub.stub(error: error, data: nil, response: nil)
let sessionSpy = URLSessionSpy()
let sut = URLSessionHTTPClient(session: sessionSpy)
do {
_ = try await sut.get(from: url)
} catch let receivedError as NSError {
XCTAssertEqual(receivedError , error)
}
}
Cette technique nous permet de tester node code de production à base de Async Await sans redéfinir la méthode data(from url) (rappelons qu’on peut pas la redéfinir vu qu’elle n’est pas open). Cependant, cette technique constitue une contrainte pour notre code de production qui pour la seule raison de faire des tests, on doit lui ajouter un protocole couplé avec la technologie async await.
Conclusion
Tester le comportement de la couche service ou client HTTP est primordiale si on veut s’assurer qu’on a rien cassé au niveau de notre code de production. Cependant, avec Async Await malgré qu’elle simplifie énormément l’écriture de notre code asynchrone, elle peut engendré une réfactorisation assez consistante en tests unitaires afin qu’ils passent. Dans la deuxième partie de cet article, je vous présenterai un troisième technique que je pense peut rendre notre code de production et de tests indépendants de Async Await qui sont URL Loading system et la Continuation.
Références:
URLSession
reference https://developer.apple.com/documentation/foundation/urlsession- Testing Tips & Tricks (WWDC 2018 – Session 417) https://developer.apple.com/videos/play/wwdc2018/417
- Modern Concurrency in Swift By Marin Todorov