You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Swift Combine中Mock URLSession的dataTaskPublisher方法最佳方案

解决方案:Mock URLSession 测试 APIClient.dispatch 方法

核心思路

由于URLSession.DataTaskPublisher是结构体无法直接重写,我们可以通过协议抽象的方式,将URLSession的依赖抽象为协议,让APIClient依赖该协议而非具体的URLSession类,从而实现轻松Mock。

步骤1:定义协议抽象核心方法

创建一个仅包含dataTaskPublisher(for:)方法的协议,让系统URLSession自动遵守该协议,原有业务代码无需改动:

protocol URLSessionProtocol {
    func dataTaskPublisher(for request: URLRequest) -> URLSession.DataTaskPublisher
}

extension URLSession: URLSessionProtocol {}

步骤2:修改APIClient的依赖类型

将APIClient中urlSession属性的类型改为上述协议,同时保留默认初始化逻辑:

class APIClient {
    let urlSession: URLSessionProtocol
    
    init(urlSession: URLSessionProtocol = URLSession.shared) {
        self.urlSession = urlSession
    }
    
    typealias ResponsePublisher<T> = AnyPublisher<T, APIRequestError>
    
    // 原dispatch方法完全保留
    func dispatch<T: Decodable>(request: URLRequest) -> ResponsePublisher<T> {
        urlSession
            .dataTaskPublisher(for: request)
            .receive(on: DispatchQueue.global(qos: .background))
            .tryMap { data, response in
                if let response: HTTPURLResponse = response as? HTTPURLResponse,
                   !(200...299).contains(response.statusCode) {
                    throw APIClient.httpError(response.statusCode)
                }
                
                return data
            }
            .decode(type: T.self, decoder: JSONDecoder())
            .mapError { APIClient.handleNonHTTPError($0) }
            .eraseToAnyPublisher()
    }
    
    // 保留原有错误处理方法
    static func httpError(_ statusCode: Int) -> APIRequestError {
        // 你的错误实现逻辑
    }
    
    static func handleNonHTTPError(_ error: Error) -> APIRequestError {
        // 你的错误转换逻辑
    }
}

// 假设已定义的错误枚举
enum APIRequestError: Error {
    case httpStatus(Int)
    case decodingError
    case networkError(Error)
}

步骤3:编写Mock URLSession实现

创建遵守URLSessionProtocol的Mock类,通过配置模拟的data、response、error来控制返回结果:

class MockURLSession: URLSessionProtocol {
    // 可配置的模拟参数
    var mockData: Data?
    var mockResponse: URLResponse?
    var mockError: Error?
    
    func dataTaskPublisher(for request: URLRequest) -> URLSession.DataTaskPublisher {
        // 利用DataTaskPublisher的初始化方法,直接传入模拟值
        return URLSession.DataTaskPublisher(
            request: request,
            session: URLSession.shared,
            data: mockData,
            response: mockResponse,
            error: mockError
        )
    }
}

步骤4:编写单元测试用例

针对不同场景(成功、HTTP错误、解码错误等)编写测试:

测试成功场景

import XCTest
import Combine

class APIClientTests: XCTestCase {
    var cancellables = Set<AnyCancellable>()
    
    func testDispatch_SuccessResponse() {
        // 1. 准备测试数据
        let mockData = #"{"id":1,"name":"TestItem"}"#.data(using: .utf8)!
        let mockResponse = HTTPURLResponse(
            url: URL(string: "https://test.com")!,
            statusCode: 200,
            httpVersion: nil,
            headerFields: nil
        )!
        
        // 2. 配置MockSession
        let mockSession = MockURLSession()
        mockSession.mockData = mockData
        mockSession.mockResponse = mockResponse
        
        // 3. 初始化APIClient并发起请求
        let apiClient = APIClient(urlSession: mockSession)
        let expectation = self.expectation(description: "Receive success response")
        let testRequest = URLRequest(url: URL(string: "https://test.com")!)
        
        apiClient.dispatch(request: testRequest)
            .sink(receiveCompletion: { completion in
                if case .failure(let error) = completion {
                    XCTFail("Expected success, got error: \(error)")
                }
                expectation.fulfill()
            }, receiveValue: { (model: TestModel) in
                XCTAssertEqual(model.id, 1)
                XCTAssertEqual(model.name, "TestItem")
            })
            .store(in: &cancellables)
        
        waitForExpectations(timeout: 1)
    }
    
    // 测试用Model
    struct TestModel: Decodable {
        let id: Int
        let name: String
    }
}

测试HTTP错误场景

func testDispatch_HTTPError() {
    let mockResponse = HTTPURLResponse(
        url: URL(string: "https://test.com")!,
        statusCode: 404,
        httpVersion: nil,
        headerFields: nil
    )!
    
    let mockSession = MockURLSession()
    mockSession.mockData = Data()
    mockSession.mockResponse = mockResponse
    
    let apiClient = APIClient(urlSession: mockSession)
    let expectation = self.expectation(description: "Receive HTTP error")
    let testRequest = URLRequest(url: URL(string: "https://test.com")!)
    
    apiClient.dispatch(request: testRequest)
        .sink(receiveCompletion: { completion in
            if case .failure(let error) = completion {
                XCTAssertEqual(error, APIRequestError.httpStatus(404))
            } else {
                XCTFail("Expected HTTP error, got success")
            }
            expectation.fulfill()
        }, receiveValue: { _ in
            XCTFail("Should not receive value on HTTP error")
        })
        .store(in: &cancellables)
    
    waitForExpectations(timeout: 1)
}

测试解码错误场景

func testDispatch_DecodingError() {
    // 传入不符合Model格式的数据
    let mockData = #"{"invalidKey":"value"}"#.data(using: .utf8)!
    let mockResponse = HTTPURLResponse(
        url: URL(string: "https://test.com")!,
        statusCode: 200,
        httpVersion: nil,
        headerFields: nil
    )!
    
    let mockSession = MockURLSession()
    mockSession.mockData = mockData
    mockSession.mockResponse = mockResponse
    
    let apiClient = APIClient(urlSession: mockSession)
    let expectation = self.expectation(description: "Receive decoding error")
    let testRequest = URLRequest(url: URL(string: "https://test.com")!)
    
    apiClient.dispatch(request: testRequest)
        .sink(receiveCompletion: { completion in
            if case .failure(let error) = completion {
                XCTAssertEqual(error, APIRequestError.decodingError)
            } else {
                XCTFail("Expected decoding error, got success")
            }
            expectation.fulfill()
        }, receiveValue: { _ in
            XCTFail("Should not receive value on decoding error")
        })
        .store(in: &cancellables)
    
    waitForExpectations(timeout: 1)
}

额外建议

  • 可扩展MockURLSession,支持按不同Request返回不同模拟数据,提升测试灵活性
  • 注意receive(on:)带来的线程切换,确保测试用expectation正确等待异步操作完成
  • 覆盖所有错误转换分支,保证错误处理逻辑的正确性

内容的提问来源于stack exchange,提问作者MattCodes

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.21 14:45:14