Swift Testing: How to Test Your iOS App’s Network Layer
Introduction
Testing is essential for building reliable software, and Swift offers powerful framework to simplify this process. If you’re new to Swift Testing or just need a refresher, I recommend starting with my previous post, “Explore Swift Testing”. In that post, I wrote about the Swift Testing framework, which offers big improvements over traditional XCTest. With features like easy test case handling, parallel execution support, and cleaner syntax, Swift Testing makes it easier to write reliable and maintainable tests.
We’ll build on those concepts by applying them to a real-world scenario: testing a network layer package for an iOS app that i developed.
Why Testing Our Network Layer is Important?
We often need to get information from the internet. This part of the app is called the network layer. It’s very important to make sure this part works well. If it doesn’t, our app might crash or show wrong information.
In this article, we’ll explore how to effectively write tests for network requests and responses and how to ensure that the network layer handles different scenarios reliably.
Here’s what we’ll learn:
- What is Swift Package Manager and why it’s useful
- How to use a package I made for network manager
- How to create a good way to manage network requests
- How to pretend we’re talking to the internet (without really doing it)
- How to write tests using Apple’s new testing framework, Swift Testing
This is a learning process for me. Feel free to provide feedback or suggestions to improve the blog further! Here is Feyyaz’s blog, welcome!
What is Swift Package Manager?
The Swift Package Manager (SwiftPM) is a tool for managing the distribution of Swift code. It’s integrated with the Swift build system to automate the process of downloading, compiling, and linking dependencies.
💡 SPM simplifies dependency management in iOS projects. It makes it easier to add new features to our apps without writing everything ourselves.
👉 If you want to learn more about SPM, you can look at the official guide.
Introducing the Network Layer SPM Package
I recently developed a Swift Package called Network Layer to simplify the network layer in iOS apps. This package makes it easier to:
- Send network requests,
- Manage responses,
- Handle errors.
It allows you to easily integrate it into your apps and customize it according to your needs. If you want, you can create your own Swift Package too!
🚀 Try it out: Check out the GitHub repository to explore the code, open issues, and contribute improvements!
Let’s explore what’s inside this repository to understand its features!
Creating a Network Manager
The main part of our package is called NetworkManager. It works as a central hub for all network tasks.
🔑 Key Components of a Network Manager
- URL Session: The backbone of network operations in iOS, responsible for sending and receiving HTTP/HTTPS requests.
- Request Configuration: A system to easily configure and customize network requests, including headers, parameters, and HTTP methods.
- Response Parsing: Decode server responses into Swift objects.
- Error Handling: A system to handle different network errors, from connection problems to server responses.
- Reusability: Uses generic request and response types, making the code flexible and reusable for different API endpoints.
💡 Benefits of a Custom Network Manager
- Centralized Control: Manage all network operations from one point, making it easier to implement changes across your app.
- Abstraction: Hide the complexities of networking from other parts of your app, allowing them to focus on their core responsibilities.
- Testability: Make it easier to unit test network operations by allowing the use of mock implementations.
- Scalability: Easily add new API endpoints or modify existing ones without affecting the rest of your app’s architecture.
Creating a network manager makes your current development process simpler and gets your app ready for future growth and changes in API needs.
➡️ Now, let’s examine the NetworkManager actor, focusing on its initialization and request functions.
NetworkManager
The NetworkManager
is defined as an actor
, which is a fundamental part of Swift's concurrency model.
public actor NetworkManager {
public static let shared = NetworkManager()
private let urlSession: URLSessionProtocol
private let decoder: JSONDecoder
init(urlSession: URLSessionProtocol = URLSession.shared, decoder: JSONDecoder = JSONDecoder()) {
self.urlSession = urlSession
self.decoder = decoder
}
public func request<T: Decodable>(_ endpoint: Endpoint, responseType: T.Type) async throws -> T {
guard let request = endpoint.urlRequest else {
throw NetworkError.invalidURL
}
do {
let (data, response) = try await urlSession.data(for: request)
try validate(response: response, data: data)
return try decode(data: data, to: responseType)
} catch let error as NetworkError {
throw error
}
}
}
❓ Why Use actor
?
- Thread Safety: Actors automatically manage access to their state, ensuring that only one task interacts with the NetworkManager at a time. This prevents data races when multiple network requests occur simultaneously.
- Simplicity: You don’t need to manage locks or worry about thread safety; the actor takes care of it for you.
- Concurrency: Integrate with Swift’s
async/await
model, allowing you asynchronous code without complexity.
💡 Key Takeaway: Actors in Swift provide a safe and efficient way to manage shared mutable state in concurrent environments, making them ideal for network operations.
👉 For a deeper understanding, read more about actors in the references section.
Initialization
init(urlSession: URLSessionProtocol = URLSession.shared, decoder: JSONDecoder = JSONDecoder()) {
self.urlSession = urlSession
self.decoder = decoder
}
- Dependency Injection: Passing
urlSession
into the initializer allows you to use a custom URLSession. - Default Value: Uses
URLSession.shared
if no custom session is provided.
URLSessionProtocol
public protocol URLSessionProtocol: Sendable {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: URLSessionProtocol {}
- Making it public allows other developers to implement this protocol if they need a custom URLSession for testing or extending functionality.
- By making
URLSessionProtocol
conform toSendable
, you tell Swift's concurrency system that thedata(for:)
method can be safely used in asynchronous contexts, even across different threads or queues. Many types in Swift, like arrays and strings, are also Sendable. This is important for preventing data races and ensuring safe behavior in concurrent programming.
❓ Why use a Protocol?
- You can create a more flexible and abstract way to handle your network requests.
- Implementing
URLSessionProtocol
allows you to easily create mockURLSession
for unit testing.
It’s time to write some tests! We’ll need a few things: mocked endpoints, responses, and of course, a mocked URLSession. Before, we discussed why we use protocols, and now it’s time to put that into action. We’ll mock URLSession using URLSessionProtocol to simulate different network conditions without making real requests.
MockURLSession: Pretending to Talk to the Internet
❓ Why Do We Need MockURLSession?
When testing network requests, you don’t want to make real API calls. Real requests are slow, unreliable, and could fail for reasons unrelated to your code. Mocking URLSession
allows us to simulate the behavior of a real network call without actually connecting to a server. This makes the tests faster, more predictable, and completely offline.
actor MockURLSession {
private var mockDataTask: (Data, URLResponse)?
func setMockDataTask(data: Data, response: URLResponse) {
self.mockDataTask = (data, response)
}
}
extension MockURLSession: URLSessionProtocol {
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
guard let mockDataTask = self.mockDataTask else {
throw NetworkError.noData
}
return mockDataTask
}
}
➡️ Let's break it down step by step:
Defining the MockURLSession
- MockURLSession is an actor, designed to handle concurrency safely. Since network operations often involve multiple threads, using an actor ensures that only one task can access its state at a time, just like with the NetworkManager.
- The variable
mockDataTask
simulates the data and response that would be returned in a real network request. Instead of sending an actual request, you can provide your own data and URL response, allowing you to test how your network layer handles different scenarios (like success, failure, or invalid responses) without needing a real server.
Conforming to URLSessionProtocol
- The MockURLSession conforms to the URLSessionProtocol that we defined earlier. This is where the mocking happens.
- The
data(for request:)
function is an async function that mimics the behavior of URLSession’sdata(for:)
. In real-world code, this method would send a network request and wait for the response. - In this mock version, it returns the previously set mockDataTask (mocked data and response) if it’s available. If not, it throws a ‘NetworkError.noData’ error, simulating a scenario where no data is received, like a failed network request.
Benefits of Using MockURLSession
- Simulate different responses: Simulates network responses without real API calls. We will test different scenarios in our test cases.
- Test error handling: You can simulate network failures by using invalid responses or throwing errors, which we’ll also explore.
- Improve test speed and reliability: Without actual network requests, tests run much faster, and you won’t have to worry about factors like server downtime or internet issues.
MockEndpoints: Structuring Testable Requests
❓ Why Do We Need MockEndpoints?
In any network-related code, endpoints define the structure of a request (including the URL, HTTP method, and more). When writing tests, it’s important to have control over these endpoints to simulate different request scenarios like GET and POST requests, invalid URLs, or missing parameters. We ensure that the tests behave predictably by mocking endpoints.
enum MockEndpoint {
case invalidURL
case noData
case getMethod
case postMethod
}
extension MockEndpoint: Endpoint {
var baseURL: URL {
switch self {
case .invalidURL:
URL(string: " ")!
case .postMethod, .getMethod, .noData:
URL(string: "<https://example.com>")!
}
}
/*
* Other codes that conform to the endpoint
*/
var body: Data? {
return switch self {
case .postMethod:
"""
{
"description": "This is a POST method"
}
""".data(using: .utf8)!
case .getMethod, .invalidURL, .noData:
nil
}
}
}
Why MockEndpoints Are Important:
- Control request scenarios (GET, POST, etc.).
- Test various outcomes (invalid URLs, missing parameters).
MockResponses: Simulating Server Behavior
❓ Why Do We Need MockResponses?
MockResponses are Swift models that we can use them in different scenarios, such as:
- A successful response from the server (valid JSON data).
- An error response (like a 3xx, 4xx or 5xx error).
- A decoding error when the data format doesn’t match our expectations.
struct MockSuccessResponse: Decodable {
let id: Int
let name: String
}
struct MockErrorResponse: Decodable {
let statusCode: Int
}
struct MockDecodingErrorResponse: Decodable {
let temperature: Int
let temperatureUnit: String
}
enum MockCodingKeys: String, CodingKey {
case key
}
📚 In summary, by using MockURLSession, MockEndpoints, and MockResponses you create a reliable and controlled testing environment for your network code.
Each one plays a specific role in simulating real-world network conditions without making actual requests, ensuring your tests are faster, more stable, and easier to maintain.
- MockEndpoints help simulate different types of network requests.
- MockResponses simulate different server behaviors, from success to failure.
- MockURLSession brings it all together, allowing you to test network interactions without needing a real server.
With this setup, your tests can cover edge cases and error scenarios.
➡️ Now, let’s dive into writing actual tests using this mock setup!
First, we are testing different aspects of the Endpoint
in our network layer using Swift's newly introduced testing framework, Swift Testing, at WWDC24.
@testable import NetworkLayer
We are importing the NetworkLayer
module with the @testable
keyword. This allows us to access internal members (e.g., methods and properties marked as internal
) of the NetworkLayer module, which wouldn't be accessible otherwise in a test context.
This is important when writing tests, as you often need access to the internal logic to fully test how your app behaves.
Endpoint Test Case
@Suite("Endpoint Tests")
struct EndpointCheck {
let mockGetEndpoint = MockEndpoint.getMethod
let mockPostEndpoint = MockEndpoint.postMethod
@Test("Base URL and path are valid") func baseURLandPath() async throws {
try #require(mockGetEndpoint.urlRequest?.url != nil)
#expect(mockGetEndpoint.urlRequest?.url?.absoluteString.contains("https://example.com/mock/testing") ?? false)
}
/*
* Other test cases
*/
@Test("POST body is not nil") func postHttpBody() {
let expectedBody = """
{
"description": "This is a POST method"
}
""".data(using: .utf8)
#expect(mockPostEndpoint.urlRequest?.httpBody == expectedBody)
}
}
We define a @Suite called ‘EndpointCheck’ and display ‘Endpoint Tests’ in the Test Navigator.
In my previous blog post, the concept of a suite is a collection of related tests that target a specific component or feature of the app, like in this case, the Endpoint.
👉 Here, you can explore the Swift Testing for more details about Suite and Test.
let mockGetEndpoint = MockEndpoint.getMethod
let mockPostEndpoint = MockEndpoint.postMethod
These lines are defined as common variables within the EndpointCheck
struct. This allows for reusability during the test suite, preventing the need to repeatedly instantiate mockGetEndpoint
and mockPostEndpoint
in each individual test function.
Each test is defined using the @Test
propert wrapper, followed by a description of what it checks.
@Test("Base URL and path are valid") func baseURLandPath() async throws {
try #require(mockGetEndpoint.urlRequest?.url != nil)
#expect(mockGetEndpoint.urlRequest?.url?.absoluteString.contains("https://example.com/mock/testing") ?? false)
}
baseURLandPath
function verifies that the URL is not nil using#require
, which is essential for the request to be valid. #require ensures that preconditions are met, stopping the test early if necessary. Then, it#expect
the URL to match the base URL and path (https://example.com/mock/testing
), which we expect from the endpoint.
#expect allows us to test a wide range of outcomes — whether it’s a boolean condition, string matching, or even JSON data comparison.
📌 For the rest of the test cases in EndpointCheck
, the focus remains the same: ensuring that the network layer components function as intended.
Successful Test Case
@Test("Successful Request Test") func successfulRequest() async throws {
let endpoint = MockEndpoint.getMethod
let mockJsonData = """
{
"id": 1,
"name": "Swift Testing"
}
""".data(using: .utf8)!
let mockSuccessResponse = HTTPURLResponse(url: URL(string: "https://example.com")!,
statusCode: 200,
httpVersion: nil,
headerFields: nil)!
await mockURLSession.setMockDataTask(data: mockJsonData, response: mockSuccessResponse)
let networkManager = NetworkManager(urlSession: mockURLSession)
// No error thrown because the response is successful
let result = try await networkManager.request(endpoint, responseType: MockSuccessResponse.self)
#expect(result.id == 1)
#expect(result.name.contains("Swift Testing"))
}
We use mockURLSession.setMockDataTask()
to simulate different network responses. This is important because it allows us to replace real network calls with controlled mock data.
❓ Why do we pass mockURLSession
to NetworkManager
?
The reason we pass mockURLSession into NetworkManager is to replace the default URLSession. Normally, URLSession would handle real network requests, but here we override it with mockURLSession, which simulates network conditions by returning the mock data and responses we define. This way, we can test how our network layer reacts to different scenarios without actually making HTTP calls.
In the successfulRequest
test, we simulate a 200 success response and verify the data received.
Parameterized Test Function
@Test("Response Errors Tests", arguments: [
HTTPURLResponse(url: URL(string: "<https://example.com>")!,
statusCode: 100,
httpVersion: nil,
headerFields: nil)!,
HTTPURLResponse(url: URL(string: "<https://example.com>")!,
statusCode: 300,
httpVersion: nil,
headerFields: nil)!,
HTTPURLResponse(url: URL(string: "<https://example.com>")!,
statusCode: 400,
httpVersion: nil,
headerFields: nil)!,
HTTPURLResponse(url: URL(string: "<https://example.com>")!,
statusCode: 500,
httpVersion: nil,
headerFields: nil)!,
HTTPURLResponse(url: URL(string: "<https://example.com>")!,
statusCode: 678,
httpVersion: nil,
headerFields: nil)!
])
func responseErrors(response: HTTPURLResponse) async throws {
let endpoint = MockEndpoint.getMethod
let mockJsonData = """
{
"statusCode": \(response.statusCode)
}
""".data(using: .utf8)!
await mockURLSession.setMockDataTask(data: mockJsonData, response: response)
let networkManager = NetworkManager(urlSession: mockURLSession)
// Expecting an error; test passes if error is thrown
await #expect(throws: NetworkError.self) {
try await networkManager.request(endpoint, responseType: MockErrorResponse.self)
}
}
The responseErrors test case is an example of a parameterized test. This means the same test logic is executed multiple times in parallel, but with different parameters. Here, we’re testing different HTTP response status codes.
🔑 Note: The key part is #expect(throws:...)
. It verifies that the network request throws an error when the HTTP status code isn't in the 2xx range. In this context, NetworkError.self
means that any error conforming to the NetworkError
type is acceptable. It can be any NetworkError case, but which one it is doesn’t matter. The test will pass if any error defined in the NetworkError
is thrown.
Which code verifies the HTTP status codes? You can find it in the NetworkManager, validate function, click here to see it.
❓ You can see it will pass if a status code is thrown, but what happens if we give a 2xx status code?
As I said earlier, if no error is thrown, this test case will fail. So, let’s test it with the 200 status code.
I changed the status code in the parameter to 200. As you can see in the error description “Expectation failed: an error was expected, but none was thrown”. 200 status code did not throw an error because I didn’t implement any error handling in the NetworkManager’s validation for status codes 200 to 299. You can check the validate function here.
Decoding Errors
Let’s move on to the next test case, which focuses on decoding errors. I won’t dive into the different types of decoding errors, but you can check them out in the test file — click here to see. As shown in the test file, we can also add sub-suites for better test organization.
🎯 Our main focus in this section will be on the expect macro for a specific NetworkError type.
@Test("Data Corrupted Error") func dataCorrupted() async throws {
await mockURLSession.setMockDataTask(data: mockCorruptedData, response: response)
let networkManager = NetworkManager(urlSession: mockURLSession)
await #expect(throws: NetworkError.decodingError(dataCorruptedError).self) {
try await networkManager.request(endpoint, responseType: MockDecodingErrorResponse.self)
}
}
This simulates a network response that contains corrupted data, such as a missing comma or missing quotation mark in the JSON response.
💡 Reminder: In the previous example, we used: #expect(throws: NetworkError.self)
. The test expects any type of NetworkError. This means that any kind of NetworkError
is thrown, the test will pass, no matter which specific error it is.
But in here, dataCorrupted test case is looking for a specific error: NetworkError.decodingError(dataCorruptedError)
. If the network manager throws a different type of NetworkError, like decodingError(typeMismatchError)
, noData
, or any other unexpected error, the test will fail.
➡️ Let’s move on to the last test case that I want to discuss.
@Test("No Response Data") func noResponseData() async throws {
let endpoint = MockEndpoint.noData
let networkManager = NetworkManager(urlSession: mockURLSession)
await #expect(throws: NetworkError.noData.self) {
try await networkManager.request(endpoint, responseType: MockErrorResponse.self)
}
}
We are focusing on a scenario where the network manager should handle cases without any response data.
- No Mock Data Task Set: Unlike previous test cases, we don’t set a mock response with
mockURLSession.setMockDataTask
. Instead, we assume that no data will be returned. - Expected Error Type: The test uses
#expect(throws: NetworkError.noData.self)
, which means it expects the network manager to throw a specific error,NetworkError.noData
.
Now, you can see the complete set of test cases from the Test Navigator. As you can see, the displayed names shown match to those specified in the @Test()
property wrapper.
In the end, all the test cases passed, showing that our network layer works well.
Conclusion
In this blog post, we discussed:
- Importance of Testing: We talked about why testing matters in software development, especially using the Swift Testing framework for network layer testing in iOS apps.
- Swift Package Manager: I introduced the Network Layer package I created to simplify network operations.
- Building a NetworkManager: We explained how the NetworkManager helps manage network requests and responses safely.
- Testing Environment: We covered how to set up a reliable testing environment using mock components to simulate network behavior without real server calls. We ensured our network layer can handle different scenarios, such as response errors and decoding issues.
Learning these testing techniques helps create strong and maintainable software, improving user experience and making future scalability easier.
🚀 Check out the GitHub repository for the Network Layer package! If you find it useful, ⭐️ please give it a star and share any ideas for improvements. Your feedback can make this resource even better for the iOS development community — and help me improve too!
🙏 Thank you for reading! If you have any suggestions for this blog post or just want to say hi, don’t hesitate to connect with me on LinkedIn!
Special thanks to my mentor, Beyza İnce (Beyza Ince)! She has helped me a lot and has been very supportive.