Skip to content

Commit 9798638

Browse files
committed
🕸 TestURLProtocol
0 parents  commit 9798638

File tree

9 files changed

+322
-0
lines changed

9 files changed

+322
-0
lines changed

‎.github/workflows/ci.yml

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
branches:
9+
- '*'
10+
11+
concurrency:
12+
group: build-ios-${{ github.ref }}
13+
cancel-in-progress: true
14+
15+
jobs:
16+
library:
17+
runs-on: macOS-12
18+
strategy:
19+
matrix:
20+
xcode:
21+
- '14.1'
22+
steps:
23+
- uses: actions/checkout@v2
24+
- name: Select Xcode ${{ matrix.xcode }}
25+
run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
26+
- name: Run tests
27+
run: make test

‎.gitignore

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
/*.xcodeproj
5+
xcuserdata/
6+
DerivedData/
7+
/.swiftpm
8+
.netrc
9+
test_output
10+

‎.swift-format.json

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"version": 1,
3+
"lineLength": 100,
4+
"indentation": {
5+
"tabs": 1
6+
},
7+
"maximumBlankLines": 1,
8+
"respectsExistingLineBreaks": true,
9+
"lineBreakBeforeControlFlowKeywords": true,
10+
"lineBreakBeforeEachArgument": true,
11+
"indentConditionalCompilationBlocks": false
12+
}

‎LICENSE

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2023 Mike Gopsill
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎Makefile

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
PLATFORM_IOS = iOS Simulator,name=iPhone 14
2+
PLATFORM_MACOS = macOS
3+
PLATFORM_WATCHOS = watchOS Simulator,name=Apple Watch Series 7 (45mm)
4+
5+
test:
6+
swift test
7+
xcodebuild test \
8+
-scheme TestURLProtocol \
9+
-destination platform="$(PLATFORM_IOS)"
10+
xcodebuild test \
11+
-scheme TestURLProtocol \
12+
-destination platform="$(PLATFORM_MACOS)"
13+
xcodebuild build \
14+
-scheme TestURLProtocol \
15+
-destination platform="$(PLATFORM_WATCHOS)"
16+
17+
format:
18+
swift format --in-place --configuration .swift-format.json --recursive ./Package.swift ./Sources ./Tests

‎Package.swift

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// swift-tools-version: 5.7
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
6+
let package = Package(
7+
name: "TestURLProtocol",
8+
products: [
9+
.library(
10+
name: "TestURLProtocol",
11+
targets: ["TestURLProtocol"]
12+
)
13+
],
14+
dependencies: [],
15+
targets: [
16+
.target(
17+
name: "TestURLProtocol",
18+
dependencies: []
19+
),
20+
.testTarget(
21+
name: "TestURLProtocolTests",
22+
dependencies: ["TestURLProtocol"]
23+
),
24+
]
25+
)

‎README.md

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# 🕸 TestURLProtocol
2+
[![CI](https://github.com/mgopsill/TestURLProtocol/workflows/CI/badge.svg)](https://github.com/mgopsill/TestURLProtocol/actions?query=workflow%3ACI)
3+
[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmgopsill%2FTestURLProtocol%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/mgopsill/TestURLProtocol)
4+
[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmgopsill%2FTestURLProtocol%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/mgopsill/TestURLProtocol)
5+
6+
The TestURLProtocol is a URLProtocol subclass for testing purposes. It allows for setting mock responses for specific URLs, which are returned instead of making a real network request. This can be very useful for testing your app's networking code without relying on external resources or APIs.
7+
8+
## Usage
9+
10+
To use the TestURLProtocol, you first need to register it with the `URLProtocol`:
11+
12+
```swift
13+
URLProtocol.registerClass(TestURLProtocol.self)
14+
```
15+
16+
Once you've registered the TestURLProtocol, you can set mock responses for specific URLs by adding them to the mockResponses dictionary. Here's an example of how to do that:
17+
18+
```
19+
TestURLProtocol.mockResponses[url] = { request in
20+
(Result.success(data), statusCode)
21+
}
22+
```
23+
24+
The mockResponses dictionary takes a closure that takes in a URLRequest and returns a tuple containing a Result of Data or Error, and an optional statusCode.
25+
26+
27+
## Example Network Test Case Setup
28+
29+
Typically, one unit test class might test network-related code. In that case a setup might look like the following.
30+
31+
```swift
32+
final class NetworkingTest: XCTestCase {
33+
override class func setUp() {
34+
super.setUp()
35+
URLProtocol.registerClass(TestURLProtocol.self)
36+
}
37+
38+
override class func tearDown() {
39+
super.tearDown()
40+
URLProtocol.unregisterClass(TestURLProtocol.self)
41+
}
42+
43+
override func tearDown() {
44+
super.tearDown()
45+
TestURLProtocol.mockResponses.removeAll()
46+
}
47+
}
48+
```
49+
50+
## Different Session
51+
52+
If you are using a custom `URLSessionConfiguration` you can register the protocol like so:
53+
54+
```swift
55+
let config = URLSessionConfiguration.default
56+
config.protocolClasses = [TestURLProtocol.self]
57+
let session = URLSession(configuration: config)
58+
```
59+
60+
### Note
61+
62+
It is important to note that TestURLProtocol will only work for URLs that have been registered in the mockResponses dictionary. If a request is made for a URL that hasn't been registered, the TestURLProtocol will not handle the request and it will be passed on to the next registered URLProtocol.
63+
64+
Also, if you have registered the TestURLProtocol you have to make sure that it's the first registered class in the protocolClasses array, otherwise the URLSession will use the next protocol in the list to handle the request.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import Foundation
2+
3+
/// A `URLProtocol` subclass for testing purposes.
4+
/// Allows for setting mock responses for specific URLs, which are returned instead of making a real network request.
5+
public final class TestURLProtocol: URLProtocol {
6+
/// A type alias for a closure that takes in a `URLRequest` and returns a tuple containing a `Result` of `Data` or `Error`, and an optional `statusCode`.
7+
public typealias MockResponse = (URLRequest) -> (
8+
result: Result<Data, Error>, statusCode: Int?
9+
)
10+
/// A dictionary storing the mock responses for specific URLs.
11+
public static var mockResponses: [URL: MockResponse] = [:]
12+
13+
public override class func canInit(with request: URLRequest) -> Bool {
14+
guard let url = request.url else { return false }
15+
return mockResponses.keys.contains(url.removingQueries)
16+
}
17+
18+
public override class func canonicalRequest(for request: URLRequest) -> URLRequest {
19+
request
20+
}
21+
22+
public override func startLoading() {
23+
guard
24+
let responseBlock = TestURLProtocol.mockResponses[
25+
request.url!.removingQueries
26+
]
27+
else { fatalError("No mock response") }
28+
let response = responseBlock(request)
29+
30+
if let statusCode = response.statusCode {
31+
let httpURLResponse = HTTPURLResponse(
32+
url: request.url!,
33+
statusCode: statusCode,
34+
httpVersion: nil,
35+
headerFields: nil
36+
)!
37+
self.client?.urlProtocol(
38+
self,
39+
didReceive: httpURLResponse,
40+
cacheStoragePolicy: .notAllowed
41+
)
42+
}
43+
44+
switch response.result {
45+
case let .success(data):
46+
client?.urlProtocol(self, didLoad: data)
47+
client?.urlProtocolDidFinishLoading(self)
48+
49+
case let .failure(error):
50+
client?.urlProtocol(self, didFailWithError: error)
51+
}
52+
}
53+
54+
public override func stopLoading() {}
55+
}
56+
57+
extension URL {
58+
var removingQueries: URL {
59+
if var components = URLComponents(string: absoluteString) {
60+
components.query = nil
61+
return components.url ?? self
62+
}
63+
else {
64+
return self
65+
}
66+
}
67+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
#if canImport(Combine)
2+
import TestURLProtocol
3+
import XCTest
4+
5+
final class TestURLProtocolTests: XCTestCase {
6+
private let url = URL(string: "https://www.google.com")!
7+
private var request: URLRequest { .init(url: url) }
8+
9+
override class func setUp() {
10+
super.setUp()
11+
URLProtocol.registerClass(TestURLProtocol.self)
12+
}
13+
14+
override class func tearDown() {
15+
super.tearDown()
16+
URLProtocol.unregisterClass(TestURLProtocol.self)
17+
}
18+
19+
override func tearDown() {
20+
super.tearDown()
21+
TestURLProtocol.mockResponses.removeAll()
22+
}
23+
24+
func test_canInit_withURL_withMockedResponse() {
25+
TestURLProtocol.mockResponses[url] = { _ in return (.success(Data()), nil) }
26+
XCTAssertTrue(TestURLProtocol.canInit(with: request))
27+
}
28+
29+
func test_cannotInit_withURL_withoutMockedResponse() {
30+
XCTAssertFalse(TestURLProtocol.canInit(with: request))
31+
}
32+
33+
func test_cannotInit_withMismatchingURLs_withoutMockedResponse() {
34+
let otherURL = URL(string: "https://www.two.com")!
35+
TestURLProtocol.mockResponses[url] = { _ in return (.success(Data()), nil) }
36+
XCTAssertFalse(TestURLProtocol.canInit(with: URLRequest(url: otherURL)))
37+
}
38+
39+
func test_canonicalRequest() {
40+
XCTAssertEqual(TestURLProtocol.canonicalRequest(for: request), request)
41+
}
42+
43+
@available(iOS 13.0, *)
44+
func test_successfulResponse() {
45+
TestURLProtocol.mockResponses[url] = { request in
46+
(result: .success(Data()), statusCode: 200)
47+
}
48+
49+
let expectation = XCTestExpectation()
50+
let cancellable = URLSession.shared.dataTaskPublisher(for: url)
51+
.sink(receiveCompletion: { _ in }) { (data, response) in
52+
expectation.fulfill()
53+
}
54+
wait(for: [expectation], timeout: 0.2)
55+
}
56+
57+
@available(iOS 13.0, *)
58+
func test_failedResponse() {
59+
struct TestError: Error {}
60+
TestURLProtocol.mockResponses[url] = { request in
61+
(result: .failure(TestError()), statusCode: 200)
62+
}
63+
64+
let expectation = XCTestExpectation()
65+
let cancellable = URLSession.shared.dataTaskPublisher(for: url)
66+
.sink { completion in
67+
switch completion {
68+
case .failure:
69+
expectation.fulfill()
70+
case .finished: break
71+
}
72+
} receiveValue: { _ in
73+
}
74+
75+
wait(for: [expectation], timeout: 0.2)
76+
}
77+
}
78+
#endif

0 commit comments

Comments
 (0)