Slide 1

Slide 1 text

終於可讀了 T v T 一 起來探索 Swift Testing 的魅 力 Chiaote Ni & Sih OuYang

Slide 2

Slide 2 text

XCTest

Slide 3

Slide 3 text

class ImageEditorViewModelTests: XCTestCase { func MagicEffectProcessingExpectPreviewImageEventTriggered() { } } test

Slide 4

Slide 4 text

class ImageEditorViewModelTests: XCTestCase { func MagicEffectProcessingExpectPreviewImageEventTriggered() { } } test XCTAssertEqual(…, …)

Slide 5

Slide 5 text

class ImageEditorViewModelTests: XCTestCase { func MagicEffectProcessingExpectPreviewImageEventTriggered() { } } test XCTAssertEqual(…, …)

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

class ImageEditorViewModelTests: XCTestCase { func MagicEffectProcessingExpectPreviewImageEventTriggered() { } } test Unit of work State under test Expected Behavior

Slide 8

Slide 8 text

No content

Slide 9

Slide 9 text

No content

Slide 10

Slide 10 text

Swift Testing @Suite / @Test / Traits / Parameterized Testing / CustomTestStringConvertible

Slide 11

Slide 11 text

No content

Slide 12

Slide 12 text

Parameterized Testing

Slide 13

Slide 13 text

@Suite struct / class / actor

Slide 14

Slide 14 text

@Suite(_ displayName: _ traits:) @Suite("ImageEditorViewModel Tests") class ImageEditorViewModelTests { }

Slide 15

Slide 15 text

@Test swift fi le / struct / class / actor Suite

Slide 16

Slide 16 text

@Test func backAction() async throws { } func testBackAction() async throws { }

Slide 17

Slide 17 text

func testBackAction() async throws { } @Test func backAction() async throws { }

Slide 18

Slide 18 text

#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) … … ➡

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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 { } // ... // ...

Slide 21

Slide 21 text

#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 { } // ...

Slide 22

Slide 22 text

@Test @Test(_ displayName: _ traits:arguments:) Parameterized Testing


Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

@Test(arguments: .allCases) func previewImageEventFire(from tool: ) async throws { } ImageEditorTools ImageEditorTool

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

@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 }

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

init( arguments collection1: C1, _ collection2: C2, parameters: [Test.Parameter], testFunction: @escaping @Sendable (C1.Element, C2.Element) async throws -> Void ) where S == CartesianProduct { 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/

Slide 31

Slide 31 text

init( arguments collection1: C1, _ collection2: C2, parameters: [Test.Parameter], testFunction: @escaping @Sendable (C1.Element, C2.Element) async throws -> Void ) where S == CartesianProduct { 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

Slide 32

Slide 32 text

@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 }

Slide 33

Slide 33 text

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: Collection & Sendable C2: Collection & Sendable C1, C2

Slide 34

Slide 34 text

@Test(arguments: (OverlayEffect.allCases, FilterEffect.allCases)) func applyOverlayWithFilter( overlay: OverlayEffect, filter: FilterEffect) async throws { } zip

Slide 35

Slide 35 text

@Test(arguments: OverlayEffect.allCases, FilterEffect.allCases, MagicEffect.allCases) func combination(c1: OverlayEffect, c2: FilterEffect, c3: MagicEffect) { } Support more than 2 collections? 🤔

Slide 36

Slide 36 text

@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) { }

Slide 37

Slide 37 text

􀫌 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

Slide 38

Slide 38 text

Traits @Suite(_ displayName: ) @Test(_ displayName: arguments:) _ traits: _ traits:

Slide 39

Slide 39 text

Tag Trait .tag(_ tags:)

Slide 40

Slide 40 text

@Test(.tags(.imageProcessing)) func processImageAction() async throws { }

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

Parallelization Trait

Slide 43

Slide 43 text

@Suite // Parallel by default @Suite(.serialized) // Apply to all the @Test

Slide 44

Slide 44 text

🤔 @Suite(.serialized, .parallel)

Slide 45

Slide 45 text

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 } }

Slide 46

Slide 46 text

Time Limit Trait .timeLimit(_ timeLimit:)

Slide 47

