Skip to content

Commit ffcf482

Browse files
committed
♻️ Replace Quick and Nimble with Swift Testing, use simpler leak testing
1 parent 81b907e commit ffcf482

File tree

9 files changed

+329
-366
lines changed

9 files changed

+329
-366
lines changed

README.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Sample iOS app written the way I write iOS apps because I cannot share the app I
1818

1919
* Localization to 2 languages with String catalogs
2020
* Continuous integration with Github Actions
21-
* Unit testing, including [testing view controllers for leaks](https://blog.kulman.sk/unit-testing-memory-leaks/)
21+
* Unit testing, including testing view controllers for leaks
2222
* Creating a view controller in code with dependency injection
2323
* Using static UITableView cells in a typed way with enums
2424
* Creating simple cells with UIListContentConfiguration
@@ -46,9 +46,6 @@ Sample iOS app written the way I write iOS apps because I cannot share the app I
4646
- [Nuke](https://github.com/kean/Nuke) - A powerful image loading and caching system
4747
- [FeedKit](https://github.com/nmdias/FeedKit) - An RSS, Atom and JSON Feed parser written in Swift
4848
- [NotificationBanner](https://github.com/Daltron/NotificationBanner) - The easiest way to display highly customizable in app notification banners in iOS
49-
- [SpecLeaks](leandromperez/specleaks) - Unit Tests Memory Leaks in Swift. Write readable tests for mem leaks easily with these Quick and Nimble extensions
50-
- [Quick](https://github.com/Quick/Quick) - The Swift (and Objective-C) testing framework
51-
- [Nimble](https://github.com/Quick/Nimble) - A Matcher Framework for Swift and Objective-C
5249
- [SwifLint](https://github.com/realm/SwiftLint) - A tool to enforce Swift style and conventions
5350

5451
## Author

Sources/iOSSampleApp.xcodeproj/project.pbxproj

Lines changed: 0 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,6 @@
2424
F3A812BF1F83740E00A09AAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F3A812BE1F83740E00A09AAB /* Assets.xcassets */; };
2525
F3A9678A21AC38D8005E7F3F /* ViewControllerLeakTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A9678921AC38D8005E7F3F /* ViewControllerLeakTests.swift */; };
2626
F3A9678C21AC3961005E7F3F /* ViewControllerLeakTests+Setup.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A9678B21AC3961005E7F3F /* ViewControllerLeakTests+Setup.swift */; };
27-
F3C71AB72B002A8A00EEAC8E /* SpecLeaks in Frameworks */ = {isa = PBXBuildFile; productRef = F3C71AB62B002A8A00EEAC8E /* SpecLeaks */; };
28-
F3C71ABA2B002B2400EEAC8E /* Nimble in Frameworks */ = {isa = PBXBuildFile; productRef = F3C71AB92B002B2400EEAC8E /* Nimble */; };
29-
F3C71ABD2B002BB500EEAC8E /* Quick in Frameworks */ = {isa = PBXBuildFile; productRef = F3C71ABC2B002BB500EEAC8E /* Quick */; };
3027
F3C8DB3F214EA3AA00C1A654 /* SourceSelectionViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C8DB3E214EA3AA00C1A654 /* SourceSelectionViewModelTests.swift */; };
3128
F3C8DB41214EA7F200C1A654 /* FeedViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C8DB40214EA7F200C1A654 /* FeedViewModelTests.swift */; };
3229
F3D6865C1F9B761E00879154 /* TestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3D6865B1F9B761E00879154 /* TestExtensions.swift */; };
@@ -84,9 +81,6 @@
8481
isa = PBXFrameworksBuildPhase;
8582
buildActionMask = 2147483647;
8683
files = (
87-
F3C71ABD2B002BB500EEAC8E /* Quick in Frameworks */,
88-
F3C71AB72B002A8A00EEAC8E /* SpecLeaks in Frameworks */,
89-
F3C71ABA2B002B2400EEAC8E /* Nimble in Frameworks */,
9084
);
9185
runOnlyForDeploymentPostprocessing = 0;
9286
};
@@ -210,9 +204,6 @@
210204
);
211205
name = iOSSampleAppTests;
212206
packageProductDependencies = (
213-
F3C71AB62B002A8A00EEAC8E /* SpecLeaks */,
214-
F3C71AB92B002B2400EEAC8E /* Nimble */,
215-
F3C71ABC2B002BB500EEAC8E /* Quick */,
216207
);
217208
productName = iOSSampleAppTests;
218209
productReference = F3208A7A1F84E48100B57B0E /* iOSSampleAppTests.xctest */;
@@ -323,9 +314,6 @@
323314
F376423425A4689800C58CE5 /* XCRemoteSwiftPackageReference "Nuke" */,
324315
F32344CD29D814B900B1886D /* XCRemoteSwiftPackageReference "SwiftLint" */,
325316
F3DFFC5C2A4C84E2001F5565 /* XCRemoteSwiftPackageReference "SwiftGenPlugin" */,
326-
F3C71AB52B002A8A00EEAC8E /* XCRemoteSwiftPackageReference "specleaks" */,
327-
F3C71AB82B002B2400EEAC8E /* XCRemoteSwiftPackageReference "Nimble" */,
328-
F3C71ABB2B002BB500EEAC8E /* XCRemoteSwiftPackageReference "Quick" */,
329317
);
330318
productRefGroup = F3A812B51F83740E00A09AAB /* Products */;
331319
projectDirPath = "";
@@ -766,30 +754,6 @@
766754
version = 9.2.3;
767755
};
768756
};
769-
F3C71AB52B002A8A00EEAC8E /* XCRemoteSwiftPackageReference "specleaks" */ = {
770-
isa = XCRemoteSwiftPackageReference;
771-
repositoryURL = "https://github.com/radiofrance/specleaks/";
772-
requirement = {
773-
kind = revision;
774-
revision = 8c6090aeff8475f6eea6d91ac36b8219f3cc3c93;
775-
};
776-
};
777-
F3C71AB82B002B2400EEAC8E /* XCRemoteSwiftPackageReference "Nimble" */ = {
778-
isa = XCRemoteSwiftPackageReference;
779-
repositoryURL = "https://github.com/Quick/Nimble";
780-
requirement = {
781-
kind = exactVersion;
782-
version = 10.0.0;
783-
};
784-
};
785-
F3C71ABB2B002BB500EEAC8E /* XCRemoteSwiftPackageReference "Quick" */ = {
786-
isa = XCRemoteSwiftPackageReference;
787-
repositoryURL = "https://github.com/Quick/Quick";
788-
requirement = {
789-
kind = exactVersion;
790-
version = 5.0.1;
791-
};
792-
};
793757
F3DFFC5C2A4C84E2001F5565 /* XCRemoteSwiftPackageReference "SwiftGenPlugin" */ = {
794758
isa = XCRemoteSwiftPackageReference;
795759
repositoryURL = "https://github.com/BookBeat/SwiftGenPlugin";
@@ -856,21 +820,6 @@
856820
package = F376423425A4689800C58CE5 /* XCRemoteSwiftPackageReference "Nuke" */;
857821
productName = Nuke;
858822
};
859-
F3C71AB62B002A8A00EEAC8E /* SpecLeaks */ = {
860-
isa = XCSwiftPackageProductDependency;
861-
package = F3C71AB52B002A8A00EEAC8E /* XCRemoteSwiftPackageReference "specleaks" */;
862-
productName = SpecLeaks;
863-
};
864-
F3C71AB92B002B2400EEAC8E /* Nimble */ = {
865-
isa = XCSwiftPackageProductDependency;
866-
package = F3C71AB82B002B2400EEAC8E /* XCRemoteSwiftPackageReference "Nimble" */;
867-
productName = Nimble;
868-
};
869-
F3C71ABC2B002BB500EEAC8E /* Quick */ = {
870-
isa = XCSwiftPackageProductDependency;
871-
package = F3C71ABB2B002BB500EEAC8E /* XCRemoteSwiftPackageReference "Quick" */;
872-
productName = Quick;
873-
};
874823
/* End XCSwiftPackageProductDependency section */
875824
};
876825
rootObject = F3A812AC1F83740E00A09AAB /* Project object */;
Lines changed: 89 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// CustomSourceViewModelTest.swift
2+
// CustomSourceViewModelTests.swift
33
// iOSSampleAppTests
44
//
55
// Created by Igor Kulman on 04/10/2017.
@@ -8,83 +8,95 @@
88

