Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions Sources/OneWay/OneWay.docc/OneWay.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,5 @@ OneWay is released under the MIT license. See LICENSE for details.
- ``Triggered``

### Articles

- doc:Testing
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
73 changes: 73 additions & 0 deletions Sources/OneWay/OneWay.docc/Testing.md
Original file line number Diff line number Diff line change
@@ -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)
106 changes: 103 additions & 3 deletions Sources/OneWay/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -41,6 +43,7 @@ where R.Action: Sendable, R.State: Sendable & Equatable {
private let reducer: any Reducer<Action, State>
private let continuation: AsyncStream<State>.Continuation
private var isProcessing: Bool = false
private var isIdle: Bool { !isProcessing && tasks.isEmpty }
private var actionQueue: [Action] = []
private var bindingTask: Task<Void, Never>?
private var tasks: [TaskID: Task<Void, Never>] = [:]
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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<Property: Equatable>(
_ keyPath: KeyPath<State, Property>,
_ 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<Property: Equatable>(
_ keyPath: KeyPath<State, Property>,
_ 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
53 changes: 53 additions & 0 deletions Sources/OneWay/ViewStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Property: Equatable>(
_ keyPath: KeyPath<State, Property>,
_ 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<Property: Equatable>(
_ keyPath: KeyPath<State, Property>,
_ 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
Loading