Testing networking logic.
Bài đăng này đã không được cập nhật trong 4 năm
-
Khi tham khảo các
topic
vềunit testing
hayautomated test
ta thường bắt gặp các thuật ngữ rất phổ biến như vềtestable code
nhưsynchronous
,predicable
hay luôn trả raoutput
giống nhau cho cácinput
khác nhau. Tuy nhiên trong thực tế các đoạn code vềnet working
mà chúng ta hay gặp lại đi ngược với các thuật ngữ chúng ta gặp trong các topic. -
Nguyên nhân khiến chúng ta gặp khó khăn khi test code
net working
lànet working
vốn dĩ là một công việc không đồng bộ và phụ thuộc nhiều vào các yếu tố bên ngoài như làinternet connection
,server
, các loại hệ thống vận hành khác nhau. Các yếu tố trên đều ảnh hưởng trực tiếp đến việcperforming
,loading
,decoding
củanet work
request
. -
Ở bài viết này chúng ta sẽ tập trung vào cách
test
các đoạn codeasynchronous
bằng cách sử dụngAPI
Foundation
:
1/ Verifying request generation logic:
-
Khi bắt đầu triển khai một công việc
test
mới chúng ta nên bắt đầu ngược bằng cách kiểm tra độ chính xác của cáclogic
cơ bản nhất trước khi chuyển sangtest
cácAPI
. -
Chúng ta sẽ cùng khơi tạo các
URLRequest
từEndpoint
với một số điều kiện kèm theo .Để việc test có tiêu chuẩn chung chúng ta cùng tạoEndpointKind
mà không hề tùy chỉnh:
extension EndpointKinds {
enum Stub: EndpointKind {
static func prepare(_ request: inout URLRequest,
with data: Void) {
// No-op
}
}
}
- Chúng ta đã có thể viết các
suite test
với đoạncode
bên trên trong đó chúng ra sẽ cần xác minh chính xác xácURLRequest
cho cácendpoint
cơ bản không cầnHTTP header
.
class EndpointTests: XCTestCase {
func testBasicRequestGeneration() {
let endpoint = Endpoint<EndpointKinds.Stub, String>(path: "path")
let request = endpoint.makeRequest(with: ())
XCTAssertEqual(request?.url, URL(string: "https://api.myapp.com/path"))
}
}
-
Chúng ta sẽ cần cải thiện thêm
test case
bên trên , chúng ta có thể nhận được kết quả sai vì cả trường hợpURLRequest
chúng ta khởi tạo cũng nhưURL
cho sẵn đều có thể bằngnil
(đây là điều rất khó xảy ra nhưng khi viếttest case
chúng ta không nên bỏ sót bất kì trường hợp nào không chắc chắn). -
Tiếp theo chúng ta đang giả định rằng
host
sẽ luôn làapi.myapp.com
, điều này đi được lại với thực tế là cácapp
hiện tại đều hỗ trợ chúng ta sử dụng nhiềunet working enviroment
nhưstaging
hayproduction
, hơn thế nữa là cácapp
còn có một vàiserver
với cáchost
cóaddress
khác nhau. -
Chúng ta sẽ gặp vấn đề đầu tiên khi sử dụng
XCTUnwrap
để kiểm tra cácrequest
được khởi tạo không bằngnil
. Chúng ta nên tạotypealias
cho cácEndpoint
đặc biệt:
class EndpointTests: XCTestCase {
typealias StubbedEndpoint = Endpoint<EndpointKinds.Stub, String>
func testBasicRequestGeneration() throws {
let endpoint = StubbedEndpoint(path: "path")
let request = try XCTUnwrap(endpoint.makeRequest(with: ()))
XCTAssertEqual(request.url, URL(string: "https://api.myapp.com/path"))
}
}
- Để có thể kiểm soát chính xác
host
hoạt động thế nào chúng ta cùng sử dụngURLHost
chuyên dụng để có thể bao bọc một chuỗiString
đơn gian:
struct URLHost: RawRepresentable {
var rawValue: String
}
- Lợi ích của việc sử dụng
URLHost
với cáctype
riêng biệt là chúng ta có thể đóng gói các biến thể phổ biến bằng cách sử dụng cácproperty
tĩnh trongenum
như cách tạoproperty
chohost
nhưstaging
vàproduction
cũng nhưdefault
để tự động xử lý cho các trường hợpapp
chạy trongDEBUG
mode
:
extension URLHost {
static var staging: Self {
URLHost(rawValue: "staging.api.myapp.com")
}
static var production: Self {
URLHost(rawValue: "api.myapp.com")
}
static var `default`: Self {
#if DEBUG
return staging
#else
return production
#endif
}
}
- Chúng ta cần
update
thêmEndpoint
cácmethod
khởi tạoURLRequest
bằng cáchenabling
URLHost
L:
extension Endpoint {
func makeRequest(with data: Kind.RequestData,
host: URLHost = .default) -> URLRequest? {
var components = URLComponents()
components.scheme = "https"
components.host = host.rawValue
components.path = "/" + path
components.queryItems = queryItems.isEmpty ? nil : queryItems
guard let url = components.url else {
return nil
}
var request = URLRequest(url: url)
Kind.prepare(&request, with: data)
return request
}
}
- Để tránh việc phải xử lý thủ công cho mỗi
URL
chúng ta sử dụng chúng ta cần cải thiệnURLHost
để sễ dàng khởi tạo cácURL
đặc biệt với cácpath
như sau:
extension URLHost {
func expectedURL(withPath path: String) throws -> URL {
let url = URL(string: "https://" + rawValue + "/" + path)
return try XCTUnwrap(url)
}
}
- Đoạn
code
trên không chỉ giúp việctest
của chúng ta trở nên chặt chẽ, chính xác cũng như linh động hơn. Chúng ta bây giờ hoàn toàn có thể tạo cáctest case
dễ đọc và dễ kiểm thử hơn.
class EndpointTests: XCTestCase {
typealias StubbedEndpoint = Endpoint<EndpointKinds.Stub, String>
let host = URLHost(rawValue: "test")
func testBasicRequestGeneration() throws {
let endpoint = StubbedEndpoint(path: "path")
let request = endpoint.makeRequest(with: (), host: host)
try XCTAssertEqual(
request?.url,
host.expectedURL(withPath: "path")
)
}
}
- CHúng ta đã tốn kha khá
effort
để viết các tùy chỉnh cho các API để cải thiện chocode base
chúng ta có thể mạnh mẽ cũng như linh hoạt hơn. Chúng ta có thể tạo thêm nhiềutest case
để kiểm tra các loạiEndpoint
một cách nhanh chóng.
...
func testGeneratingRequestWithQueryItems() throws {
let endpoint = StubbedEndpoint(path: "path", queryItems: [
URLQueryItem(name: "a", value: "1"),
URLQueryItem(name: "b", value: "2")
])
let request = endpoint.makeRequest(with: (), host: host)
try XCTAssertEqual(
request?.url,
host.expectedURL(withPath: "path?a=1&b=2")
)
}
}
- Chúng ta nên tạo thêm một số
test case
kiêm tra cácendpoint
thực để đảm bảocode test
hoạt động đúng. Trong trường hợp chúng ta gặpendpoint
yêu cầu sử dụngauthentication
thì chúng ta sẽ cần thêmheader
Authorization
khi khởi tạorequest
:
class EndpointTests: XCTestCase {
...
func testAddingAccessTokenToPrivateEndpoint() throws {
let endpoint = Endpoint.search(for: "query")
let token = AccessToken(rawValue: "12345")
let request = endpoint.makeRequest(with: token, host: host)
try XCTAssertEqual(
request?.url,
host.expectedURL(withPath: "search?q=query")
)
XCTAssertEqual(request?.allHTTPHeaderFields, [
"Authorization": "Bearer 12345"
])
}
}
2/ Using integration tests:
- Chúng ta hiện đang sử dụng
URLSession
API
trongFoundation
kết hợp với một vàioperator
củaCombine
để xây dựng corenet working
code:
extension URLSession {
func publisher<K, R>(
for endpoint: Endpoint<K, R>,
using requestData: K.RequestData,
decoder: JSONDecoder = .init()
) -> AnyPublisher<R, Error> {
guard let request = endpoint.makeRequest(with: requestData) else {
return Fail(
error: InvalidEndpointError(endpoint: endpoint)
).eraseToAnyPublisher()
}
return dataTaskPublisher(for: request)
.map(\.data)
.decode(type: NetworkResponse<R>.self, decoder: decoder)
.map(\.result)
.eraseToAnyPublisher()
}
}
-
Kết quả
successfully
của đoạncode
trên không phải là kết quả chúng ta nên quá quan tâm vì chúng ta sử dụng một loạt cácAPI
của hệ thống đã được tùy chỉnh, kiểm soát theo một số cách thức đặc biệt khiến các kết quảtest
đạt được thường bị thu hẹp và dễ đoán biết. -
Protocol
là một phương pháp cải tiện kết quảtest
không chỉ trong trường hợp trên mà còn cho nhiều trường hợp khác mà chúng ta không phải tham gia tùy chỉnh, kiểm soát cácAPI
. Đây là một phương pháp hữu dụng và phổ biến nên ở bài viết này chúng ta sẽ cùng ưu tiên cho cách thức khác: -
Trong
Swift
ta thấyURLSession
sử dụngURLProtcol
để tiến hành các tác vụnetwork
, hệ thốngApple
đã cung cấp cũng như hỗ trợ hoàn chỉnh để chúng ta có thể tùy chỉnh bằng việc sử dụng cácclass
. Điều đó có nghĩa là chúng ta có thể tự tùy biến một hệ thốngHTTP Net working stack
mà không phải sửa đổi cácoperator Combine
tùy biến trước đó. -
Nhược điểm của
URLProtcol
từ quan điểm cá nhân tôi là nó chủ yếu dựa vào cácstatic method
đồng nghĩa chúng ta sẽ phảiimplement
cácmock
của chúng ta riêng biệt. Cách khắc phục tạm thời là sử dụng thêm protocolMockURLResponser
để cho phép chúng ta tạo cácmock server
trả vềData
hoặcError
cần thiết:
protocol MockURLResponder {
static func respond(to request: URLRequest) throws -> Data
}
- Điều tiếp theo chúng ta cần triển khai là tùy chỉnh thêm
URLProtcol
với việcoverride
cácmethod
như sau:
class MockURLProtocol<Responder: MockURLResponder>: URLProtocol {
override class func canInit(with request: URLRequest) -> Bool {
true
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override func startLoading() {
guard let client = client else { return }
do {
// Here we try to get data from our responder type, and
// we then send that data, as well as a HTTP response,
// to our client. If any of those operations fail,
// we send an error instead:
let data = try Responder.respond(to: request)
let response = try XCTUnwrap(HTTPURLResponse(
url: XCTUnwrap(request.url),
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: nil
))
client.urlProtocol(self,
didReceive: response,
cacheStoragePolicy: .notAllowed
)
client.urlProtocol(self, didLoad: data)
} catch {
client.urlProtocol(self, didFailWithError: error)
}
client.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {
// Required method, implement as a no-op.
}
}
- Chúng ta cần
URLSession
sử dụng cácmock protocol
hơn là sử dụng hệ thốngHTTP
mặc định. Để thực hiện điều đó chúng ta chỉ cần choURLSession
sử dụngMockURLProtocol
nhưng đừng quên khai báo khởi tạo cho chínhURLProtcol
:
extension URLSession {
convenience init<T: MockURLResponder>(mockResponder: T.Type) {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol<T>.self]
self.init(configuration: config)
URLProtocol.registerClass(MockURLProtocol<T>.self)
}
- Với những đoạn
code
trên chúng ta giờ đã có thể cố địnhMockURLResponder
protocol
, để ví dụ chúng ta sẽ tham khảo việcencode
item
sau:
extension Item {
enum MockDataURLResponder: MockURLResponder {
static let item = Item(title: "Title", description: "Description")
static func respond(to request: URLRequest) throws -> Data {
let response = NetworkResponse(result: item)
return try JSONEncoder().encode(response)
}
}
}
- Chúng ta nên cân nhắc việc sử dụng chế đố
synchronous
màCombine
giới thiệu với việc sử dụngXCtTest
để xây dựng các logicsynchronous
hơn là sử dụngGrand Central Dispatch semaphore
:
extension XCTestCase {
func awaitCompletion<T: Publisher>(
of publisher: T,
timeout: TimeInterval = 10
) throws -> [T.Output] {
// An expectation lets us await the result of an asynchronous
// operation in a synchronous manner:
let expectation = self.expectation(
description: "Awaiting publisher completion"
)
var completion: Subscribers.Completion<T.Failure>?
var output = [T.Output]()
let cancellable = publisher.sink {
completion = $0
expectation.fulfill()
} receiveValue: {
output.append($0)
}
// Our test execution will stop at this point until our
// expectation has been fulfilled, or until the given timeout
// interval has elapsed:
waitForExpectations(timeout: timeout)
switch completion {
case .failure(let error):
throw error
case .finished:
return output
case nil:
// If we enter this code path, then our test has
// already been marked as failing, since our
// expectation was never fullfilled.
cancellable.cancel()
return []
}
}
}
- Giống công việc
test
Endpoint
của hệ thống chúng ta đã sử dụng nhiềueffort
để xây dựng các công cụ hữu hiệu để có thể triển khai việctest
với các dòng code ngắn gọn mạch lạc hơn. Chúng ta sẽ cùng tiến hànhtest
công việcsuccessfully load
cũng nhưdecode
củarequest
:
class NetworkIntegrationTests: XCTestCase {
func testSuccessfullyPerformingRequest() throws {
let session = URLSession(mockResponder: Item.MockDataURLResponder.self)
let accessToken = AccessToken(rawValue: "12345")
let publisher = session.publisher(for: .latestItem, using: accessToken)
let result = try awaitCompletion(of: publisher)
XCTAssertEqual(result, [Item.MockDataURLResponder.item])
}
}
- YEAHH!!!, Điều cuối cùng chúng ta cần quan tâm là chúng ta có thể xác nhận lại các phương thức
networking
hoạt động đúng mong đợi khi xảy ra các lỗi:
enum MockErrorURLResponder: MockURLResponder {
static func respond(to request: URLRequest) throws -> Data {
throw URLError(.badServerResponse)
}
}
class NetworkIntegrationTests: XCTestCase {
...
func testFailingWhenEncounteringError() throws {
let session = URLSession(mockResponder: MockErrorURLResponder.self)
let accessToken = AccessToken(rawValue: "12345")
let publisher = session.publisher(for: .latestItem, using: accessToken)
XCTAssertThrowsError(try awaitCompletion(of: publisher))
}
}
All rights reserved