Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

Swift Testing - iPlayground

Chiaote Ni
November 12, 2024

Swift Testing - iPlayground

This presentation, titled ‘Swift Testing,’ was delivered at the iPlayground iOS conference and was a collaborative effort between Chiaote Ni and Sih OuYang. Our session dives into Swift Testing, exploring its capabilities to improve test expressiveness and readability. We cover topics like parameterized testing, asynchronous results, and integration with Combine.

Chiaote Ni

November 12, 2024
Tweet

More Decks by Chiaote Ni

Other Decks in Programming

Transcript

  1. class ImageEditorViewModelTests: XCTestCase { func MagicEffect_Processing_ExpectPreviewImageEventTriggered() { } } test

    https://osherove.com/blog/2005/4/3/naming-standards-for-unit-tests.html Unit of work State under test Expected Behavior
  2. #expect(_ condition:) Check that expectation has passed after a condition

    has been evaluated #require(_ condition:) Check that expectation has passed after a condition has been evaluated 
 and throw and error if it failed XCTAssertEqual(x, y) #expect(x == y) XCTAssertNil(x) #expect(x == nil) XCTAssertGreaterThan(x, y) #expect(x > y) … … ➡
  3. #expect( onBackCalled, "Expected onBack to be called when back action

    is received" ) @Test func backAction() async throws { } // ... #expect(sut.hasSetup) // ...
  4. guard sut.hasSetup else { Issue.record("view model not setup") return }

    #expect( onBackCalled, "Expected onBack to be called when back action is received" ) @Test func backAction() async throws { } // ... // ...
  5. #expect( onBackCalled, "Expected onBack to be called when back action

    is received" ) // Halting test after failure try #require(sut.hasSetup) @Test func backAction() async throws { } // ...
  6. @Test(arguments: .allCases) func previewImageEventFire(from tool: ) async throws { }

    ImageEditorTools ImageEditorTool enum ImageEditorTools: CaseIterable { case magicEffects case removal case blur case blemish }
  7. @Test(arguments: .allCases) func previewImageEventFire(from tool: ) async throws { }

    enum ImageEditorTools: CaseIterable { case magicEffects case removal case blur case blemish } ImageEditorTools ImageEditorTool
  8. public static func __function< >( named testFunctionName: String, in containingType:

    Any.Type?, xcTestCompatibleSelector: __XCTestCompatibleSelector?, displayName: String? = nil, traits: [any TestTrait], sourceLocation: SourceLocation, parameters paramTuples: [__Parameter], testFunction: @escaping @Sendable (C.Element) async throws -> Void ) -> { let containingTypeInfo = containingType.map(TypeInfo.init(describing:)) let parameters = paramTuples.parameters let caseGenerator = { @Sendable in } return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceLocation: sourceLocation, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters) } Case.Generator(arguments: try await collection(), parameters: parameters, testFunction: testFunction) arguments collection: @escaping @Sendable () async throws -> C, C Self where C: Collection & Sendable, C.Element: Sendable
  9. public static func __function< >( named testFunctionName: String, in containingType:

    Any.Type?, xcTestCompatibleSelector: __XCTestCompatibleSelector?, displayName: String? = nil, traits: [any TestTrait], sourceLocation: SourceLocation, parameters paramTuples: [__Parameter], testFunction: @escaping @Sendable (C.Element) async throws -> Void ) -> { let containingTypeInfo = containingType.map(TypeInfo.init(describing:)) let parameters = paramTuples.parameters let caseGenerator = { @Sendable in } return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceLocation: sourceLocation, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters) } Case.Generator(arguments: try await collection(), parameters: parameters, testFunction: testFunction) arguments collection: @escaping @Sendable () async throws -> C, C Self where C: Collection & Sendable, C.Element: Sendable
  10. @Test(arguments: OverlayEffect.allCases, FilterEffect.allCases) func applyOverlayWithFilter( overlay: OverlayEffect, filter: FilterEffect )

    async throws { } enum OverlayEffect: CaseIterable { case overlay1 case overlay2 } enum FilterEffect: CaseIterable { case filter1 case filter2 case filter3 }
  11. public static func __function< >( named testFunctionName: String, in containingType:

    Any.Type?, xcTestCompatibleSelector: __XCTestCompatibleSelector?, displayName: String? = nil, traits: [any TestTrait], arguments collection1: @escaping @Sendable () async throws -> C1, _ collection2: @escaping @Sendable () async throws -> C2, sourceLocation: SourceLocation, parameters paramTuples: [__Parameter], testFunction: @escaping @Sendable (C1.Element, C2.Element) async throws -> Void ) -> Self where , C1.Element: Sendable, , C2.Element: Sendable { let containingTypeInfo = containingType.map(TypeInfo.init(describing:)) let parameters = paramTuples.parameters let caseGenerator = { @Sendable in } return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceLocation: sourceLocation, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters) } C1, C2 try await Case.Generator(arguments: collection1(), collection2(), parameters: parameters, testFunction: testFunction) C1: Collection & Sendable C2: Collection & Sendable
  12. init<C1, C2>( arguments collection1: C1, _ collection2: C2, parameters: [Test.Parameter],

    testFunction: @escaping @Sendable (C1.Element, C2.Element) async throws -> Void ) where S == CartesianProduct<C1, C2> { self.init(sequence: ) { element in Test.Case(values: [element.0, element.1], parameters: parameters) { try await testFunction(element.0, element.1) } } } cartesianProduct(collection1, collection2) https://zh.wikipedia.org/
  13. init<C1, C2>( arguments collection1: C1, _ collection2: C2, parameters: [Test.Parameter],

    testFunction: @escaping @Sendable (C1.Element, C2.Element) async throws -> Void ) where S == CartesianProduct<C1, C2> { self.init(sequence: ) { element in Test.Case(values: [element.0, element.1], parameters: parameters) { try await testFunction(element.0, element.1) } } } cartesianProduct(collection1, collection2) Cartesian https://zh.wikipedia.org/ (z,1) (z,2) (z,3) (y,1) (y,2) (y,3) (x,1) (x,2) (x,3) 1 2 3 z y x B A A×B
  14. @Test(arguments: OverlayEffect.allCases, FilterEffect.allCases) func applyOverlayWithFilter( overlay: OverlayEffect, filter: FilterEffect )

    async throws { } enum OverlayEffect: CaseIterable { case overlay1 case overlay2 } enum FilterEffect: CaseIterable { case filter1 case filter2 case filter3 }
  15. public static func __function< >( named testFunctionName: String, in containingType:

    Any.Type?, xcTestCompatibleSelector: __XCTestCompatibleSelector?, displayName: String? = nil, traits: [any TestTrait], , sourceLocation: SourceLocation, parameters paramTuples: [__Parameter], testFunction: @escaping @Sendable (C1.Element, C2.Element) async throws -> Void ) -> Self where , C1.Element: Sendable, , C2.Element: Sendable { let containingTypeInfo = containingType.map(TypeInfo.init(describing:)) let parameters = paramTuples.parameters let caseGenerator = { @Sendable in } return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceLocation: sourceLocation, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters) } Case.Generator(arguments: try await zippedCollections(), parameters: parameters) { try await testFunction($0, $1) } arguments zippedCollections: @escaping @Sendable () async throws -> Zip2Sequence<C1, C2>, C1: Collection & Sendable C2: Collection & Sendable C1, C2
  16. @Test(arguments: [ (OverlayEffect.overlay1, FilterEffect.filter2, MagicEffect.effect3), (OverlayEffect.overlay2, FilterEffect.filter3, MagicEffect.effect1), (OverlayEffect.overlay3, FilterEffect.filter1,

    MagicEffect.effect2), (OverlayEffect.overlay1, FilterEffect.filter3, MagicEffect.effect2) ]) func combination(c1: OverlayEffect, c2: FilterEffect, c3: MagicEffect) { }
  17. 􀫌 To support running selected test cases, argument should at

    least follow one of the listed protocol: • CustomTestArgumentEncodable • RawRepresentable, where RawValue conforms to Encodable • Encodable • Identi fi able, where ID conforms to Encodable
  18. extension Tag { @Tag static var binding: Self @Tag static

    var imageProcessing: Self @Tag static var toolBehavior: Self @Tag static var encoder: Self @Tag static var decoder: Self } // Must be declared as a static member of Tag
  19. extension Trait where Self == Testing.ParallelizationTrait { /// A trait

    that serializes the test to which it is applied. /// /// ## See Also /// /// - ``ParallelizationTrait`` public static var serialized: Testing.ParallelizationTrait { get } }
  20. func testGeneratePathTuplesCorrectlyWithInputVarietyPoints() throws { let spy = SketchEditorDisplayingSpy() sut.displayer =

    spy let points: [CGPoint] = [ CGPoint(x: 0, y: 0), CGPoint(x: 10, y: 0), CGPoint(x: 10, y: 10), CGPoint(x: 0, y: 10), CGPoint(x: 0, y: 0) ] let rect = CGRect( origin: .zero, size: CGSize(width: 1, height: 1) ) var respectedTuples: NSMutableArray = [] // 1 touch - expect 1 point tuple sut.startTouching(points[0], in: rect) sut.touchEnd() respectedTuples = [NSValue(cgPoint: points[0])] XCTAssertEqual(spy.storageStroke?.pathTuples, respectedTuples) // 2 touches - expect 2 point tuples sut.startTouching(points[0], in: rect) sut.touchesMoved(points[1], in: rect) sut.touchEnd() respectedTuples = [ NSValue(cgPoint: points[0]), NSValue(cgPoint: points[1]) ] XCTAssertEqual(spy.storageStroke?.pathTuples, respectedTuples) // 3 touches - expect 1 point and 1 array(2) tuples sut.startTouching(points[0], in: rect) sut.touchesMoved(points[1], in: rect) sut.touchesMoved(points[2], in: rect) sut.touchEnd() respectedTuples = [ NSValue(cgPoint: points[0]), [ NSValue(cgPoint: points[1]), NSValue(cgPoint: points[2]) ] ] XCTAssertEqual(spy.storageStroke?.pathTuples, respectedTuples) // 4 touches - expect 1 point and 1 array(3) tuples sut.startTouching(points[0], in: rect) sut.touchesMoved(points[1], in: rect) sut.touchesMoved(points[2], in: rect) sut.touchesMoved(points[3], in: rect) sut.touchEnd() respectedTuples = [ NSValue(cgPoint: points[0]), [ NSValue(cgPoint: points[1]), NSValue(cgPoint: points[2]), NSValue(cgPoint: points[3]) ] ] XCTAssertEqual(spy.storageStroke?.pathTuples, respectedTuples) // 5 touches - expect 2 point and 1 array(3) tuples sut.startTouching(points[0], in: rect) sut.touchesMoved(points[1], in: rect) sut.touchesMoved(points[2], in: rect) sut.touchesMoved(points[3], in: rect) sut.touchesMoved(points[4], in: rect) sut.touchEnd() // In the current behavior, we'll adjust the 4th point to be the average of the 3rd and 5th. let forthPoint = CGPoint( x: (points[2].x + points[4].x) / 2, y: (points[2].y + points[4].y) / 2 ) respectedTuples = [ NSValue(cgPoint: points[0]), [ NSValue(cgPoint: points[1]), NSValue(cgPoint: points[2]), NSValue(cgPoint: forthPoint) ], NSValue(cgPoint: points[4]) ] XCTAssertEqual(spy.storageStroke?.pathTuples, respectedTuples) } And so on …
  21. Arguments // 1 touch - expect 1 point tuple sut.startTouching(points[0],

    in: rect) sut.touchEnd() respectedTuples = [NSValue(cgPoint: points[0])] XCTAssertEqual(spy.storageStroke?.pathTuples, respectedTuples) // 3 touches - expect 1 point and 1 array(2) tuples sut.startTouching(points[0], in: rect) sut.touchesMoved(points[1], in: rect) sut.touchesMoved(points[2], in: rect) sut.touchEnd() respectedTuples = [ NSValue(cgPoint: points[0]), [ NSValue(cgPoint: points[1]), NSValue(cgPoint: points[2]) ] ] XCTAssertEqual( spy.storageStroke?.pathTuples, respectedTuples ) // 2 touches - expect 2 point tuples sut.startTouching(points[0], in: rect) sut.touchesMoved(points[1], in: rect) sut.touchEnd() respectedTuples = [ NSValue(cgPoint: points[0]), NSValue(cgPoint: points[1]) ] XCTAssertEqual( spy.storageStroke?.pathTuples, respectedTuples )
  22. Arguments // 1 touch - expect 1 point tuple sut.startTouching(points[0],

    in: rect) sut.touchEnd() respectedTuples = [NSValue(cgPoint: points[0])] XCTAssertEqual(spy.storageStroke?.pathTuples, respectedTuples) // 3 touches - expect 1 point and 1 array(2) tuples sut.startTouching(points[0], in: rect) sut.touchesMoved(points[1], in: rect) sut.touchesMoved(points[2], in: rect) sut.touchEnd() respectedTuples = [ NSValue(cgPoint: points[0]), [ NSValue(cgPoint: points[1]), NSValue(cgPoint: points[2]) ] ] XCTAssertEqual( spy.storageStroke?.pathTuples, respectedTuples ) // 2 touches - expect 2 point tuples sut.startTouching(points[0], in: rect) sut.touchesMoved(points[1], in: rect) sut.touchEnd() respectedTuples = [ NSValue(cgPoint: points[0]), NSValue(cgPoint: points[1]) ] XCTAssertEqual( spy.storageStroke?.pathTuples, respectedTuples ) - - - // 4 touches - expect 1 point and 1 array(3) tuples sut.startTouching(points[0], in: rect) sut.touchesMoved(points[1], in: rect) sut.touchesMoved(points[2], in: rect) sut.touchesMoved(points[3], in: rect) sut.touchEnd() respectedTuples = [ NSValue(cgPoint: points[0]), [ NSValue(cgPoint: points[1]), NSValue(cgPoint: points[2]), NSValue(cgPoint: points[3]) ] ] XCTAssertEqual(spy.storageStroke?.pathTuples, respectedTuples) // 5 touches - expect 2 point and 1 array(3) tuples sut.startTouching(points[0], in: rect) sut.touchesMoved(points[1], in: rect) sut.touchesMoved(points[2], in: rect) sut.touchesMoved(points[3], in: rect) sut.touchesMoved(points[4], in: rect) sut.touchEnd() // In the current behavior, we'll adjust the 4th point to be the average of the 3 let forthPoint = CGPoint( x: (points[2].x + points[4].x) / 2, y: (points[2].y + points[4].y) / 2 ) respectedTuples = [ NSValue(cgPoint: points[0]), [ NSValue(cgPoint: points[1]), NSValue(cgPoint: points[2]), NSValue(cgPoint: forthPoint) ],
  23. Arguments @Test( "Generate path tuples correctly with input variety points",

    arguments: PathTupleTestCase.allCases ) func generatePathTuples(for testCase: PathTupleTestCase) { let rect = CGRect( origin: .zero, size: CGSize(width: 1, height: 1) ) sut.startTouching(testCase.points[0], in: rect) for point in testCase.points.dropFirst() { sut.touchesMoved(point, in: rect) } sut.touchEnd() #expect(spy.storageStroke?.pathTuples == testCase.expectedTuples) }
  24. Arguments @Test( "Generate path tuples correctly with input variety points",

    arguments: PathTupleTestCase.allCases ) func generatePathTuples(for testCase: PathTupleTestCase) { let rect = CGRect( origin: .zero, size: CGSize(width: 1, height: 1) ) sut.startTouching(testCase.points[0], in: rect) for point in testCase.points.dropFirst() { sut.touchesMoved(point, in: rect) } sut.touchEnd() #expect(spy.storageStroke?.pathTuples == testCase.expectedTuples) } enum PathTupleTestCase: CaseIterable { case singleTouch case twoTouches case threeTouches case fourTouches case fiveTouches var points: [CGPoint] {…} var expectedTuples: NSMutableArray {…} private var numberOfPoints: Int {…} var description: String {…} }
  25. Arguments enum PathTupleTestCase: CaseIterable, CustomStringConvertible { case singleTouch case twoTouches

    case threeTouches case fourTouches case fiveTouches } var description: String { switch self { case .singleTouch: return "Single touch ➡ expect 1 point tuple" case .twoTouches: return "Two touches ➡ expect 2 point tuples" case .threeTouches: return "Three touches ➡ expect 1 point and 1 array(2) tuples" case .fourTouches: return "Four touches ➡ expect 1 point and 1 array(3) tuples" case .fiveTouches: return "Five touches ➡ expect 2 point and 1 array(3) tuples" } } CustomStringConvertible
  26. Arguments enum PathTupleTestCase: CaseIterable, CustomStringConvertible { case singleTouch case twoTouches

    case threeTouches case fourTouches case fiveTouches } var description: String { switch self { case .singleTouch: return "Single touch ➡ expect 1 point tuple" case .twoTouches: return "Two touches ➡ expect 2 point tuples" case .threeTouches: return "Three touches ➡ expect 1 point and 1 array(2) tuples" case .fourTouches: return "Four touches ➡ expect 1 point and 1 array(3) tuples" case .fiveTouches: return "Five touches ➡ expect 2 point and 1 array(3) tuples" } } CustomStringConvertible
  27. Con fi rmation Con fi rmations function similarly to the

    expectations API of XCTest, however, they don’t block or suspend the caller while waiting for a condition to be ful fi lled. they don’t block or suspend the caller
  28. • Con fi rm that an event happens / doesn’t

    happen • Supports async/await in its closure • Relies on await to wait for the results • = an 100% alternative to Expectation Con fi rmation
  29. public func confirmation<R>( _ comment: Comment? = nil, expectedCount: some

    RangeExpression<Int> & Sendable, isolation: isolated (any Actor)? = #isolation, sourceLocation: SourceLocation = #_sourceLocation, _ body: (Confirmation) async throws -> sending R ) async rethrows -> R { let confirmation = Confirmation() defer { let actualCount = confirmation.count.rawValue if !expectedCount.contains(actualCount) { let issue = Issue( kind: expectedCount.issueKind(forActualCount: actualCount), comments: Array(comment), sourceContext: .init(backtrace: .current(), sourceLocation: sourceLocation) ) issue.record() } } return try await body(confirmation) }
  30. let result = await withUnsafeContinuation { continuation in someAsyncTask {

    result in continuation.resume(returning: result) } }
  31. let result = await withUnsafeContinuation { continuation in someAsyncTask {

    result in continuation.resume(returning: result) } } let result = await confirmation(expectedCount: 0) { confirmation in let result = await someExecutor.run { event in confirmation() } return result } Con fi rm that an event happens / doesn’t happen
  32. func expectation( timeout: TimeInterval = 1.0, _ condition: @escaping ()

    -> Bool ) async throws { let start = Date() while !condition() { if Date().timeIntervalSince(start) > timeout { throw TestingError.timeout } await Task.yield() } }
  33. var isFinished = false someLongProcessService.process { result in // handle

    result ... isFinished = true } await expectation(timeout: 0.25) { isFinished } #expect( // check the result )
  34. final class TestExpectation { private let description: String private let

    timeout: TimeInterval private var isFulfilled = false init(description: String, timeout: TimeInterval = 1.0) { self.description = description self.timeout = timeout } func fulfill() { isFulfilled = true } func wait() async throws { let start = Date() while !isFulfilled { if Date().timeIntervalSince(start) > timeout { throw TestError.timeout(description: description, duration: timeout) } await Task.yield() } } } final class TestExpectation { private let timeout: TimeInterval private var isFulfilled = false init(description: String, timeout: TimeInterval = 1.0) { } func fulfill() { isFulfilled = true } func wait() async throws { } }
  35. let expectation = TestExpectation( description: "onFinish to be called", timeout:

    0.25 ) someLongProcessService.process { result in // handle result ... expectation.fulfill() } try await expectation.wait() #expect( // check the result )
  36. @Test(.timeLimit(.minutes(1))) ❓ Do not support high-precision, arbitrarily short durations Must

    be at least one minute, and can only be expressed in increments of one minute
  37. System under test (SUT) ➡ ViewModel Subscribe to the output

    (publishers) SUT ⬇ ⬇ Input Output (publishers)
  38. @Test( "Process image action: send processImage ➡ expect redrawImageTrigger fired”

    ) func processImageAction() async throws { let (_, action, input) = createInputs() let output = sut.bind(input, subscriptions: &subscriptions) var redrawTriggered = false output.redrawImageTrigger .sink { _ in redrawTriggered = true } .store(in: &subscriptions) action.send(.processImage) try await expectation { redrawTriggered } #expect(redrawTriggered, "Expected redrawImageTrigger to be called once") } output.redrawImageTrigger .sink { _ in redrawTriggered = true } .store(in: &subscriptions)
  39. @Test( "Process image action: send processImage ➡ expect redrawImageTrigger fired”

    ) func processImageAction() async throws { let (_, action, input) = createInputs() let output = sut.bind(input, subscriptions: &subscriptions) var redrawTriggered = false output.redrawImageTrigger .sink { _ in redrawTriggered = true } .store(in: &subscriptions) action.send(.processImage) try await expectation { redrawTriggered } #expect(redrawTriggered, "Expected redrawImageTrigger to be called once") } output.redrawImageTrigger .sink { _ in redrawTriggered = true } .store(in: &subscriptions) @Suite final class SomeBusinessLogicTests { var sut: SomeBusinessLogic var spy: SomeDisplayingSpy var subscriptions = Set<AnyCancellable>() init() { sut = SomeBusinessController(tweaks: twe spy = SomeDisplayingSpy() sut.displayer = spy } }
  40. @Test( "Process image action: send processImage ➡ expect redrawImageTrigger fired”

    ) func processImageAction() async throws { let (_, action, input) = createInputs() let output = sut.bind(input, subscriptions: &subscriptions) var redrawTriggered = false output.redrawImageTrigger .sink { _ in redrawTriggered = true } .store(in: &subscriptions) action.send(.processImage) try await expectation { redrawTriggered } #expect(redrawTriggered, "Expected redrawImageTrigger to be called once") } var redrawTriggered = false .sink { _ in redrawTriggered = true } try await expectation { redrawTriggered }
  41. final class TestPublisherAsserter { private var subscriptions = Set<AnyCancellable>() func

    waitForFirstEvent<T>(_ publisher: AnyPublisher<T, Never>) async -> T { await withCheckedContinuation { continuation in var cancellable: AnyCancellable? cancellable = publisher .first() .sink { [weak self] value in continuation.resume(returning: value) if let cancellable { self?.subscriptions.remove(cancellable) } } if let cancellable { subscriptions.insert(cancellable) } } }
  42. •Swift Testing enhances the expressiveness and readability of test code.

    •Parameterized testing is very useful •For asynchronous results, you can await the result and then use expect. •Some simple solutions with timeouts & for Combine. 👍
  43. Migrating a test from XCTest
 https://developer.apple.com/documentation/testing/migratingfromxctest Con fi rmation
 https://developer.apple.com/documentation/testing/con

    fi rmation Testing asynchronous code
 https://developer.apple.com/documentation/testing/testing-asynchronous-code Swift-testing
 https://github.com/swiftlang/swift-testing Naming standards for unit tests
 https://osherove.com/blog/2005/4/3/naming-standards-for-unit-tests.html Reference