Skip to content
Open
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
31 changes: 6 additions & 25 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,36 +9,17 @@ on:
branches:
- main


jobs:
spm:
name: SwiftPM build and test
runs-on: macos-14
runs-on: macos-latest
steps:
- run: |
sudo xcode-select -s /Applications/Xcode_15.3.app
- uses: actions/checkout@v3
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable

- uses: actions/checkout@v6
- name: Build swift packages
run: swift build -v
- name: Run tests
run: swift test -v
carthage:
name: Xcode project build and test
runs-on: macos-14
steps:
- run: |
sudo xcode-select -s /Applications/Xcode_15.3.app
- uses: actions/checkout@v3
- name: Build xcode project
run: xcodebuild build -scheme 'SubprocessMocks' -derivedDataPath .build
- name: Run tests
run: xcodebuild test -scheme 'Subprocess' -derivedDataPath .build
cocoapods:
name: Pod lib lint
runs-on: macos-14
steps:
- run: |
sudo xcode-select -s /Applications/Xcode_15.3.app
- uses: actions/checkout@v3
- name: Lib lint
run: pod lib lint --verbose Subprocess.podspec --allow-warnings
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [4.0.0] - 2026-05-05

### Added
- New `SubprocessTesting` library target providing `SubprocessTrait` — a Swift Testing `TestTrait`/`SuiteTrait` that scopes subprocess mocking to individual tests via `@TaskLocal`, enabling safe parallel test execution.
- `SwiftTesting` test target with suite demonstrating parallel mock usage using Swift Testing.

### Changed
- `SubprocessDependencyFactory` now conforms to `Sendable`.
- `MockSubprocessDependencyBuilder` is now `public final` and `Sendable`; its `shared` instance is `@TaskLocal` instead of a `nonisolated(unsafe)` static, removing the need to manually reset it between tests.
- `MockSubprocessDependencyBuilder.makeProcess`, `makeInputFileHandle`, and `makeInputPipe` are now `public` to support the new `SubprocessTesting` target.
- `MockProcess.Context`, `MockProcess.Context.State`, `ExpectationError`, and `MockSubprocessError` now conform to `Sendable`.
- `MockProcess.Context.runStub` closure is now `@Sendable`.
- `MockProcess.Context` standard I/O properties marked `nonisolated(unsafe)` for `Sendable` conformance.
- All `Shell.expect` and `Subprocess.expect` overloads now use `#filePath` instead of `#file` for the default `file:` argument.
- Unit tests import `SubprocessMocks` publicly instead of `@testable`.
- `swift-tools-version` bumped to `5.10`.

## [3.0.5] - 2024-08-07

### Changed
Expand Down
13 changes: 12 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version: 5.9
// swift-tools-version: 5.10

import PackageDescription

Expand All @@ -14,6 +14,10 @@ let package = Package(
name: "SubprocessMocks",
targets: [ "SubprocessMocks" ]
),
.library(
name: "SubprocessTesting",
targets: ["SubprocessTesting"]
),
.library(
name: "libSubprocess",
targets: [ "Subprocess" ]
Expand All @@ -37,6 +41,7 @@ let package = Package(
.target(name: "Subprocess")
]
),
.target(name: "SubprocessTesting", dependencies: ["Subprocess", "SubprocessMocks"]),
.testTarget(
name: "UnitTests",
dependencies: [
Expand All @@ -49,6 +54,12 @@ let package = Package(
dependencies: [
.target(name: "Subprocess")
]
),
.testTarget(
name: "SwiftTesting",
dependencies: [
.target(name: "SubprocessTesting")
]
)
],
swiftLanguageVersions: [.v5, .version("6")]
Expand Down
84 changes: 78 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,83 @@ let package = Package(
]
)
```
### Cocoapods
```ruby
pod 'Subprocess'

## Unit Testing with Swift Testing

Add `SubprocessMocks` and `SubprocessTesting` as dependencies of your test target:

```swift
.testTarget(
name: "MyTests",
dependencies: [
.product(name: "SubprocessMocks", package: "Subprocess"),
.product(name: "SubprocessTesting", package: "Subprocess"),
]
)
```

### SubprocessTrait

`SubprocessTrait` is a Swift Testing `TestTrait` and `SuiteTrait` that automatically scopes subprocess mocking to each test. Each test gets its own isolated `MockSubprocessDependencyBuilder` via `@TaskLocal`, so tests can safely run in parallel without interfering with each other.

Apply `.subprocessTesting` to any `@Test` or `@Suite`:

```swift
import Testing
import Subprocess
import SubprocessMocks
import SubprocessTesting

@Test(.subprocessTesting)
func testSoftwareVersion() async throws {
Subprocess.expect(["/usr/bin/sw_vers", "-productVersion"], standardOutput: "15.0\n".data(using: .utf8))

let version = try await Subprocess.string(for: ["/usr/bin/sw_vers", "-productVersion"])

#expect(version.trimmingCharacters(in: .whitespacesAndNewlines) == "15.0")
try Subprocess.verify()
}
```

