Skip to content

Commit ce6c37d

Browse files
committed
testing improvements
Signed-off-by: Sertac Ozercan <sozercan@gmail.com>
1 parent 707867a commit ce6c37d

29 files changed

+751
-406
lines changed

AGENTS.md

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -189,26 +189,66 @@ GlassEffectContainer(spacing: 0) {
189189
- `SearchView` (on VStack)
190190
- `PlaylistDetailView` (on Group)
191191

192+
### Swift Testing (Preferred)
193+
194+
> **Use Swift Testing for all new unit tests** — See [ADR-0006](docs/adr/0006-swift-testing-migration.md) for details.
195+
196+
Swift Testing is 4x faster than XCTest and provides cleaner syntax. Use it for all new tests except:
197+
- **Performance tests** — Keep in XCTest (requires `measure {}`)
198+
- **UI tests** — Keep in XCTest (requires XCUIApplication)
199+
200+
| XCTest | Swift Testing |
201+
|--------|---------------|
202+
| `import XCTest` | `import Testing` |
203+
| `class ... : XCTestCase` | `@Suite struct ...` |
204+
| `func testFoo()` | `@Test func foo()` |
205+
| `XCTAssertEqual(a, b)` | `#expect(a == b)` |
206+
| `XCTAssertTrue(x)` | `#expect(x)` |
207+
| `XCTAssertNil(x)` | `#expect(x == nil)` |
208+
| `XCTFail("msg")` | `Issue.record("msg")` |
209+
| `setUp()` / `tearDown()` | `init()` (ARC handles cleanup) |
210+
211+
**Pattern for `@MainActor` tests:**
212+
213+
```swift
214+
import Testing
215+
@testable import Kaset
216+
217+
@Suite(.serialized) // Required for @MainActor
218+
@MainActor
219+
struct MyServiceTests {
220+
let service: MyService
221+
let mockClient: MockYTMusicClient
222+
223+
init() {
224+
mockClient = MockYTMusicClient()
225+
service = MyService(client: mockClient)
226+
}
227+
228+
@Test("Does something correctly")
229+
func doesSomething() async {
230+
await service.doSomething()
231+
#expect(service.state == .done)
232+
}
233+
}
234+
```
235+
236+
**Parameterized tests** — Use for multiple input cases:
237+
238+
```swift
239+
@Test("Status raw values", arguments: [
240+
(LikeStatus.liked, "LIKE"),
241+
(LikeStatus.disliked, "DISLIKE"),
242+
])
243+
func statusRawValues(status: LikeStatus, expected: String) {
244+
#expect(status.rawValue == expected)
245+
}
246+
```
247+
192248
### Swift Concurrency
193249

194250
- Mark `@Observable` classes with `@MainActor`
195251
- Never use `DispatchQueue` — use `async`/`await`, `MainActor`
196-
- For `@MainActor` test classes, don't call `super.setUp()` in async context:
197-
```swift
198-
@MainActor
199-
final class MyServiceTests: XCTestCase {
200-
override func setUp() async throws {
201-
// Do NOT call: try await super.setUp()
202-
// Set up test fixtures here
203-
}
204-
205-
override func tearDown() async throws {
206-
// Clean up here
207-
// Do NOT call: try await super.tearDown()
208-
}
209-
}
210-
```
211-
**Why?** `XCTestCase` is not `Sendable`. Calling `super.setUp()` from a `@MainActor` async context sends `self` across actor boundaries, causing Swift 6 strict concurrency errors. XCTest's base implementations are no-ops, so skipping them is safe.
212252

213253
### WebKit Patterns
214254

Kaset.xcodeproj/project.pbxproj

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@
104104
E50000010000000000000092 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50000020000000000000092 /* OnboardingView.swift */; };
105105
E50000010000000000000093 /* AccentBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50000020000000000000093 /* AccentBackground.swift */; };
106106
E50000010000000000000101 /* WebKitManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50000020000000000000101 /* WebKitManagerTests.swift */; };
107-
E50000010000000000000600 /* Tags.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50000020000000000000600 /* Tags.swift */; };
107+
15F144163C7B4864A81CF001 /* Tags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15F144163C7B4864A81CF002 /* Tags.swift */; };
108+
15F144163C7B4864A81CF004 /* TestStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15F144163C7B4864A81CF005 /* TestStringConvertible.swift */; };
108109
E50000010000000000000102 /* AuthServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50000020000000000000102 /* AuthServiceTests.swift */; };
109110
E50000010000000000000103 /* YTMusicClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50000020000000000000103 /* YTMusicClientTests.swift */; };
110111
E50000010000000000000104 /* PlayerServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50000020000000000000104 /* PlayerServiceTests.swift */; };
@@ -270,7 +271,8 @@
270271
E50000020000000000000093 /* AccentBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccentBackground.swift; sourceTree = "<group>"; };
271272
E50000020000000000000099 /* KasetTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KasetTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
272273
E50000020000000000000101 /* WebKitManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebKitManagerTests.swift; sourceTree = "<group>"; };
273-
E50000020000000000000600 /* Tags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tags.swift; sourceTree = "<group>"; };
274+
15F144163C7B4864A81CF002 /* Tags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tags.swift; sourceTree = "<group>"; };
275+
15F144163C7B4864A81CF005 /* TestStringConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestStringConvertible.swift; sourceTree = "<group>"; };
274276
E50000020000000000000102 /* AuthServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthServiceTests.swift; sourceTree = "<group>"; };
275277
E50000020000000000000103 /* YTMusicClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YTMusicClientTests.swift; sourceTree = "<group>"; };
276278
E50000020000000000000104 /* PlayerServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerServiceTests.swift; sourceTree = "<group>"; };
@@ -697,7 +699,7 @@
697699
isa = PBXGroup;
698700
children = (
699701
E50000020000000000000101 /* WebKitManagerTests.swift */,
700-
E50000060000000000000054 /* SwiftTestingHelpers */,
702+
15F144163C7B4864A81CF003 /* SwiftTestingHelpers */,
701703
E50000020000000000000102 /* AuthServiceTests.swift */,
702704
E50000020000000000000103 /* YTMusicClientTests.swift */,
703705
E50000020000000000000104 /* PlayerServiceTests.swift */,
@@ -726,10 +728,11 @@
726728
path = KasetTests;
727729
sourceTree = "<group>";
728730
};
729-
E50000060000000000000054 /* SwiftTestingHelpers */ = {
731+
15F144163C7B4864A81CF003 /* SwiftTestingHelpers */ = {
730732
isa = PBXGroup;
731733
children = (
732-
E50000020000000000000600 /* Tags.swift */,
734+
15F144163C7B4864A81CF002 /* Tags.swift */,
735+
15F144163C7B4864A81CF005 /* TestStringConvertible.swift */,
733736
);
734737
path = SwiftTestingHelpers;
735738
sourceTree = "<group>";
@@ -1002,7 +1005,8 @@
10021005
buildActionMask = 2147483647;
10031006
files = (
10041007
E50000010000000000000101 /* WebKitManagerTests.swift in Sources */,
1005-
E50000010000000000000600 /* Tags.swift in Sources */,
1008+
15F144163C7B4864A81CF001 /* Tags.swift in Sources */,
1009+
15F144163C7B4864A81CF004 /* TestStringConvertible.swift in Sources */,
10061010
E50000010000000000000102 /* AuthServiceTests.swift in Sources */,
10071011
E50000010000000000000103 /* YTMusicClientTests.swift in Sources */,
10081012
E50000010000000000000104 /* PlayerServiceTests.swift in Sources */,

Tests/KasetTests/APICacheTests.swift

Lines changed: 43 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Testing
33
@testable import Kaset
44

55
/// Tests for APICache.
6-
@Suite(.serialized)
6+
@Suite("APICache", .serialized, .tags(.service))
77
@MainActor
88
struct APICacheTests {
99
var cache: APICache
@@ -16,99 +16,99 @@ struct APICacheTests {
1616
@Test("Cache set and get")
1717
func cacheSetAndGet() {
1818
let data: [String: Any] = ["key": "value", "number": 42]
19-
cache.set(key: "test_key", data: data, ttl: 60)
19+
self.cache.set(key: "test_key", data: data, ttl: 60)
2020

21-
let retrieved = cache.get(key: "test_key")
21+
let retrieved = self.cache.get(key: "test_key")
2222
#expect(retrieved != nil)
2323
#expect(retrieved?["key"] as? String == "value")
2424
#expect(retrieved?["number"] as? Int == 42)
2525
}
2626

2727
@Test("Cache get nonexistent returns nil")
2828
func cacheGetNonexistent() {
29-
let retrieved = cache.get(key: "nonexistent_key")
29+
let retrieved = self.cache.get(key: "nonexistent_key")
3030
#expect(retrieved == nil)
3131
}
3232

3333
@Test("Cache invalidate all")
3434
func cacheInvalidateAll() {
35-
cache.set(key: "key1", data: ["a": 1], ttl: 60)
36-
cache.set(key: "key2", data: ["b": 2], ttl: 60)
35+
self.cache.set(key: "key1", data: ["a": 1], ttl: 60)
36+
self.cache.set(key: "key2", data: ["b": 2], ttl: 60)
3737

38-
#expect(cache.get(key: "key1") != nil)
39-
#expect(cache.get(key: "key2") != nil)
38+
#expect(self.cache.get(key: "key1") != nil)
39+
#expect(self.cache.get(key: "key2") != nil)
4040

41-
cache.invalidateAll()
41+
self.cache.invalidateAll()
4242

43-
#expect(cache.get(key: "key1") == nil)
44-
#expect(cache.get(key: "key2") == nil)
43+
#expect(self.cache.get(key: "key1") == nil)
44+
#expect(self.cache.get(key: "key2") == nil)
4545
}
4646

4747
@Test("Cache invalidate matching prefix")
4848
func cacheInvalidateMatchingPrefix() {
49-
cache.set(key: "home_section1", data: ["a": 1], ttl: 60)
50-
cache.set(key: "home_section2", data: ["b": 2], ttl: 60)
51-
cache.set(key: "search_results", data: ["c": 3], ttl: 60)
49+
self.cache.set(key: "home_section1", data: ["a": 1], ttl: 60)
50+
self.cache.set(key: "home_section2", data: ["b": 2], ttl: 60)
51+
self.cache.set(key: "search_results", data: ["c": 3], ttl: 60)
5252

53-
cache.invalidate(matching: "home_")
53+
self.cache.invalidate(matching: "home_")
5454

55-
#expect(cache.get(key: "home_section1") == nil)
56-
#expect(cache.get(key: "home_section2") == nil)
57-
#expect(cache.get(key: "search_results") != nil)
55+
#expect(self.cache.get(key: "home_section1") == nil)
56+
#expect(self.cache.get(key: "home_section2") == nil)
57+
#expect(self.cache.get(key: "search_results") != nil)
5858
}
5959

6060
@Test("Cache entry expiration")
6161
func cacheEntryExpiration() async throws {
62-
cache.set(key: "short_lived", data: ["test": true], ttl: 0.1)
62+
self.cache.set(key: "short_lived", data: ["test": true], ttl: 0.1)
6363

64-
#expect(cache.get(key: "short_lived") != nil)
64+
#expect(self.cache.get(key: "short_lived") != nil)
6565

6666
try await Task.sleep(for: .milliseconds(150))
6767

68-
#expect(cache.get(key: "short_lived") == nil)
68+
#expect(self.cache.get(key: "short_lived") == nil)
6969
}
7070

7171
@Test("Cache overwrite")
7272
func cacheOverwrite() {
73-
cache.set(key: "key", data: ["value": 1], ttl: 60)
74-
#expect(cache.get(key: "key")?["value"] as? Int == 1)
73+
self.cache.set(key: "key", data: ["value": 1], ttl: 60)
74+
#expect(self.cache.get(key: "key")?["value"] as? Int == 1)
7575

76-
cache.set(key: "key", data: ["value": 2], ttl: 60)
77-
#expect(cache.get(key: "key")?["value"] as? Int == 2)
76+
self.cache.set(key: "key", data: ["value": 2], ttl: 60)
77+
#expect(self.cache.get(key: "key")?["value"] as? Int == 2)
7878
}
7979

8080
@Test("Cache TTL constants are correct")
8181
func cacheTTLConstants() {
82-
#expect(APICache.TTL.home == 5 * 60) // 5 minutes
83-
#expect(APICache.TTL.playlist == 30 * 60) // 30 minutes
84-
#expect(APICache.TTL.artist == 60 * 60) // 1 hour
85-
#expect(APICache.TTL.search == 2 * 60) // 2 minutes
86-
#expect(APICache.TTL.library == 5 * 60) // 5 minutes
87-
#expect(APICache.TTL.lyrics == 24 * 60 * 60) // 24 hours
88-
#expect(APICache.TTL.songMetadata == 30 * 60) // 30 minutes
82+
#expect(APICache.TTL.home == 5 * 60) // 5 minutes
83+
#expect(APICache.TTL.playlist == 30 * 60) // 30 minutes
84+
#expect(APICache.TTL.artist == 60 * 60) // 1 hour
85+
#expect(APICache.TTL.search == 2 * 60) // 2 minutes
86+
#expect(APICache.TTL.library == 5 * 60) // 5 minutes
87+
#expect(APICache.TTL.lyrics == 24 * 60 * 60) // 24 hours
88+
#expect(APICache.TTL.songMetadata == 30 * 60) // 30 minutes
8989
}
9090

9191
@Test("Lyrics cache not invalidated by mutations")
9292
func lyricsCacheNotInvalidatedByMutations() {
93-
cache.set(key: "browse:lyrics_abc123", data: ["text": "lyrics content"], ttl: APICache.TTL.lyrics)
94-
cache.set(key: "next:song_abc123", data: ["title": "song"], ttl: APICache.TTL.songMetadata)
93+
self.cache.set(key: "browse:lyrics_abc123", data: ["text": "lyrics content"], ttl: APICache.TTL.lyrics)
94+
self.cache.set(key: "next:song_abc123", data: ["title": "song"], ttl: APICache.TTL.songMetadata)
9595

96-
cache.invalidate(matching: "next:")
96+
self.cache.invalidate(matching: "next:")
9797

98-
#expect(cache.get(key: "browse:lyrics_abc123") != nil)
99-
#expect(cache.get(key: "next:song_abc123") == nil)
98+
#expect(self.cache.get(key: "browse:lyrics_abc123") != nil)
99+
#expect(self.cache.get(key: "next:song_abc123") == nil)
100100
}
101101

102102
@Test("Song metadata cache invalidated by mutations")
103103
func songMetadataCacheInvalidatedByMutations() {
104-
cache.set(key: "next:song_abc123", data: ["title": "song"], ttl: APICache.TTL.songMetadata)
105-
cache.set(key: "browse:home_section", data: ["section": "home"], ttl: APICache.TTL.home)
104+
self.cache.set(key: "next:song_abc123", data: ["title": "song"], ttl: APICache.TTL.songMetadata)
105+
self.cache.set(key: "browse:home_section", data: ["section": "home"], ttl: APICache.TTL.home)
106106

107-
cache.invalidate(matching: "browse:")
108-
cache.invalidate(matching: "next:")
107+
self.cache.invalidate(matching: "browse:")
108+
self.cache.invalidate(matching: "next:")
109109

110-
#expect(cache.get(key: "next:song_abc123") == nil)
111-
#expect(cache.get(key: "browse:home_section") == nil)
110+
#expect(self.cache.get(key: "next:song_abc123") == nil)
111+
#expect(self.cache.get(key: "browse:home_section") == nil)
112112
}
113113

114114
@Test("Cache entry isExpired property")

Tests/KasetTests/ArtistDetailTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Testing
33
@testable import Kaset
44

55
/// Tests for ArtistDetail.
6-
@Suite
6+
@Suite("ArtistDetail", .tags(.viewModel))
77
struct ArtistDetailTests {
88
@Test("ArtistDetail initialization")
99
func artistDetailInit() {

Tests/KasetTests/AuthServiceTests.swift

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Testing
33
@testable import Kaset
44

55
/// Tests for AuthService.
6-
@Suite(.serialized)
6+
@Suite("AuthService", .serialized, .tags(.service))
77
@MainActor
88
struct AuthServiceTests {
99
var authService: AuthService
@@ -16,60 +16,60 @@ struct AuthServiceTests {
1616

1717
@Test("Initial state is initializing")
1818
func initialState() {
19-
#expect(authService.state == .initializing)
20-
#expect(authService.needsReauth == false)
19+
#expect(self.authService.state == .initializing)
20+
#expect(self.authService.needsReauth == false)
2121
}
2222

2323
@Test("State isInitializing property")
2424
func isInitializing() {
25-
#expect(authService.state.isInitializing == true)
26-
#expect(authService.state.isLoggedIn == false)
25+
#expect(self.authService.state.isInitializing == true)
26+
#expect(self.authService.state.isLoggedIn == false)
2727

28-
authService.completeLogin(sapisid: "test")
29-
#expect(authService.state.isInitializing == false)
30-
#expect(authService.state.isLoggedIn == true)
28+
self.authService.completeLogin(sapisid: "test")
29+
#expect(self.authService.state.isInitializing == false)
30+
#expect(self.authService.state.isLoggedIn == true)
3131
}
3232

3333
@Test("Start login transitions to loggingIn state")
3434
func startLogin() {
35-
authService.startLogin()
36-
#expect(authService.state == .loggingIn)
35+
self.authService.startLogin()
36+
#expect(self.authService.state == .loggingIn)
3737
}
3838

3939
@Test("Complete login transitions to loggedIn state")
4040
func completeLogin() {
41-
authService.completeLogin(sapisid: "test-sapisid")
42-
#expect(authService.state == .loggedIn(sapisid: "test-sapisid"))
43-
#expect(authService.needsReauth == false)
41+
self.authService.completeLogin(sapisid: "test-sapisid")
42+
#expect(self.authService.state == .loggedIn(sapisid: "test-sapisid"))
43+
#expect(self.authService.needsReauth == false)
4444
}
4545

4646
@Test("Session expired transitions to loggedOut and sets needsReauth")
4747
func sessionExpired() {
48-
authService.completeLogin(sapisid: "test-sapisid")
49-
authService.sessionExpired()
48+
self.authService.completeLogin(sapisid: "test-sapisid")
49+
self.authService.sessionExpired()
5050

51-
#expect(authService.state == .loggedOut)
52-
#expect(authService.needsReauth == true)
51+
#expect(self.authService.state == .loggedOut)
52+
#expect(self.authService.needsReauth == true)
5353
}
5454

5555
@Test("State isLoggedIn property")
5656
func stateIsLoggedIn() {
57-
#expect(authService.state.isLoggedIn == false)
57+
#expect(self.authService.state.isLoggedIn == false)
5858

59-
authService.completeLogin(sapisid: "test")
60-
#expect(authService.state.isLoggedIn == true)
59+
self.authService.completeLogin(sapisid: "test")
60+
#expect(self.authService.state.isLoggedIn == true)
6161
}
6262

6363
@Test("Sign out clears state and calls mock")
6464
func signOut() async {
65-
authService.completeLogin(sapisid: "test-sapisid")
66-
authService.needsReauth = true
65+
self.authService.completeLogin(sapisid: "test-sapisid")
66+
self.authService.needsReauth = true
6767

68-
await authService.signOut()
68+
await self.authService.signOut()
6969

70-
#expect(authService.state == .loggedOut)
71-
#expect(authService.needsReauth == false)
72-
#expect(mockWebKitManager.clearAllDataCalled == true)
70+
#expect(self.authService.state == .loggedOut)
71+
#expect(self.authService.needsReauth == false)
72+
#expect(self.mockWebKitManager.clearAllDataCalled == true)
7373
}
7474

7575
@Test("State equality")

0 commit comments

Comments
 (0)