99
import Foundation
1010
@testable import iOSSampleApp
11-
import Nimble
12-
import Quick
11+
import Testing
1312
import RxSwift
1413

15-
class CustomSourceViewModelTests: QuickSpec {
16-
override func spec() {
17-
describe("CustomSourceViewModel") {
18-
var vm: CustomSourceViewModel!
19-
beforeEach {
20-
vm = CustomSourceViewModel()
21-
}
22-
23-
context("with empty data") {
24-
it("should not validate") {
25-
expect(try! vm.isValid.toBlocking().first()) == false
26-
}
27-
}
28-
29-
context("with valid data") {
30-
beforeEach {
31-
vm.title.accept("Coding Journal")
32-
vm.rssUrl.accept("https://blog.kulman.sk/index.xml")
33-
vm.url.accept("https://blog.kulman.sk")
34-
}
35-
36-
it("should validate OK") {
37-
expect(try! vm.isValid.toBlocking().first()) == true
38-
}
39-
}
40-
41-
context("with missing URL") {
42-
beforeEach {
43-
vm.title.accept("Coding Journal")
44-
vm.rssUrl.accept("https://blog.kulman.sk/index.xml")
45-
vm.url.accept(nil)
46-
}
47-
48-
it("should not validate") {
49-
expect(try! vm.isValid.toBlocking().first()) == false
50-
}
51-
}
52-
53-
context("with invalid URL") {
54-
beforeEach {
55-
vm.title.accept("Coding Journal")
56-
vm.rssUrl.accept("https://blog.kulman.sk/index.xml")
57-
vm.url.accept("blog")
58-
}
59-
60-
it("should not validate") {
61-
expect(try! vm.isValid.toBlocking().first()) == false
62-
}
63-
}
64-
65-
context("with invalid RSS URL") {
66-
beforeEach {
67-
vm.title.accept("Coding Journal")
68-
vm.rssUrl.accept("dss")
69-
vm.url.accept("https://blog.kulman.sk")
70-
}
71-
72-
it("should not validate") {
73-
expect(try! vm.isValid.toBlocking().first()) == false
74-
}
75-
}
76-
77-
context("with missing title") {
78-
beforeEach {
79-
vm.title.accept(nil)
80-
vm.rssUrl.accept("https://blog.kulman.sk/index.xml")
81-
vm.url.accept("https://blog.kulman.sk")
82-
}
83-
84-
it("should not validate") {
85-
expect(try! vm.isValid.toBlocking().first()) == false
86-
}
87-
}
88-
}
14+
struct CustomSourceViewModelTests {
15+
16+
@Test("Empty data should not be valid")
17+
func testEmptyDataValidation() throws {
18+
// Given
19+
let vm = CustomSourceViewModel()
20+
21+
// When
22+
let isValid = try vm.isValid.toBlocking().first()!
23+
24+
// Then
25+
#expect(isValid == false)
26+
}
27+
28+
@Test("Valid data should validate correctly")
29+
func testValidDataValidation() throws {
30+
// Given
31+
let vm = CustomSourceViewModel()
32+
vm.title.accept("Coding Journal")
33+
vm.rssUrl.accept("https://blog.kulman.sk/index.xml")
34+
vm.url.accept("https://blog.kulman.sk")
35+
36+
// When
37+
let isValid = try vm.isValid.toBlocking().first()!
38+
39+
// Then
40+
#expect(isValid == true)
41+
}
42+
43+
@Test("Missing URL should not validate")
44+
func testMissingUrlValidation() throws {
45+
// Given
46+
let vm = CustomSourceViewModel()
47+
vm.title.accept("Coding Journal")
48+
vm.rssUrl.accept("https://blog.kulman.sk/index.xml")
49+
vm.url.accept(nil)
50+
51+
// When
52+
let isValid = try vm.isValid.toBlocking().first()!
53+
54+
// Then
55+
#expect(isValid == false)
56+
}
57+
58+
@Test("Invalid URL should not validate")
59+
func testInvalidUrlValidation() throws {
60+
// Given
61+
let vm = CustomSourceViewModel()
62+
vm.title.accept("Coding Journal")
63+
vm.rssUrl.accept("https://blog.kulman.sk/index.xml")
64+
vm.url.accept("blog")
65+
66+
// When
67+
let isValid = try vm.isValid.toBlocking().first()!
68+
69+
// Then
70+
#expect(isValid == false)
71+
}
72+
73+
@Test("Invalid RSS URL should not validate")
74+
func testInvalidRssUrlValidation() throws {
75+
// Given
76+
let vm = CustomSourceViewModel()
77+
vm.title.accept("Coding Journal")
78+
vm.rssUrl.accept("dss")
79+
vm.url.accept("https://blog.kulman.sk")
80+
81+
// When
82+
let isValid = try vm.isValid.toBlocking().first()!
83+
84+
// Then
85+
#expect(isValid == false)
86+
}
87+
88+
@Test("Missing title should not validate")
89+
func testMissingTitleValidation() throws {
90+
// Given
91+
let vm = CustomSourceViewModel()
92+
vm.title.accept(nil)
93+
vm.rssUrl.accept("https://blog.kulman.sk/index.xml")
94+
vm.url.accept("https://blog.kulman.sk")
95+
96+
// When
97+
let isValid = try vm.isValid.toBlocking().first()!
98+
99+
// Then
100+
#expect(isValid == false)
89101
}
90102
}

Sources/iOSSampleAppTests/DataServiceTests.swift

Lines changed: 51 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -8,54 +8,61 @@
88

99
import Foundation
1010
@testable import iOSSampleApp
11-
import Nimble
12-
import Quick
13-
14-
class DataServiceTests: QuickSpec {
15-
override func spec() {
16-
describe("RSS data service") {
17-
var service: RssDataService!
18-
beforeEach {
19-
service = RssDataService()
20-
}
11+
import Testing
12+
13+
struct DataServiceTests {
14+
15+
@Test("Successfully fetch from valid RSS feed")
16+
func testValidRssFeed() async throws {
17+
// Given
18+
let service = RssDataService()
19+
let source = RssSource(
20+
title: "Hacker News",
21+
url: URL(string: "https://news.ycombinator.com")!,
22+
rss: URL(string: "https://news.ycombinator.com/rss")!,
23+
icon: nil
24+
)
2125

22-
context("given valid RSS feed") {
23-
var source: RssSource!
24-
beforeEach {
25-
source = RssSource(title: "Hacker News", url: URL(string: "https://news.ycombinator.com")!, rss: URL(string: "https://news.ycombinator.com/rss")!, icon: nil)
26-
}
27-
28-
it("succeeeds") {
29-
waitUntil(timeout: .seconds(5)) { done in
30-
service.getFeed(source: source) { result in
31-
expect(result).notTo(equal(.failure(RssError.emptyResponse)))
32-
expect(result) == .success([])
33-
done()
34-
}
35-
}
36-
}
26+
// When
27+
let result = await withCheckedContinuation { continuation in
28+
service.getFeed(source: source) { result in
29+
continuation.resume(returning: result)
3730
}
31+
}
32+
33+
// Then
34+
switch result {
35+
case let .success(items):
36+
#expect(!items.isEmpty)
37+
case let .failure(error):
38+
Issue.record("Failed with error: \(error)")
39+
}
40+
}
41+
42+
@Test("Fail when fetching from invalid RSS feed")
43+
func testInvalidRssFeed() async throws {
44+
// Given
45+
let service = RssDataService()
46+
let source = RssSource(
47+
title: "Fake",
48+
url: URL(string: "https://news.ycombinator.com")!,
49+
rss: URL(string: "https://news.ycombinator.com")!,
50+
icon: nil
51+
)
3852

39-
context("given invalid RSS feed") {
40-
var source: RssSource!
41-
beforeEach {
42-
source = RssSource(title: "Fake", url: URL(string: "https://news.ycombinator.com")!, rss: URL(string: "https://news.ycombinator.com")!, icon: nil)
43-
}
44-
45-
it("fails") {
46-
waitUntil(timeout: .seconds(5)) { done in
47-
service.getFeed(source: source) { result in
48-
switch result {
49-
case .success:
50-
fail("Expected failure, but got success")
51-
case .failure:
52-
break
53-
}
54-
done()
55-
}
56-
}
57-
}
53+
// When
54+
let result = await withCheckedContinuation { continuation in
55+
service.getFeed(source: source) { result in
56+
continuation.resume(returning: result)
5857
}
5958
}
59+
60+
// Then
61+
switch result {
62+
case .success:
63+
Issue.record("Expected failure but got success")
64+
case .failure:
65+
break
66+
}
6067
}
6168
}

0 commit comments

Comments
 (0)