Apply it to a whole suite to cover every test in the type:

```swift
@Suite(.subprocessTesting)
struct MyCommandTests {
@Test
func testGrep() async throws {
Subprocess.expect(["/usr/bin/grep", "foo"], standardOutput: "foo bar\n".data(using: .utf8))

let result = try await Subprocess.string(for: ["/usr/bin/grep", "foo"])

#expect(result.contains("foo"))
try Subprocess.verify()
}

@Test
func testMissingFile() async throws {
let error = NSError(domain: NSPOSIXErrorDomain, code: Int(ENOENT))
Subprocess.expect(["/bin/cat", "/no/such/file"], error: error)

await #expect(throws: (any Error).self) {
try await Subprocess.data(for: ["/bin/cat", "/no/such/file"])
}
}
}
```
### Carthage
```ruby
github 'jamf/Subprocess'

### Parallel tests

Because each test's mocks are stored in a `@TaskLocal`, parameterised and parallel tests work without any extra setup:

```swift
@Test(.subprocessTesting, arguments: ["foo", "bar", "baz"])
func testEcho(_ word: String) async throws {
Subprocess.expect(["/bin/echo", word], standardOutput: "\(word)\n".data(using: .utf8))

let output = try await Subprocess.string(for: ["/bin/echo", word])

#expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == word)
try Subprocess.verify()
}
```
16 changes: 8 additions & 8 deletions Sources/Subprocess/SubprocessDependencyBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import Foundation
#endif

/// Protocol call used for dependency injection
public protocol SubprocessDependencyFactory {
public protocol SubprocessDependencyFactory: Sendable {
/// Creates new Subprocess
///
/// - Parameter command: Command represented as an array of strings
Expand All @@ -56,17 +56,17 @@ public protocol SubprocessDependencyFactory {
/// Default implementation of SubprocessDependencyFactory
public struct SubprocessDependencyBuilder: SubprocessDependencyFactory {
private static let queue = DispatchQueue(label: "\(Self.self)")

#if compiler(<5.10)
private static var _shared: any SubprocessDependencyFactory = SubprocessDependencyBuilder()
#else
nonisolated(unsafe) private static var _shared: any SubprocessDependencyFactory = SubprocessDependencyBuilder()
#endif
@TaskLocal public static var __shared: (any SubprocessDependencyFactory)?
/// Shared instance used for dependency creation
public static var shared: any SubprocessDependencyFactory {
get {
queue.sync {
_shared
if let value = __shared {
value
} else {
queue.sync {
_shared
}
}
}
set {
Expand Down
14 changes: 6 additions & 8 deletions Sources/SubprocessMocks/MockProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ import Subprocess

/// Interface used for mocking a process
public struct MockProcess: Sendable {

/// The underlying `MockProcessReference`
public var reference: MockProcessReference

Expand Down Expand Up @@ -70,10 +69,9 @@ public struct MockProcess: Sendable {
/// Subclass of `Process` used for mocking
open class MockProcessReference: Process, @unchecked Sendable {
/// Context information and values used for overriden properties
public struct Context {

public struct Context: Sendable {
/// State of the mock process
public enum State {
public enum State: Sendable {
case initialized
case running
case uncaughtSignal
Expand All @@ -84,11 +82,11 @@ open class MockProcessReference: Process, @unchecked Sendable {
public var state: State = .initialized

/// Block called to stub the call to launch
public var runStub: (MockProcess) throws -> Void
public var runStub: @Sendable (MockProcess) throws -> Void

var standardInput: Any?
var standardOutput: Any?
var standardError: Any?
nonisolated(unsafe) var standardInput: Any?
nonisolated(unsafe) var standardOutput: Any?
nonisolated(unsafe) var standardError: Any?
var terminationHandler: (@Sendable (Process) -> Void)?
}

Expand Down
32 changes: 16 additions & 16 deletions Sources/SubprocessMocks/MockShell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,12 @@ public extension Shell {
/// - command: The command to mock
/// - input: The expected input of the process
/// - error: Error thrown when `Process.run` is called
/// - file: Source file where expect was called (Default: #file)
/// - file: Source file where expect was called (Default: #filePath)
/// - line: Line number of source file where expect was called (Default: #line)
static func expect(_ command: [String],
input: Input? = nil,
error: any Swift.Error,
file: StaticString = #file,
file: StaticString = #filePath,
line: UInt = #line) {
Subprocess.expect(command, input: input, error: error, file: file, line: line)
}
Expand All @@ -191,14 +191,14 @@ public extension Shell {
/// - standardOutput: Data written to stdout of the process
/// - standardError: Data written to stderr of the process
/// - exitCode: Exit code of the process (Default: 0)
/// - file: Source file where expect was called (Default: #file)
/// - file: Source file where expect was called (Default: #filePath)
/// - line: Line number of source file where expect was called (Default: #line)
static func expect(_ command: [String],
input: Input? = nil,
standardOutput: Data? = nil,
standardError: Data? = nil,
exitCode: Int32 = 0,
file: StaticString = #file,
file: StaticString = #filePath,
line: UInt = #line) {
Subprocess.expect(command, input: input, file: file, line: line) { process in
if let data = standardOutput {
Expand All @@ -220,14 +220,14 @@ public extension Shell {
/// - stdout: String written to stdout of the process
/// - stderr: String written to stderr of the process (Default: nil)
/// - exitCode: Exit code of the process (Default: 0)
/// - file: Source file where expect was called (Default: #file)
/// - file: Source file where expect was called (Default: #filePath)
/// - line: Line number of source file where expect was called (Default: #line)
static func expect(_ command: [String],
input: Input? = nil,
stdout: String,
stderr: String? = nil,
exitCode: Int32 = 0,
file: StaticString = #file,
file: StaticString = #filePath,
line: UInt = #line) {
Subprocess.expect(command, input: input, file: file, line: line) { process in
process.writeTo(stdout: stdout)
Expand All @@ -246,13 +246,13 @@ public extension Shell {
/// - stdout: String written to stdout of the process
/// - stderr: String written to stderr of the process
/// - exitCode: Exit code of the process (Default: 0)
/// - file: Source file where expect was called (Default: #file)
/// - file: Source file where expect was called (Default: #filePath)
/// - line: Line number of source file where expect was called (Default: #line)
static func expect(_ command: [String],
input: Input? = nil,
stderr: String,
exitCode: Int32 = 0,
file: StaticString = #file,
file: StaticString = #filePath,
line: UInt = #line) {
Subprocess.expect(command, input: input, file: file, line: line) { process in
process.writeTo(stderr: stderr)
Expand All @@ -268,14 +268,14 @@ public extension Shell {
/// - input: The expected input of the process
/// - plist: Property list object serialized and written to stdout
/// - exitCode: Exit code of the process (Default: 0)
/// - file: Source file where expect was called (Default: #file)
/// - file: Source file where expect was called (Default: #filePath)
/// - line: Line number of source file where expect was called (Default: #line)
/// - Throws: Error when serializing property list object
static func expect(_ command: [String],
input: Input? = nil,
plist: Any,
exitCode: Int32 = 0,
file: StaticString = #file,
file: StaticString = #filePath,
line: UInt = #line) throws {
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
Shell.expect(command, input: input, standardOutput: data, exitCode: exitCode, file: file, line: line)
Expand All @@ -289,14 +289,14 @@ public extension Shell {
/// - input: The expected input of the process
/// - plist: JSON object serialized and written to stdout
/// - exitCode: Exit code of the process (Default: 0)
/// - file: Source file where expect was called (Default: #file)
/// - file: Source file where expect was called (Default: #filePath)
/// - line: Line number of source file where expect was called (Default: #line)
/// - Throws: Error when serializing JSON object
static func expect(_ command: [String],
input: Input? = nil,
json: Any,
exitCode: Int32 = 0,
file: StaticString = #file,
file: StaticString = #filePath,
line: UInt = #line) throws {
let data = try JSONSerialization.data(withJSONObject: json, options: [])
Shell.expect(command, input: input, standardOutput: data, exitCode: exitCode, file: file, line: line)
Expand All @@ -310,14 +310,14 @@ public extension Shell {
/// - input: The expected input of the process
/// - plistObject: Encodable object written to stdout as a property list
/// - exitCode: Exit code of the process (Default: 0)
/// - file: Source file where expect was called (Default: #file)
/// - file: Source file where expect was called (Default: #filePath)
/// - line: Line number of source file where expect was called (Default: #line)
/// - Throws: Error when encoding the provided object
static func expect<T: Encodable>(_ command: [String],
input: Input? = nil,
plistObject: T,
exitCode: Int32 = 0,
file: StaticString = #file,
file: StaticString = #filePath,
line: UInt = #line) throws {
let data = try PropertyListEncoder().encode(plistObject)
Shell.expect(command, input: input, standardOutput: data, exitCode: exitCode, file: file, line: line)
Expand All @@ -331,14 +331,14 @@ public extension Shell {
/// - input: The expected input of the process
/// - jsonObject: Encodable object written to stdout as JSON
/// - exitCode: Exit code of the process (Default: 0)
/// - file: Source file where expect was called (Default: #file)
/// - file: Source file where expect was called (Default: #filePath)
/// - line: Line number of source file where expect was called (Default: #line)
/// - Throws: Error when encoding the provided object
static func expect<T: Encodable>(_ command: [String],
input: Input? = nil,
jsonObject: T,
exitCode: Int32 = 0,
file: StaticString = #file,
file: StaticString = #filePath,
line: UInt = #line) throws {
let data = try JSONEncoder().encode(jsonObject)
Shell.expect(command, input: input, standardOutput: data, exitCode: exitCode, file: file, line: line)
Expand Down
Loading
Loading