Skip to content

Commit 5d96e6b

Browse files
pblazejclaude
andauthored
Migrate tests to Swift Testing (#922)
- Better support for concurrency - Explicit serialization, tags, etc. - Parametrization --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d111465 commit 5d96e6b

78 files changed

Lines changed: 2854 additions & 3453 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yaml

Lines changed: 21 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,8 @@ concurrency:
1616
cancel-in-progress: true
1717

1818
env:
19-
TEST_TARGETS: "LiveKitCoreTests LiveKitObjCTests"
20-
TEST_TIMEOUT_MINUTES: 15
21-
TEST_RETRY_ATTEMPTS: 2
19+
TEST_TIMEOUT_MINUTES: 30
20+
TEST_RETRY_ATTEMPTS: 3
2221

2322
jobs:
2423
build-and-test:
@@ -92,6 +91,9 @@ jobs:
9291

9392
runs-on: ${{ matrix.os }}
9493
timeout-minutes: 60
94+
env:
95+
LIBDISPATCH_COOPERATIVE_POOL_STRICT: ${{ matrix.strict-concurrency-env == true && '1' || '0' }}
96+
NSUnbufferedIO: "YES"
9597
defaults:
9698
run:
9799
shell: bash
@@ -115,44 +117,22 @@ jobs:
115117
- name: Swift Version
116118
run: xcrun swift --version
117119

118-
- name: Build for Testing
119-
run: |
120-
set -o pipefail && xcodebuild build-for-testing \
121-
-scheme LiveKit \
122-
-destination 'platform=${{ matrix.platform }}' \
123-
-enableAddressSanitizer ${{ matrix.asan == true && 'YES' || 'NO' }} \
124-
-enableThreadSanitizer ${{ matrix.tsan == true && 'YES' || 'NO' }} \
125-
APPLICATION_EXTENSION_API_ONLY=${{ matrix.extension-api-only == true && 'YES' || 'NO' }} \
126-
| xcbeautify --renderer github-actions
127-
128-
- name: Run Tests
129-
uses: nick-fields/retry@v3
130-
env:
131-
LIBDISPATCH_COOPERATIVE_POOL_STRICT: ${{ matrix.strict-concurrency-env == true && '1' || '0' }}
120+
- name: Build & Test
121+
uses: nick-fields/retry@v4
132122
with:
133123
timeout_minutes: ${{ env.TEST_TIMEOUT_MINUTES }}
134124
max_attempts: ${{ env.TEST_RETRY_ATTEMPTS }}
135125
command: |
136-
set -euo pipefail
137-
IFS=' ' read -r -a tests <<< "${{ env.TEST_TARGETS }}"
138-
139-
for test in "${tests[@]}"; do
140-
echo "::group::$test"
141-
if ! xcodebuild test-without-building \
142-
-scheme LiveKit \
143-
-destination 'platform=${{ matrix.platform }}' \
144-
-enableAddressSanitizer ${{ matrix.asan == true && 'YES' || 'NO' }} \
145-
-enableThreadSanitizer ${{ matrix.tsan == true && 'YES' || 'NO' }} \
146-
APPLICATION_EXTENSION_API_ONLY=${{ matrix.extension-api-only == true && 'YES' || 'NO' }} \
147-
-only-testing:$test \
148-
-parallel-testing-enabled NO \
149-
| xcbeautify --renderer github-actions
150-
then
151-
echo "::error::Test failed: $test"
152-
exit 1
153-
fi
154-
echo "::endgroup::"
155-
done
126+
set -o pipefail && xcodebuild test \
127+
-scheme LiveKit \
128+
-destination 'platform=${{ matrix.platform }}' \
129+
-enableAddressSanitizer ${{ matrix.asan == true && 'YES' || 'NO' }} \
130+
-enableThreadSanitizer ${{ matrix.tsan == true && 'YES' || 'NO' }} \
131+
APPLICATION_EXTENSION_API_ONLY=${{ matrix.extension-api-only == true && 'YES' || 'NO' }} \
132+
-only-testing:LiveKitCoreTests \
133+
-only-testing:LiveKitObjCTests \
134+
-parallel-testing-enabled NO \
135+
| xcbeautify --renderer github-actions
156136
157137
- name: Build for Release
158138
if: ${{ matrix.symbol-graph }}
@@ -170,7 +150,7 @@ jobs:
170150
171151
- name: Upload Symbol Graph
172152
if: ${{ matrix.symbol-graph }}
173-
uses: actions/upload-artifact@v4
153+
uses: actions/upload-artifact@v7
174154
with:
175155
name: symbol-graph-${{ matrix.platform }}
176156
path: symbol-graph
@@ -200,7 +180,7 @@ jobs:
200180
sparse-checkout: Sources/LiveKit/LiveKit.docc
201181

202182
- name: Download Symbol Graphs
203-
uses: actions/download-artifact@v4
183+
uses: actions/download-artifact@v8
204184
with:
205185
pattern: symbol-graph-*
206186
path: symbol-graphs
@@ -221,7 +201,7 @@ jobs:
221201
run: zip -r docs.zip docs
222202

223203
- name: Upload Archive
224-
uses: actions/upload-artifact@v4
204+
uses: actions/upload-artifact@v7
225205
with:
226206
name: docs
227207
path: docs.zip
@@ -275,7 +255,7 @@ jobs:
275255
276256
- name: Comment on PR
277257
if: steps.check-changes.outputs.has_changes == 'false'
278-
uses: mshick/add-pr-comment@v2
258+
uses: mshick/add-pr-comment@v3
279259
with:
280260
message: |
281261
⚠️ This PR does not contain any files in the `.changes` directory.

.swiftlint.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ type_name:
2222
- OS
2323

2424
custom_rules:
25+
no_xctest_in_swift_tests:
26+
name: "No XCTest in Swift Tests"
27+
regex: "^import XCTest"
28+
message: "Use Swift Testing (import Testing) instead of XCTest in Swift test targets."
29+
severity: error
30+
included:
31+
- "Tests/LiveKitCoreTests/.*\\.swift"
32+
- "Tests/LiveKitAudioTests/.*\\.swift"
2533
no_manual_task_management:
2634
name: "No Manual Task Management"
2735
regex: "(let|var)\\s+\\w+\\s*:\\s*Task<Void,"

AGENTS.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,13 @@ Usage notes:
174174

175175
<skill>
176176
<name>swift-concurrency</name>
177-
<description>'Expert guidance on Swift Concurrency best practices, patterns, and implementation. Use when developers mention: (1) Swift Concurrency, async/await, actors, or tasks, (2) "use Swift Concurrency" or "modern concurrency patterns", (3) migrating to Swift 6, (4) data races or thread safety issues, (5) refactoring closures to async/await, (6) @MainActor, Sendable, or actor isolation, (7) concurrent code architecture or performance optimization, (8) concurrency-related linter warnings (SwiftLint or similar; e.g. async_without_await, Sendable/actor isolation/MainActor lint).'</description>
177+
<description>'Diagnose data races, convert callback-based code to async/await, implement actor isolation patterns, resolve Sendable conformance issues, and guide Swift 6 migration. Use when developers mention: (1) Swift Concurrency, async/await, actors, or tasks, (2) "use Swift Concurrency" or "modern concurrency patterns", (3) migrating to Swift 6, (4) data races or thread safety issues, (5) refactoring closures to async/await, (6) @MainActor, Sendable, or actor isolation, (7) concurrent code architecture or performance optimization, (8) concurrency-related linter warnings (SwiftLint or similar; e.g. async_without_await, Sendable/actor isolation/MainActor lint).'</description>
178+
<location>global</location>
179+
</skill>
180+
181+
<skill>
182+
<name>swift-testing-expert</name>
183+
<description>'Expert guidance for Swift Testing: test structure, #expect/#require macros, traits and tags, parameterized tests, test plans, parallel execution, async waiting patterns, and XCTest migration. Use when writing new Swift tests, modernizing XCTest suites, debugging flaky tests, or improving test quality and maintainability in Apple-platform or Swift server projects.'</description>
178184
<location>global</location>
179185
</skill>
180186

Tests/LiveKitAudioTests/AudioEngineAvailability.swift

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,34 +16,35 @@
1616

1717
@preconcurrency import AVFoundation
1818
@testable import LiveKit
19+
import Testing
1920
#if canImport(LiveKitTestSupport)
2021
import LiveKitTestSupport
2122
#endif
2223

23-
class AudioEngineAvailabilityTests: LKTestCase {
24+
@Suite(.serialized, .tags(.audio)) struct AudioEngineAvailabilityTests {
2425
// Check if audio engine will stop when availability is set to .none,
2526
// then resume (restart) when availability is set back to .default.
26-
func testRecording() async throws {
27+
@Test func recording() throws {
2728
// Test without enabling VP
2829
try AudioManager.shared.setVoiceProcessingEnabled(false)
2930

3031
// First check
31-
XCTAssertFalse(AudioManager.shared.isEngineRunning)
32+
#expect(!AudioManager.shared.isEngineRunning)
3233

3334
// Start
3435
try AudioManager.shared.startLocalRecording()
35-
XCTAssertTrue(AudioManager.shared.isEngineRunning)
36+
#expect(AudioManager.shared.isEngineRunning)
3637

3738
// Disable both input & output
3839
try AudioManager.shared.setEngineAvailability(.none)
39-
XCTAssertFalse(AudioManager.shared.isEngineRunning)
40+
#expect(!AudioManager.shared.isEngineRunning)
4041

4142
// Re-enable both input & output (default)
4243
try AudioManager.shared.setEngineAvailability(.default)
43-
XCTAssertTrue(AudioManager.shared.isEngineRunning)
44+
#expect(AudioManager.shared.isEngineRunning)
4445

4546
// Stop
4647
try AudioManager.shared.stopLocalRecording()
47-
XCTAssertFalse(AudioManager.shared.isEngineRunning)
48+
#expect(!AudioManager.shared.isEngineRunning)
4849
}
4950
}

Tests/LiveKitAudioTests/AudioEngineObserver.swift

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
@preconcurrency import AVFoundation
1818
@testable import LiveKit
19+
import Testing
1920
#if canImport(LiveKitTestSupport)
2021
import LiveKitTestSupport
2122
#endif
@@ -33,11 +34,10 @@ final class TestEngineObserver: AudioEngineObserver, @unchecked Sendable {
3334
}
3435
}
3536

36-
class AudioEngineObserverTests: LKTestCase {
37+
@Suite(.serialized, .tags(.audio)) struct AudioEngineObserverTests {
3738
// Error codes returned in an `AudioEngineObserver` should propagate through the WebRTC's AudioDeviceModule and
3839
// the SDK should throw in such cases for both device and manual rendering modes.
39-
func testObserverFail() async throws {
40-
//
40+
@Test func observerFail() throws {
4141
let testObserver = TestEngineObserver()
4242

4343
// Set test engine observer
@@ -47,7 +47,7 @@ class AudioEngineObserverTests: LKTestCase {
4747
try AudioManager.shared.setVoiceProcessingEnabled(false)
4848

4949
// First check
50-
XCTAssertFalse(AudioManager.shared.isEngineRunning)
50+
#expect(!AudioManager.shared.isEngineRunning)
5151

5252
#if os(iOS) || os(visionOS) || os(tvOS)
5353
try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playAndRecord)
@@ -57,24 +57,24 @@ class AudioEngineObserverTests: LKTestCase {
5757

5858
// Attempt to start
5959
try AudioManager.shared.startLocalRecording()
60-
XCTAssertTrue(AudioManager.shared.isEngineRunning)
60+
#expect(AudioManager.shared.isEngineRunning)
6161

6262
testObserver.shouldSucceed = false
6363

6464
// Stop
65-
XCTAssertThrowsError(try AudioManager.shared.stopLocalRecording())
66-
XCTAssertFalse(AudioManager.shared.isEngineRunning)
65+
#expect(throws: (any Error).self) { try AudioManager.shared.stopLocalRecording() }
66+
#expect(!AudioManager.shared.isEngineRunning)
6767

6868
testObserver.shouldSucceed = true
6969

7070
try AudioManager.shared.stopLocalRecording()
71-
XCTAssertFalse(AudioManager.shared.isEngineRunning)
71+
#expect(!AudioManager.shared.isEngineRunning)
7272

7373
testObserver.shouldSucceed = false
7474

7575
// Attempt to start, should fail
76-
XCTAssertThrowsError(try AudioManager.shared.startLocalRecording())
77-
XCTAssertFalse(AudioManager.shared.isEngineRunning)
76+
#expect(throws: (any Error).self) { try AudioManager.shared.startLocalRecording() }
77+
#expect(!AudioManager.shared.isEngineRunning)
7878

7979
// Switch to manual mode
8080
try AudioManager.shared.setManualRenderingMode(true)
@@ -83,6 +83,6 @@ class AudioEngineObserverTests: LKTestCase {
8383

8484
// Attempt to start
8585
try AudioManager.shared.startLocalRecording()
86-
XCTAssertFalse(AudioManager.shared.isEngineRunning)
86+
#expect(!AudioManager.shared.isEngineRunning)
8787
}
8888
}

Tests/LiveKitAudioTests/AudioEnginePermission.swift

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,38 +16,39 @@
1616

1717
@preconcurrency import AVFoundation
1818
@testable import LiveKit
19+
import Testing
1920
#if canImport(LiveKitTestSupport)
2021
import LiveKitTestSupport
2122
#endif
2223

23-
class AudioEnginePermissionTests: LKTestCase {
24+
@Suite(.serialized, .tags(.audio)) struct AudioEnginePermissionTests {
2425
#if os(iOS) || os(visionOS) || os(tvOS)
2526
// Check if audio engine will fail to start instead of crashing when `AVAudioSession.category` isn't
2627
// configured correctly. Only for non-macOS platforms.
27-
func testAudioSessionPermission() async throws {
28+
@Test func audioSessionPermission() throws {
2829
// Test without enabling VP
2930
try AudioManager.shared.setVoiceProcessingEnabled(false)
3031

3132
// First check
32-
XCTAssertFalse(AudioManager.shared.isEngineRunning)
33+
#expect(!AudioManager.shared.isEngineRunning)
3334

3435
// Set no engine observer
3536
AudioManager.shared.set(engineObservers: [])
3637

3738
// Attempt to start, should fail
38-
XCTAssertThrowsError(try AudioManager.shared.startLocalRecording())
39-
XCTAssertFalse(AudioManager.shared.isEngineRunning)
39+
#expect(throws: (any Error).self) { try AudioManager.shared.startLocalRecording() }
40+
#expect(!AudioManager.shared.isEngineRunning)
4041

4142
// Set audio session engine observers
4243
AudioManager.shared.set(engineObservers: [AudioSessionEngineObserver()])
4344

4445
// Attempt to start
4546
try AudioManager.shared.startLocalRecording()
46-
XCTAssertTrue(AudioManager.shared.isEngineRunning)
47+
#expect(AudioManager.shared.isEngineRunning)
4748

4849
// Stop
4950
try AudioManager.shared.stopLocalRecording()
50-
XCTAssertFalse(AudioManager.shared.isEngineRunning)
51+
#expect(!AudioManager.shared.isEngineRunning)
5152

5253
print("Category: \(AVAudioSession.sharedInstance().category)")
5354
}

0 commit comments

Comments
 (0)