diff --git a/Package.swift b/Package.swift index eafa3a1..0ed615d 100644 --- a/Package.swift +++ b/Package.swift @@ -16,6 +16,10 @@ let package = Package( name: "OneWay", targets: ["OneWay"] ), + .library( + name: "OneWayTesting", + targets: ["OneWayTesting"] + ), ], dependencies: [ .package( @@ -26,19 +30,27 @@ let package = Package( targets: [ .target( name: "OneWay", - dependencies: [], resources: [.copy("PrivacyInfo.xcprivacy")] ), .testTarget( name: "OneWayTests", dependencies: [ "OneWay", + "OneWayTesting", .product( name: "Clocks", package: "swift-clocks" ), ] ), + .target( + name: "OneWayTesting", + dependencies: ["OneWay"] + ), + .testTarget( + name: "OneWayTestingTests", + dependencies: ["OneWayTesting"] + ), ] ) diff --git a/README.md b/README.md index 76facf6..f30f201 100644 --- a/README.md +++ b/README.md @@ -265,9 +265,15 @@ struct CountingReducer: Reducer { ### Testing -**OneWay** provides the `expect` and `xctExpect` functions to help you write concise and clear tests. These functions work asynchronously, allowing you to verify if the state updates as expected. +**OneWay** provides the `expect` function to help you write concise and clear tests. This function works asynchronously, allowing you to verify whether the state updates as expected. -#### When using `Testing` +Before using the `expect` function, make sure to import the **OneWayTesting** module. + +```swift +import OneWayTesting +``` + +#### When using Testing You can use the `expect` function to easily check the state value. @@ -281,16 +287,16 @@ func incrementTwice() async { } ``` -#### When using `XCTest` +#### When using XCTest -The `xctExpect` function is used within an XCTest environment to assert the state value. +The `expect` function is used in the same way within the `XCTest` environment. ```swift func test_incrementTwice() async { await sut.send(.increment) await sut.send(.increment) - await sut.xctExpect(\.count, 2) + await sut.expect(\.count, 2) } ``` diff --git a/Sources/OneWay/OneWay.docc/Articles/Testing.md b/Sources/OneWay/OneWay.docc/Articles/Testing.md index 6d125e2..455bfa4 100644 --- a/Sources/OneWay/OneWay.docc/Articles/Testing.md +++ b/Sources/OneWay/OneWay.docc/Articles/Testing.md @@ -4,7 +4,13 @@ Using OneWay for Unit Testing. ## Overview -**OneWay** provides the `expect` and `xctExpect` functions to help you write concise and clear tests. These functions work asynchronously, allowing you to verify if the state updates as expected. +**OneWay** provides the `expect` function to help you write concise and clear tests. This function works asynchronously, allowing you to verify whether the state updates as expected. + +Before using the `expect` function, make sure to import the **OneWayTesting** module. + +```swift +import OneWayTesting +``` #### When using Testing @@ -22,7 +28,7 @@ func incrementTwice() async { #### When using XCTest -The `xctExpect` function is used within an XCTest environment to assert the state value. +The `expect` function is used in the same way within the `XCTest` environment. ```swift func test_incrementTwice() async { @@ -56,18 +62,18 @@ func test_incrementTwice() async { await sut.send(.increment) await sut.send(.increment) - await sut.xctExpect(\.count, 2, timeout: 5) + await sut.expect(\.count, 2, timeout: 5) } ``` -## Failed Tests +## Diagnosing Issues -When a test fails, the output provides detailed information about the failure, making it easy to diagnose issues. Below are example screenshots showing how a failure appears for both `expect` and `xctExpect` functions. +When a test fails, the output provides detailed information about the failure, making it easier to diagnose the issue. Below are example screenshots showing how a failure appears. -#### Failure with expect +#### When using Testing -![failure with expect](expect-failure.png) +![failure with expect](expect-testing-failure.png) -#### Failure with xctExpect +#### When using XCTest -![failure with xctExpect](xct-expect-failure.png) +![failure with xctExpect](expect-xctest-failure.png) diff --git a/Sources/OneWay/OneWay.docc/Resources/expect-failure.png b/Sources/OneWay/OneWay.docc/Resources/expect-failure.png deleted file mode 100644 index 05ae65f..0000000 Binary files a/Sources/OneWay/OneWay.docc/Resources/expect-failure.png and /dev/null differ diff --git a/Sources/OneWay/OneWay.docc/Resources/expect-failure~dark.png b/Sources/OneWay/OneWay.docc/Resources/expect-failure~dark.png deleted file mode 100644 index 99cc53f..0000000 Binary files a/Sources/OneWay/OneWay.docc/Resources/expect-failure~dark.png and /dev/null differ diff --git a/Sources/OneWay/OneWay.docc/Resources/expect-testing-failure.png b/Sources/OneWay/OneWay.docc/Resources/expect-testing-failure.png new file mode 100644 index 0000000..44bacff Binary files /dev/null and b/Sources/OneWay/OneWay.docc/Resources/expect-testing-failure.png differ diff --git a/Sources/OneWay/OneWay.docc/Resources/expect-testing-failure~dark.png b/Sources/OneWay/OneWay.docc/Resources/expect-testing-failure~dark.png new file mode 100644 index 0000000..c4ce24d Binary files /dev/null and b/Sources/OneWay/OneWay.docc/Resources/expect-testing-failure~dark.png differ diff --git a/Sources/OneWay/OneWay.docc/Resources/expect-xctest-failure.png b/Sources/OneWay/OneWay.docc/Resources/expect-xctest-failure.png new file mode 100644 index 0000000..877c008 Binary files /dev/null and b/Sources/OneWay/OneWay.docc/Resources/expect-xctest-failure.png differ diff --git a/Sources/OneWay/OneWay.docc/Resources/expect-xctest-failure~dark.png b/Sources/OneWay/OneWay.docc/Resources/expect-xctest-failure~dark.png new file mode 100644 index 0000000..e097936 Binary files /dev/null and b/Sources/OneWay/OneWay.docc/Resources/expect-xctest-failure~dark.png differ diff --git a/Sources/OneWay/OneWay.docc/Resources/xct-expect-failure.png b/Sources/OneWay/OneWay.docc/Resources/xct-expect-failure.png deleted file mode 100644 index 327d192..0000000 Binary files a/Sources/OneWay/OneWay.docc/Resources/xct-expect-failure.png and /dev/null differ diff --git a/Sources/OneWay/OneWay.docc/Resources/xct-expect-failure~dark.png b/Sources/OneWay/OneWay.docc/Resources/xct-expect-failure~dark.png deleted file mode 100644 index 2b854b5..0000000 Binary files a/Sources/OneWay/OneWay.docc/Resources/xct-expect-failure~dark.png and /dev/null differ diff --git a/Sources/OneWay/Store.swift b/Sources/OneWay/Store.swift index 197a27b..7f99d60 100644 --- a/Sources/OneWay/Store.swift +++ b/Sources/OneWay/Store.swift @@ -40,10 +40,10 @@ where R.Action: Sendable, R.State: Sendable & Equatable { /// state changes public var states: AsyncStream + package var isIdle: Bool { !isProcessing && tasks.isEmpty } private let reducer: any Reducer private let continuation: AsyncStream.Continuation private var isProcessing: Bool = false - private var isIdle: Bool { !isProcessing && tasks.isEmpty } private var actionQueue: [Action] = [] private var bindingTask: Task? private var tasks: [TaskID: Task] = [:] @@ -150,191 +150,3 @@ private struct EffectIDWrapper: Hashable, @unchecked Sendable { self.id = id } } - -#if canImport(Testing) && canImport(CoreFoundation) -import CoreFoundation -import Testing - -extension Store { - #if swift(>=6) - /// Allows the expectation of a certain property value in the store's state. It compares the - /// current value of the given `keyPath` in the state with an expected `input` value. The - /// function works asynchronously, yielding control to allow other tasks to execute, especially - /// when the store is processing or updating its state. - /// - /// - Parameters: - /// - keyPath: A key path that specifies the property in the `State` to be compared. - /// - input: The expected value of the property at the given key path. - /// - timeout: The maximum amount of time (in seconds) to wait for the store to finish - /// processing before timing out. Defaults to 2 seconds. - /// - sourceLocation: The source location for tracking the test location. - public func expect( - _ keyPath: KeyPath & Sendable, - _ input: Property, - timeout: TimeInterval = 2, - sourceLocation: Testing.SourceLocation = #_sourceLocation - ) async where Property: Sendable & Equatable { - var isTimeout = false - let start = CFAbsoluteTimeGetCurrent() - await Task.detached(priority: .background) { - await Task.yield() - }.value - await Task.yield() - while !isIdle { - await Task.detached(priority: .background) { - await Task.yield() - }.value - await Task.yield() - let elapsedTime = CFAbsoluteTimeGetCurrent() - start - if elapsedTime > timeout { - isTimeout = true - break - } - } - let result = state[keyPath: keyPath] - if isTimeout && result != input { - Issue.record("Exceeded timeout of \(timeout) seconds", sourceLocation: sourceLocation) - } else { - #expect(result == input, sourceLocation: sourceLocation) - } - } - #else - /// Allows the expectation of a certain property value in the store's state. It compares the - /// current value of the given `keyPath` in the state with an expected `input` value. The - /// function works asynchronously, yielding control to allow other tasks to execute, especially - /// when the store is processing or updating its state. - /// - /// - Parameters: - /// - keyPath: A key path that specifies the property in the `State` to be compared. - /// - input: The expected value of the property at the given key path. - /// - timeout: The maximum amount of time (in seconds) to wait for the store to finish - /// processing before timing out. Defaults to 2 seconds. - /// - sourceLocation: The source location for tracking the test location. - public func expect( - _ keyPath: KeyPath, - _ input: Property, - timeout: TimeInterval = 2, - sourceLocation: Testing.SourceLocation = #_sourceLocation - ) async where Property: Sendable & Equatable { - var isTimeout = false - let start = CFAbsoluteTimeGetCurrent() - await Task.detached(priority: .background) { - await Task.yield() - }.value - await Task.yield() - while !isIdle { - await Task.detached(priority: .background) { - await Task.yield() - }.value - await Task.yield() - let elapsedTime = CFAbsoluteTimeGetCurrent() - start - if elapsedTime > timeout { - isTimeout = true - break - } - } - let result = state[keyPath: keyPath] - if isTimeout && result != input { - Issue.record("Exceeded timeout of \(timeout) seconds", sourceLocation: sourceLocation) - } else { - #expect(result == input, sourceLocation: sourceLocation) - } - } - #endif -} -#endif - -#if canImport(XCTest) && canImport(CoreFoundation) -import CoreFoundation -import XCTest - -extension Store { - #if swift(>=6) - /// An `XCTest`-specific helper that asynchronously waits for the store to finish processing - /// before comparing a specific property in the state to an expected value. It uses - /// `XCTAssertEqual` to validate that the retrieved value matches the input. - /// - /// - Parameters: - /// - keyPath: A key path that specifies the property in the `State` to be compared. - /// - input: The expected value of the property at the given key path. - /// - timeout: The maximum amount of time (in seconds) to wait for the store to finish - /// processing before timing out. Defaults to 2 seconds. - /// - file: The file path from which the function is called (default is the current file). - /// - line: The line number from which the function is called (default is the current line). - public func xctExpect( - _ keyPath: KeyPath & Sendable, - _ input: Property, - timeout: TimeInterval = 2, - file: StaticString = #filePath, - line: UInt = #line - ) async where Property: Sendable & Equatable { - var isTimeout = false - let start = CFAbsoluteTimeGetCurrent() - await Task.detached(priority: .background) { - await Task.yield() - }.value - await Task.yield() - while !isIdle { - await Task.detached(priority: .background) { - await Task.yield() - }.value - await Task.yield() - let elapsedTime = CFAbsoluteTimeGetCurrent() - start - if elapsedTime > timeout { - isTimeout = true - break - } - } - let result = state[keyPath: keyPath] - if isTimeout && result != input { - XCTFail("Exceeded timeout of \(timeout) seconds", file: file, line: line) - } else { - XCTAssertEqual(result, input, file: file, line: line) - } - } - #else - /// An `XCTest`-specific helper that asynchronously waits for the store to finish processing - /// before comparing a specific property in the state to an expected value. It uses - /// `XCTAssertEqual` to validate that the retrieved value matches the input. - /// - /// - Parameters: - /// - keyPath: A key path that specifies the property in the `State` to be compared. - /// - input: The expected value of the property at the given key path. - /// - timeout: The maximum amount of time (in seconds) to wait for the store to finish - /// processing before timing out. Defaults to 2 seconds. - /// - file: The file path from which the function is called (default is the current file). - /// - line: The line number from which the function is called (default is the current line). - public func xctExpect( - _ keyPath: KeyPath, - _ input: Property, - timeout: TimeInterval = 2, - file: StaticString = #filePath, - line: UInt = #line - ) async where Property: Sendable & Equatable { - var isTimeout = false - let start = CFAbsoluteTimeGetCurrent() - await Task.detached(priority: .background) { - await Task.yield() - }.value - await Task.yield() - while !isIdle { - await Task.detached(priority: .background) { - await Task.yield() - }.value - await Task.yield() - let elapsedTime = CFAbsoluteTimeGetCurrent() - start - if elapsedTime > timeout { - isTimeout = true - break - } - } - let result = state[keyPath: keyPath] - if isTimeout && result != input { - XCTFail("Exceeded timeout of \(timeout) seconds", file: file, line: line) - } else { - XCTAssertEqual(result, input, file: file, line: line) - } - } - #endif -} -#endif diff --git a/Sources/OneWay/ViewStore.swift b/Sources/OneWay/ViewStore.swift index 66f564d..add4ba5 100644 --- a/Sources/OneWay/ViewStore.swift +++ b/Sources/OneWay/ViewStore.swift @@ -41,7 +41,7 @@ where R.Action: Sendable, R.State: Sendable & Equatable { /// state changes public let states: AsyncViewStateSequence - private let store: Store + package let store: Store private let continuation: AsyncStream.Continuation private var task: Task? @@ -103,113 +103,4 @@ where R.Action: Sendable, R.State: Sendable & Equatable { extension ViewStore: ObservableObject { } #endif -#if canImport(Testing) -import Testing - -extension ViewStore { - #if swift(>=6) - /// Allows the expectation of a certain property value in the store's state. It compares the - /// current value of the given `keyPath` in the state with an expected `input` value. The - /// function works asynchronously, yielding control to allow other tasks to execute, especially - /// when the store is processing or updating its state. - /// - /// - Parameters: - /// - keyPath: A key path that specifies the property in the `State` to be compared. - /// - input: The expected value of the property at the given key path. - /// - timeout: The maximum amount of time (in seconds) to wait for the store to finish - /// processing before timing out. Defaults to 2 seconds. - /// - sourceLocation: The source location for tracking the test location. - public func expect( - _ keyPath: KeyPath & Sendable, - _ input: Property, - timeout: TimeInterval = 2, - sourceLocation: Testing.SourceLocation = #_sourceLocation - ) async where Property: Sendable & Equatable { - await Task { @MainActor in - await Task.yield() - }.value - await store.expect(keyPath, input, timeout: timeout, sourceLocation: sourceLocation) - } - #else - /// Allows the expectation of a certain property value in the store's state. It compares the - /// current value of the given `keyPath` in the state with an expected `input` value. The - /// function works asynchronously, yielding control to allow other tasks to execute, especially - /// when the store is processing or updating its state. - /// - /// - Parameters: - /// - keyPath: A key path that specifies the property in the `State` to be compared. - /// - input: The expected value of the property at the given key path. - /// - timeout: The maximum amount of time (in seconds) to wait for the store to finish - /// processing before timing out. Defaults to 2 seconds. - /// - sourceLocation: The source location for tracking the test location. - public func expect( - _ keyPath: KeyPath, - _ input: Property, - timeout: TimeInterval = 2, - sourceLocation: Testing.SourceLocation = #_sourceLocation - ) async where Property: Sendable & Equatable { - await Task { @MainActor in - await Task.yield() - }.value - await store.expect(keyPath, input, timeout: timeout, sourceLocation: sourceLocation) - } - #endif -} -#endif - -#if canImport(XCTest) -import XCTest - -extension ViewStore { - #if swift(>=6) - /// An `XCTest`-specific helper that asynchronously waits for the store to finish processing - /// before comparing a specific property in the state to an expected value. It uses - /// `XCTAssertEqual` to validate that the retrieved value matches the input. - /// - /// - Parameters: - /// - keyPath: A key path that specifies the property in the `State` to be compared. - /// - input: The expected value of the property at the given key path. - /// - timeout: The maximum amount of time (in seconds) to wait for the store to finish - /// processing before timing out. Defaults to 2 seconds. - /// - file: The file path from which the function is called (default is the current file). - /// - line: The line number from which the function is called (default is the current line). - public func xctExpect( - _ keyPath: KeyPath & Sendable, - _ input: Property, - timeout: TimeInterval = 2, - file: StaticString = #filePath, - line: UInt = #line - ) async where Property: Sendable & Equatable { - await Task { @MainActor in - await Task.yield() - }.value - await store.xctExpect(keyPath, input, timeout: timeout, file: file, line: line) - } - #else - /// An `XCTest`-specific helper that asynchronously waits for the store to finish processing - /// before comparing a specific property in the state to an expected value. It uses - /// `XCTAssertEqual` to validate that the retrieved value matches the input. - /// - /// - Parameters: - /// - keyPath: A key path that specifies the property in the `State` to be compared. - /// - input: The expected value of the property at the given key path. - /// - timeout: The maximum amount of time (in seconds) to wait for the store to finish - /// processing before timing out. Defaults to 2 seconds. - /// - file: The file path from which the function is called (default is the current file). - /// - line: The line number from which the function is called (default is the current line). - public func xctExpect( - _ keyPath: KeyPath, - _ input: Property, - timeout: TimeInterval = 2, - file: StaticString = #filePath, - line: UInt = #line - ) async where Property: Sendable & Equatable { - await Task { @MainActor in - await Task.yield() - }.value - await store.xctExpect(keyPath, input, timeout: timeout, file: file, line: line) - } - #endif -} -#endif #endif diff --git a/Sources/OneWayTesting/Store+Testing.swift b/Sources/OneWayTesting/Store+Testing.swift new file mode 100644 index 0000000..1538791 --- /dev/null +++ b/Sources/OneWayTesting/Store+Testing.swift @@ -0,0 +1,314 @@ +// +// OneWay +// The MIT License (MIT) +// +// Copyright (c) 2022-2024 SeungYeop Yeom ( https://github.com/DevYeom ). +// + +import OneWay + +#if canImport(CoreFoundation) +import CoreFoundation +#endif + +#if canImport(Testing) +import Testing +#endif + +#if canImport(XCTest) +import XCTest +#endif + +#if canImport(Testing) +extension Store { + #if swift(>=6) + /// Allows the expectation of a certain property value in the store's state. It compares the + /// current value of the given `keyPath` in the state with an expected `input` value + /// + /// The function works asynchronously, waiting for the store to become idle, i.e., when the + /// store is not actively processing or updating its state, before performing the comparison. + /// + /// - Parameters: + /// - keyPath: A key path that specifies the property in the `State` to be compared. + /// - input: The expected value of the property at the given key path. + /// - timeout: The maximum amount of time (in seconds) to wait for the store to finish + /// processing before timing out. Defaults to 2 seconds. + /// - fileID: The file ID from which the function is called. + /// - filePath: The file path from which the function is called. + /// - line: The line number from which the function is called. + /// - column: The column number from which the function is called. + public func expect( + _ keyPath: KeyPath & Sendable, + _ input: Property, + timeout: Double = 2, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async where Property: Sendable & Equatable { + var isTimeout = false + let start = CFAbsoluteTimeGetCurrent() + await Task.detached(priority: .background) { + await Task.yield() + }.value + await Task.yield() + while !isIdle { + await Task.detached(priority: .background) { + await Task.yield() + }.value + await Task.yield() + let elapsedTime = CFAbsoluteTimeGetCurrent() - start + if elapsedTime > timeout { + isTimeout = true + break + } + } + let result = state[keyPath: keyPath] + switch TestingFramework.current { + case .xcTest: + #if canImport(XCTest) + if isTimeout && result != input { + XCTFail( + "Exceeded timeout of \(timeout) seconds", + file: filePath, + line: line + ) + } else { + XCTAssertEqual( + result, + input, + file: filePath, + line: line + ) + } + #else + break + #endif + case .testing: + if isTimeout && result != input { + Issue.record( + "Exceeded timeout of \(timeout) seconds", + sourceLocation: Testing.SourceLocation( + fileID: String(describing: fileID), + filePath: String(describing: filePath), + line: Int(line), + column: Int(column) + ) + ) + } else { + #expect( + result == input, + sourceLocation: Testing.SourceLocation( + fileID: String(describing: fileID), + filePath: String(describing: filePath), + line: Int(line), + column: Int(column) + ) + ) + } + } + } + #else + /// Allows the expectation of a certain property value in the store's state. It compares the + /// current value of the given `keyPath` in the state with an expected `input` value + /// + /// The function works asynchronously, waiting for the store to become idle, i.e., when the + /// store is not actively processing or updating its state, before performing the comparison. + /// + /// - Parameters: + /// - keyPath: A key path that specifies the property in the `State` to be compared. + /// - input: The expected value of the property at the given key path. + /// - timeout: The maximum amount of time (in seconds) to wait for the store to finish + /// processing before timing out. Defaults to 2 seconds. + /// - fileID: The file ID from which the function is called. + /// - filePath: The file path from which the function is called. + /// - line: The line number from which the function is called. + /// - column: The column number from which the function is called. + public func expect( + _ keyPath: KeyPath, + _ input: Property, + timeout: Double = 2, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async where Property: Sendable & Equatable { + var isTimeout = false + let start = CFAbsoluteTimeGetCurrent() + await Task.detached(priority: .background) { + await Task.yield() + }.value + await Task.yield() + while !isIdle { + await Task.detached(priority: .background) { + await Task.yield() + }.value + await Task.yield() + let elapsedTime = CFAbsoluteTimeGetCurrent() - start + if elapsedTime > timeout { + isTimeout = true + break + } + } + let result = state[keyPath: keyPath] + switch TestingFramework.current { + case .xcTest: + #if canImport(XCTest) + if isTimeout && result != input { + XCTFail( + "Exceeded timeout of \(timeout) seconds", + file: filePath, + line: line + ) + } else { + XCTAssertEqual( + result, + input, + file: filePath, + line: line + ) + } + #else + break + #endif + case .testing: + if isTimeout && result != input { + Issue.record( + "Exceeded timeout of \(timeout) seconds", + sourceLocation: Testing.SourceLocation( + fileID: String(describing: fileID), + filePath: String(describing: filePath), + line: Int(line), + column: Int(column) + ) + ) + } else { + #expect( + result == input, + sourceLocation: Testing.SourceLocation( + fileID: String(describing: fileID), + filePath: String(describing: filePath), + line: Int(line), + column: Int(column) + ) + ) + } + } + } + #endif +} +#endif + +#if !canImport(Testing) && canImport(XCTest) +extension Store { + #if swift(>=6) + /// Allows the expectation of a certain property value in the store's state. It compares the + /// current value of the given `keyPath` in the state with an expected `input` value + /// + /// The function works asynchronously, waiting for the store to become idle, i.e., when the + /// store is not actively processing or updating its state, before performing the comparison. + /// + /// - Parameters: + /// - keyPath: A key path that specifies the property in the `State` to be compared. + /// - input: The expected value of the property at the given key path. + /// - timeout: The maximum amount of time (in seconds) to wait for the store to finish + /// processing before timing out. Defaults to 2 seconds. + /// - file: The file path from which the function is called. + /// - line: The line number from which the function is called. + public func expect( + _ keyPath: KeyPath & Sendable, + _ input: Property, + timeout: Double = 2, + file: StaticString = #filePath, + line: UInt = #line + ) async where Property: Sendable & Equatable { + var isTimeout = false + let start = CFAbsoluteTimeGetCurrent() + await Task.detached(priority: .background) { + await Task.yield() + }.value + await Task.yield() + while !isIdle { + await Task.detached(priority: .background) { + await Task.yield() + }.value + await Task.yield() + let elapsedTime = CFAbsoluteTimeGetCurrent() - start + if elapsedTime > timeout { + isTimeout = true + break + } + } + let result = state[keyPath: keyPath] + if isTimeout && result != input { + XCTFail( + "Exceeded timeout of \(timeout) seconds", + file: file, + line: line + ) + } else { + XCTAssertEqual( + result, + input, + file: file, + line: line + ) + } + } + #else + /// Allows the expectation of a certain property value in the store's state. It compares the + /// current value of the given `keyPath` in the state with an expected `input` value + /// + /// The function works asynchronously, waiting for the store to become idle, i.e., when the + /// store is not actively processing or updating its state, before performing the comparison. + /// + /// - Parameters: + /// - keyPath: A key path that specifies the property in the `State` to be compared. + /// - input: The expected value of the property at the given key path. + /// - timeout: The maximum amount of time (in seconds) to wait for the store to finish + /// processing before timing out. Defaults to 2 seconds. + /// - file: The file path from which the function is called. + /// - line: The line number from which the function is called. + public func expect( + _ keyPath: KeyPath, + _ input: Property, + timeout: Double = 2, + file: StaticString = #filePath, + line: UInt = #line + ) async where Property: Sendable & Equatable { + var isTimeout = false + let start = CFAbsoluteTimeGetCurrent() + await Task.detached(priority: .background) { + await Task.yield() + }.value + await Task.yield() + while !isIdle { + await Task.detached(priority: .background) { + await Task.yield() + }.value + await Task.yield() + let elapsedTime = CFAbsoluteTimeGetCurrent() - start + if elapsedTime > timeout { + isTimeout = true + break + } + } + let result = state[keyPath: keyPath] + if isTimeout && result != input { + XCTFail( + "Exceeded timeout of \(timeout) seconds", + file: file, + line: line + ) + } else { + XCTAssertEqual( + result, + input, + file: file, + line: line + ) + } + } + #endif +} +#endif diff --git a/Sources/OneWayTesting/TestingEnvironment.swift b/Sources/OneWayTesting/TestingEnvironment.swift new file mode 100644 index 0000000..f616d32 --- /dev/null +++ b/Sources/OneWayTesting/TestingEnvironment.swift @@ -0,0 +1,27 @@ +// +// OneWay +// The MIT License (MIT) +// +// Copyright (c) 2022-2024 SeungYeop Yeom ( https://github.com/DevYeom ). +// + +#if canImport(Testing) +import Testing +#endif + +enum TestingFramework { + case xcTest + case testing + + static var current: TestingFramework { + #if canImport(Testing) + if Test.current != nil { + return .testing + } else { + return .xcTest + } + #else + return .xcTest + #endif + } +} diff --git a/Sources/OneWayTesting/ViewStore+Testing.swift b/Sources/OneWayTesting/ViewStore+Testing.swift new file mode 100644 index 0000000..e3b19ac --- /dev/null +++ b/Sources/OneWayTesting/ViewStore+Testing.swift @@ -0,0 +1,162 @@ +// +// OneWay +// The MIT License (MIT) +// +// Copyright (c) 2022-2024 SeungYeop Yeom ( https://github.com/DevYeom ). +// + +import OneWay + +#if !os(Linux) +#if canImport(Testing) +extension ViewStore { + #if swift(>=6) + /// Allows the expectation of a certain property value in the store's state. It compares the + /// current value of the given `keyPath` in the state with an expected `input` value + /// + /// The function works asynchronously, waiting for the store to become idle, i.e., when the + /// store is not actively processing or updating its state, before performing the comparison. + /// + /// - Parameters: + /// - keyPath: A key path that specifies the property in the `State` to be compared. + /// - input: The expected value of the property at the given key path. + /// - timeout: The maximum amount of time (in seconds) to wait for the store to finish + /// processing before timing out. Defaults to 2 seconds. + /// - fileID: The file ID from which the function is called. + /// - filePath: The file path from which the function is called. + /// - line: The line number from which the function is called. + /// - column: The column number from which the function is called. + public func expect( + _ keyPath: KeyPath & Sendable, + _ input: Property, + timeout: Double = 2, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async where Property: Sendable & Equatable { + await Task { @MainActor in + await Task.yield() + }.value + await store.expect( + keyPath, + input, + timeout: timeout, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } + #else + /// Allows the expectation of a certain property value in the store's state. It compares the + /// current value of the given `keyPath` in the state with an expected `input` value + /// + /// The function works asynchronously, waiting for the store to become idle, i.e., when the + /// store is not actively processing or updating its state, before performing the comparison. + /// + /// - Parameters: + /// - keyPath: A key path that specifies the property in the `State` to be compared. + /// - input: The expected value of the property at the given key path. + /// - timeout: The maximum amount of time (in seconds) to wait for the store to finish + /// processing before timing out. Defaults to 2 seconds. + /// - fileID: The file ID from which the function is called. + /// - filePath: The file path from which the function is called. + /// - line: The line number from which the function is called. + /// - column: The column number from which the function is called. + public func expect( + _ keyPath: KeyPath, + _ input: Property, + timeout: Double = 2, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async where Property: Sendable & Equatable { + await Task { @MainActor in + await Task.yield() + }.value + await store.expect( + keyPath, + input, + timeout: timeout, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } + #endif +} +#endif + +#if !canImport(Testing) && canImport(XCTest) +extension ViewStore { + #if swift(>=6) + /// Allows the expectation of a certain property value in the store's state. It compares the + /// current value of the given `keyPath` in the state with an expected `input` value + /// + /// The function works asynchronously, waiting for the store to become idle, i.e., when the + /// store is not actively processing or updating its state, before performing the comparison. + /// + /// - Parameters: + /// - keyPath: A key path that specifies the property in the `State` to be compared. + /// - input: The expected value of the property at the given key path. + /// - timeout: The maximum amount of time (in seconds) to wait for the store to finish + /// processing before timing out. Defaults to 2 seconds. + /// - file: The file path from which the function is called. + /// - line: The line number from which the function is called. + public func expect( + _ keyPath: KeyPath & Sendable, + _ input: Property, + timeout: Double = 2, + file: StaticString = #filePath, + line: UInt = #line + ) async where Property: Sendable & Equatable { + await Task { @MainActor in + await Task.yield() + }.value + await store.expect( + keyPath, + input, + timeout: timeout, + file: file, + line: line + ) + } + #else + /// Allows the expectation of a certain property value in the store's state. It compares the + /// current value of the given `keyPath` in the state with an expected `input` value + /// + /// The function works asynchronously, waiting for the store to become idle, i.e., when the + /// store is not actively processing or updating its state, before performing the comparison. + /// + /// - Parameters: + /// - keyPath: A key path that specifies the property in the `State` to be compared. + /// - input: The expected value of the property at the given key path. + /// - timeout: The maximum amount of time (in seconds) to wait for the store to finish + /// processing before timing out. Defaults to 2 seconds. + /// - file: The file path from which the function is called. + /// - line: The line number from which the function is called. + public func expect( + _ keyPath: KeyPath, + _ input: Property, + timeout: Double = 2, + file: StaticString = #filePath, + line: UInt = #line + ) async where Property: Sendable & Equatable { + await Task { @MainActor in + await Task.yield() + }.value + await store.expect( + keyPath, + input, + timeout: timeout, + file: file, + line: line + ) + } + #endif +} +#endif +#endif diff --git a/Tests/OneWayTestingTests/TestingTests.swift b/Tests/OneWayTestingTests/TestingTests.swift new file mode 100644 index 0000000..2351201 --- /dev/null +++ b/Tests/OneWayTestingTests/TestingTests.swift @@ -0,0 +1,98 @@ +// +// OneWay +// The MIT License (MIT) +// +// Copyright (c) 2022-2024 SeungYeop Yeom ( https://github.com/DevYeom ). +// + +#if canImport(Testing) +import Testing +import OneWay + +@testable import OneWayTesting + +struct TestingTests { + @Test + func testingFramework() { + #expect(TestingFramework.current == TestingFramework.testing) + } + + @Test + func storeExpect() async { + let store = Store( + reducer: TestReducer(), + state: TestReducer.State(count: 0) + ) + await store.expect(\.count, 0) + + await store.send(.increment) + await store.expect(\.count, 1) + + await store.send(.increment) + await store.expect(\.count, 2) + + await store.send(.setName("hello")) + await store.expect(\.nested.name, "hello") + + await store.send(.setValue(1.23)) + await store.expect(\.nested.doubleNested.value, 1.23) + } + + #if !os(Linux) + @Test + func viewStoreExpect() async { + let store = await ViewStore( + reducer: TestReducer(), + state: TestReducer.State(count: 0) + ) + await store.expect(\.count, 0) + + await store.send(.increment) + await store.expect(\.count, 1) + + await store.send(.increment) + await store.expect(\.count, 2) + + await store.send(.setName("hello")) + await store.expect(\.nested.name, "hello") + + await store.send(.setValue(1.23)) + await store.expect(\.nested.doubleNested.value, 1.23) + } + #endif +} + +private struct TestReducer: Reducer { + enum Action: Sendable { + case increment + case setName(String) + case setValue(Double) + } + + struct State: Sendable, Equatable { + var count: Int + var nested = Nested() + struct Nested: Sendable, Equatable { + var name: String = "" + var doubleNested = DoubleNested(value: 0) + struct DoubleNested: Sendable, Equatable { + var value: Double + } + } + } + + func reduce(state: inout State, action: Action) -> AnyEffect { + switch action { + case .increment: + state.count += 1 + return .none + case let .setName(name): + state.nested.name = name + return .none + case let .setValue(value): + state.nested.doubleNested.value = value + return .none + } + } +} +#endif diff --git a/Tests/OneWayTestingTests/XCTestTests.swift b/Tests/OneWayTestingTests/XCTestTests.swift new file mode 100644 index 0000000..5549a7e --- /dev/null +++ b/Tests/OneWayTestingTests/XCTestTests.swift @@ -0,0 +1,93 @@ +// +// OneWay +// The MIT License (MIT) +// +// Copyright (c) 2022-2024 SeungYeop Yeom ( https://github.com/DevYeom ). +// + +import XCTest +import OneWay + +@testable import OneWayTesting + +final class XCTestTests: XCTestCase { + func test_testingFramework() { + XCTAssertEqual(TestingFramework.current, TestingFramework.xcTest) + } + + func test_storeExpect() async { + let store = Store( + reducer: TestReducer(), + state: TestReducer.State(count: 0) + ) + await store.expect(\.count, 0) + + await store.send(.increment) + await store.expect(\.count, 1) + + await store.send(.increment) + await store.expect(\.count, 2) + + await store.send(.setName("hello")) + await store.expect(\.nested.name, "hello") + + await store.send(.setValue(1.23)) + await store.expect(\.nested.doubleNested.value, 1.23) + } + + #if !os(Linux) + func test_viewStoreExpect() async { + let store = await ViewStore( + reducer: TestReducer(), + state: TestReducer.State(count: 0) + ) + await store.expect(\.count, 0) + + await store.send(.increment) + await store.expect(\.count, 1) + + await store.send(.increment) + await store.expect(\.count, 2) + + await store.send(.setName("hello")) + await store.expect(\.nested.name, "hello") + + await store.send(.setValue(1.23)) + await store.expect(\.nested.doubleNested.value, 1.23) + } + #endif +} + +private struct TestReducer: Reducer { + enum Action: Sendable { + case increment + case setName(String) + case setValue(Double) + } + + struct State: Sendable, Equatable { + var count: Int + var nested = Nested() + struct Nested: Sendable, Equatable { + var name: String = "" + var doubleNested = DoubleNested(value: 0) + struct DoubleNested: Sendable, Equatable { + var value: Double + } + } + } + + func reduce(state: inout State, action: Action) -> AnyEffect { + switch action { + case .increment: + state.count += 1 + return .none + case let .setName(name): + state.nested.name = name + return .none + case let .setValue(value): + state.nested.doubleNested.value = value + return .none + } + } +} diff --git a/Tests/OneWayTests/StoreTests.swift b/Tests/OneWayTests/StoreTests.swift index 5f8e354..e77148c 100644 --- a/Tests/OneWayTests/StoreTests.swift +++ b/Tests/OneWayTests/StoreTests.swift @@ -10,6 +10,7 @@ import Clocks import Combine #endif import OneWay +import OneWayTesting import XCTest @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) @@ -53,14 +54,14 @@ final class StoreTests: XCTestCase { await sut.send(.increment) await sut.send(.twice) - await sut.xctExpect(\.count, 4) - await sut.xctExpect(\.text, "") + await sut.expect(\.count, 4) + await sut.expect(\.text, "") } func test_lotsOfActions() async { let iterations: Int = 100_000 await sut.send(.incrementMany) - await sut.xctExpect(\.count, iterations, timeout: 5) + await sut.expect(\.count, iterations, timeout: 5) } func test_threadSafeSendingActions() async { @@ -80,12 +81,12 @@ final class StoreTests: XCTestCase { } } - await sut.xctExpect(\.count, iterations) + await sut.expect(\.count, iterations) } func test_asyncAction() async { await sut.send(.request) - await sut.xctExpect(\.text, "Success") + await sut.expect(\.text, "Success") } #if canImport(Combine) @@ -169,7 +170,7 @@ final class StoreTests: XCTestCase { } try! await Task.sleep(nanoseconds: NSEC_PER_MSEC * 550) - await sut.xctExpect(\.count, 2) + await sut.expect(\.count, 2) for _ in 0..<5 { try! await Task.sleep(nanoseconds: NSEC_PER_MSEC * 100) @@ -177,7 +178,7 @@ final class StoreTests: XCTestCase { } try! await Task.sleep(nanoseconds: NSEC_PER_MSEC * 100) // 100ms < 500ms - await sut.xctExpect(\.count, 2, timeout: 0.1) + await sut.expect(\.count, 2, timeout: 0.1) } func test_debounceWithClock() async { @@ -192,7 +193,7 @@ final class StoreTests: XCTestCase { } await clock.advance(by: .seconds(100)) - await sut.xctExpect(\.count, 2) + await sut.expect(\.count, 2) for _ in 0..<5 { await clock.advance(by: .seconds(10)) @@ -200,7 +201,7 @@ final class StoreTests: XCTestCase { } await clock.advance(by: .seconds(10)) // 10s < 100s - await sut.xctExpect(\.count, 2) + await sut.expect(\.count, 2) } func test_deboouncedSequence() async { @@ -215,7 +216,7 @@ final class StoreTests: XCTestCase { } try! await Task.sleep(nanoseconds: NSEC_PER_MSEC * 550) - await sut.xctExpect(\.count, 10) + await sut.expect(\.count, 10) for _ in 0..<5 { try! await Task.sleep(nanoseconds: NSEC_PER_MSEC * 100) @@ -223,7 +224,7 @@ final class StoreTests: XCTestCase { } try! await Task.sleep(nanoseconds: NSEC_PER_MSEC * 100) // 100ms < 500ms - await sut.xctExpect(\.count, 10, timeout: 0.1) + await sut.expect(\.count, 10, timeout: 0.1) } func test_deboouncedSequenceWithClock() async { @@ -238,7 +239,7 @@ final class StoreTests: XCTestCase { } await clock.advance(by: .seconds(100)) - await sut.xctExpect(\.count, 10) + await sut.expect(\.count, 10) for _ in 0..<5 { await clock.advance(by: .seconds(10)) @@ -246,7 +247,7 @@ final class StoreTests: XCTestCase { } await clock.advance(by: .seconds(10)) // 10s < 100s - await sut.xctExpect(\.count, 10) + await sut.expect(\.count, 10) } } diff --git a/Tests/OneWayTests/ViewStoreTests.swift b/Tests/OneWayTests/ViewStoreTests.swift index 1905a65..ad5d3c0 100644 --- a/Tests/OneWayTests/ViewStoreTests.swift +++ b/Tests/OneWayTests/ViewStoreTests.swift @@ -44,7 +44,7 @@ final class ViewStoreTests: XCTestCase { sut.send(.increment) sut.send(.twice) - await sut.xctExpect(\.count, 4) + await sut.expect(\.count, 4) } @MainActor