Slide 47 text

@Test( , arguments: ImageEditorTools.allCases) func previewImageEventFire( from argument: ImageEditorTools ) async throws { } .timeLimit(.minutes(1))

Slide 48

Slide 48 text

🤔 @Suite( .timeLimit(.minutes(2)), .timeLimit(.minutes(3)) ) .timeLimit(.minutes(1)),

Slide 49

Slide 49 text

@Suite( .timeLimit(.minutes(2)), .timeLimit(.minutes(3)) ) .timeLimit(.minutes(1)), // The shortest one is used

Slide 50

Slide 50 text

@Suite(.timeLimit(.minutes(1))) class ImageEditorViewModelTests { func finishAction() async throws { } } @Test(.timeLimit(.minutes(2))) 🤔

Slide 51

Slide 51 text

@Suite(.timeLimit(.minutes(1))) class ImageEditorViewModelTests { func finishAction() async throws { } } @Test(.timeLimit(.minutes(2))) // The shortest one is used

Slide 52

Slide 52 text

Real-World Use Cases & Solutions

Slide 53

Slide 53 text

• Parameterized testing • Asynchronous Result • Combine Real-World Use Cases & Solutions

Slide 54

Slide 54 text

Parameterized testing

Slide 55

Slide 55 text

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 …

Slide 56

Slide 56 text

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 )

Slide 57

Slide 57 text

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) ],

Slide 58

Slide 58 text

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) }

Slide 59

Slide 59 text

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 {…} }

Slide 60

Slide 60 text

Arguments

Slide 61

Slide 61 text

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

Slide 62

Slide 62 text

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

Slide 63

Slide 63 text

Asynchronous Result

Slide 64

Slide 64 text

Expectation?

Slide 65

Slide 65 text

Expectation?

Slide 66

Slide 66 text

Expectation? withCheckedContinuation withUnsafeContinuation

Slide 67

Slide 67 text

Expectation? Con fi rmation?

Slide 68

Slide 68 text

Con fi rmation? con fi rmation expectation

Slide 69

Slide 69 text

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

Slide 70

Slide 70 text

• 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

Slide 71

Slide 71 text

public func confirmation( _ comment: Comment? = nil, expectedCount: some RangeExpression & 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) }

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

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

Slide 74

Slide 74 text

Timeout?

Slide 75

Slide 75 text

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() } }

Slide 76

Slide 76 text

var isFinished = false someLongProcessService.process { result in // handle result ... isFinished = true } await expectation(timeout: 0.25) { isFinished } #expect( // check the result )

Slide 77

Slide 77 text

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 { } }

Slide 78

Slide 78 text

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 )

Slide 79

Slide 79 text

@Test(.timeLimit(.minutes(1))) ❓

Slide 80

Slide 80 text

@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

Slide 81

Slide 81 text

Combine?

Slide 82

Slide 82 text

System under test (SUT) ➡ ViewModel Subscribe to the output (publishers) SUT ⬇ ⬇ Input Output (publishers)

Slide 83

Slide 83 text

@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)

Slide 84

Slide 84 text

@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() init() { sut = SomeBusinessController(tweaks: twe spy = SomeDisplayingSpy() sut.displayer = spy } }

Slide 85

Slide 85 text

@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 }

Slide 86

Slide 86 text

final class TestPublisherAsserter { private var subscriptions = Set() func waitForFirstEvent(_ publisher: AnyPublisher) 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) } } }

Slide 87

Slide 87 text

let result = await asserter.waitForFirstEvent(some Publisher) #expect( result == someExpectedResult, “Some description about the error” )

Slide 88

Slide 88 text

Conclusion

Slide 89

Slide 89 text

•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. 👍

Slide 90

Slide 90 text

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

Slide 91

Slide 91 text

No content

Slide 92

Slide 92 text

No content

Slide 93

Slide 93 text

No content

Slide 94

Slide 94 text

No content

Slide 95

Slide 95 text

No content

Slide 96

Slide 96 text

活動時間: 每週 二 pm: 21:30 ~ 10:00 (每 月 第 一 週休) FB GitHub

Slide 97

Slide 97 text

Thanks for your attention