diff --git a/README.md b/README.md index cdf0450..3b79fab 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,39 @@ 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. + +#### When using `Testing` + +You can use the `expect` function to easily check the state value. + +```swift +@Test +func incrementTwice() async { + await sut.send(.increment) + await sut.send(.increment) + + await sut.expect(\.count, 2) +} +``` + +#### When using `XCTest` + +The `xctExpect` function is used within an XCTest environment to assert the state value. + +```swift +func test_incrementTwice() async { + await sut.send(.increment) + await sut.send(.increment) + + await sut.xctExpect(\.count, 2) +} +``` + +For more details, please refer to the [Testing](https://swiftpackageindex.com/DevYeom/OneWay/main/documentation/OneWay/Testing) article. + ## Documentation To learn how to use **OneWay** in more detail, go through the [documentation](https://swiftpackageindex.com/DevYeom/OneWay/main/documentation/OneWay). diff --git a/Sources/OneWay/OneWay.docc/OneWay.md b/Sources/OneWay/OneWay.docc/OneWay.md index 0fa1c0a..8a85a0c 100644 --- a/Sources/OneWay/OneWay.docc/OneWay.md +++ b/Sources/OneWay/OneWay.docc/OneWay.md @@ -82,3 +82,5 @@ OneWay is released under the MIT license. See LICENSE for details. - ``Triggered`` ### Articles + +- doc:Testing diff --git a/Sources/OneWay/OneWay.docc/Resources/expect-failure.png b/Sources/OneWay/OneWay.docc/Resources/expect-failure.png new file mode 100644 index 0000000..8be4eb7 Binary files /dev/null and b/Sources/OneWay/OneWay.docc/Resources/expect-failure.png differ diff --git a/Sources/OneWay/OneWay.docc/Resources/expect-failure~dark.png b/Sources/OneWay/OneWay.docc/Resources/expect-failure~dark.png new file mode 100644 index 0000000..cae530b Binary files /dev/null and b/Sources/OneWay/OneWay.docc/Resources/expect-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 new file mode 100644 index 0000000..eba3075 Binary files /dev/null and b/Sources/OneWay/OneWay.docc/Resources/xct-expect-failure.png 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 new file mode 100644 index 0000000..3b7ecf1 Binary files /dev/null and b/Sources/OneWay/OneWay.docc/Resources/xct-expect-failure~dark.png differ diff --git a/Sources/OneWay/OneWay.docc/Testing.md b/Sources/OneWay/OneWay.docc/Testing.md new file mode 100644 index 0000000..0758c38 --- /dev/null +++ b/Sources/OneWay/OneWay.docc/Testing.md @@ -0,0 +1,73 @@ +# Testing + +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. + +### When using `Testing` + +You can use the `expect` function to easily check the state value. + +```swift +@Test +func incrementTwice() async { + await sut.send(.increment) + await sut.send(.increment) + + await sut.expect(\.count, 2) +} +``` + +### When using `XCTest` + +The `xctExpect` function is used within an XCTest environment to assert the state value. + +```swift +func test_incrementTwice() async { + await sut.send(.increment) + await sut.send(.increment) + + await sut.xctExpect(\.count, 2) +} +``` + +## Specifying timeout if needed + +Both functions include a `timeout` parameter, which specifies the maximum amount of time (in seconds) to wait for the state to finish processing before timing out. The default value is 2 seconds. + +### When using `Testing` + +```swift +@Test +func incrementTwice() async { + await sut.send(.increment) + await sut.send(.increment) + + await sut.expect(\.count, 2, timeout: 0.1) +} +``` + +### When using `XCTest` + +```swift +func test_incrementTwice() async { + await sut.send(.increment) + await sut.send(.increment) + + await sut.xctExpect(\.count, 2, timeout: 5) +} +``` + +## Failed Tests + +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. + +### Failure with `expect` + +![failure with expect](expect-failure.png) + +### Failure with `xctExpect` + +![failure with xctExpect](xct-expect-failure.png) diff --git a/Sources/OneWay/Store.swift b/Sources/OneWay/Store.swift index a275683..8c88a34 100644 --- a/Sources/OneWay/Store.swift +++ b/Sources/OneWay/Store.swift @@ -5,7 +5,9 @@ // Copyright (c) 2022-2024 SeungYeop Yeom ( https://github.com/DevYeom ). // +#if canImport(Foundation) import Foundation +#endif /// `Store` is an actor that holds and manages state values. /// @@ -41,6 +43,7 @@ where R.Action: Sendable, R.State: Sendable & Equatable { 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] = [:] @@ -99,11 +102,9 @@ where R.Action: Sendable, R.State: Sendable & Equatable { taskIDs.forEach { removeTask($0) } } cancellables[EffectIDWrapper(id), default: []].insert(taskID) - - case .cancel(let id): + case let .cancel(id): let taskIDs = cancellables[EffectIDWrapper(id), default: []] taskIDs.forEach { removeTask($0) } - case .none: break } @@ -149,3 +150,102 @@ private struct EffectIDWrapper: Hashable, @unchecked Sendable { self.id = id } } + +#if canImport(Testing) && canImport(CoreFoundation) +import CoreFoundation +import Testing + +extension Store { + /// 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 { + 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 + +#if canImport(XCTest) && canImport(CoreFoundation) +import CoreFoundation +import XCTest + +extension Store { + /// 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 { + 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 diff --git a/Sources/OneWay/ViewStore.swift b/Sources/OneWay/ViewStore.swift index 3cce8b3..d5052cd 100644 --- a/Sources/OneWay/ViewStore.swift +++ b/Sources/OneWay/ViewStore.swift @@ -98,4 +98,57 @@ where R.Action: Sendable, R.State: Sendable & Equatable { #if canImport(Combine) extension ViewStore: ObservableObject { } #endif + +#if canImport(Testing) +import Testing + +extension ViewStore { + /// 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 { + await store.expect(keyPath, input, timeout: timeout, sourceLocation: sourceLocation) + } +} +#endif + +#if canImport(XCTest) +import XCTest + +extension ViewStore { + /// 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 { + await store.xctExpect(keyPath, input, timeout: timeout, file: file, line: line) + } +} +#endif #endif diff --git a/Tests/OneWayTests/StoreTests.swift b/Tests/OneWayTests/StoreTests.swift index b47c2b3..5f8e354 100644 --- a/Tests/OneWayTests/StoreTests.swift +++ b/Tests/OneWayTests/StoreTests.swift @@ -53,18 +53,14 @@ final class StoreTests: XCTestCase { await sut.send(.increment) await sut.send(.twice) - await expect { await sut.state.count == 4 } - await expect { await sut.state.text == "" } + await sut.xctExpect(\.count, 4) + await sut.xctExpect(\.text, "") } func test_lotsOfActions() async { let iterations: Int = 100_000 await sut.send(.incrementMany) - - await expect( - compare: { await sut.state.count == iterations }, - timeout: 10 - ) + await sut.xctExpect(\.count, iterations, timeout: 5) } func test_threadSafeSendingActions() async { @@ -84,16 +80,12 @@ final class StoreTests: XCTestCase { } } - await expect( - compare: { await sut.state.count == iterations }, - timeout: 10 - ) + await sut.xctExpect(\.count, iterations) } func test_asyncAction() async { await sut.send(.request) - - await expect { await sut.state.text == "Success" } + await sut.xctExpect(\.text, "Success") } #if canImport(Combine) @@ -177,7 +169,7 @@ final class StoreTests: XCTestCase { } try! await Task.sleep(nanoseconds: NSEC_PER_MSEC * 550) - await expect { await sut.state.count == 2 } + await sut.xctExpect(\.count, 2) for _ in 0..<5 { try! await Task.sleep(nanoseconds: NSEC_PER_MSEC * 100) @@ -185,7 +177,7 @@ final class StoreTests: XCTestCase { } try! await Task.sleep(nanoseconds: NSEC_PER_MSEC * 100) // 100ms < 500ms - await expect { await sut.state.count == 2 } + await sut.xctExpect(\.count, 2, timeout: 0.1) } func test_debounceWithClock() async { @@ -200,7 +192,7 @@ final class StoreTests: XCTestCase { } await clock.advance(by: .seconds(100)) - await expect { await sut.state.count == 2 } + await sut.xctExpect(\.count, 2) for _ in 0..<5 { await clock.advance(by: .seconds(10)) @@ -208,7 +200,7 @@ final class StoreTests: XCTestCase { } await clock.advance(by: .seconds(10)) // 10s < 100s - await expect { await sut.state.count == 2 } + await sut.xctExpect(\.count, 2) } func test_deboouncedSequence() async { @@ -223,7 +215,7 @@ final class StoreTests: XCTestCase { } try! await Task.sleep(nanoseconds: NSEC_PER_MSEC * 550) - await expect { await sut.state.count == 10 } + await sut.xctExpect(\.count, 10) for _ in 0..<5 { try! await Task.sleep(nanoseconds: NSEC_PER_MSEC * 100) @@ -231,7 +223,7 @@ final class StoreTests: XCTestCase { } try! await Task.sleep(nanoseconds: NSEC_PER_MSEC * 100) // 100ms < 500ms - await expect { await sut.state.count == 10 } + await sut.xctExpect(\.count, 10, timeout: 0.1) } func test_deboouncedSequenceWithClock() async { @@ -246,7 +238,7 @@ final class StoreTests: XCTestCase { } await clock.advance(by: .seconds(100)) - await expect { await sut.state.count == 10 } + await sut.xctExpect(\.count, 10) for _ in 0..<5 { await clock.advance(by: .seconds(10)) @@ -254,7 +246,7 @@ final class StoreTests: XCTestCase { } await clock.advance(by: .seconds(10)) // 10s < 100s - await expect { await sut.state.count == 10 } + await sut.xctExpect(\.count, 10) } } diff --git a/Tests/OneWayTests/TestHelper/XCTestCase+Expect.swift b/Tests/OneWayTests/TestHelper/XCTestCase+Expect.swift index fbc52aa..54cec7e 100644 --- a/Tests/OneWayTests/TestHelper/XCTestCase+Expect.swift +++ b/Tests/OneWayTests/TestHelper/XCTestCase+Expect.swift @@ -8,48 +8,6 @@ import XCTest extension XCTestCase { - func expect( - compare: () async -> Bool, - timeout seconds: UInt64 = 1, - description: String = #function - ) async { - let limit = NSEC_PER_SEC * seconds - let start = DispatchTime.now().uptimeNanoseconds - while true { - guard start.distance(to: DispatchTime.now().uptimeNanoseconds) < limit else { - XCTFail("Exceeded timeout of \(seconds) seconds") - break - } - if await compare() { - XCTAssert(true) - break - } else { - await Task.yield() - } - } - } - - func sendableExpect( - compare: @Sendable () async -> Bool, - timeout seconds: UInt64 = 1, - description: String = #function - ) async { - let limit = NSEC_PER_SEC * seconds - let start = DispatchTime.now().uptimeNanoseconds - while true { - guard start.distance(to: DispatchTime.now().uptimeNanoseconds) < limit else { - XCTFail("Exceeded timeout of \(seconds) seconds") - break - } - if await compare() { - XCTAssert(true) - break - } else { - await Task.yield() - } - } - } - @MainActor func sendableExpectWithMainActor( compare: @Sendable () async -> Bool, diff --git a/Tests/OneWayTests/ViewStoreTests.swift b/Tests/OneWayTests/ViewStoreTests.swift index 9df1be3..1905a65 100644 --- a/Tests/OneWayTests/ViewStoreTests.swift +++ b/Tests/OneWayTests/ViewStoreTests.swift @@ -44,8 +44,7 @@ final class ViewStoreTests: XCTestCase { sut.send(.increment) sut.send(.twice) - let sut = sut! - await sendableExpectWithMainActor { await sut.state.count == 4 } + await sut.xctExpect(\.count, 4) } @MainActor