Subprocess is a Swift library for macOS providing interfaces for both synchronous and asynchronous process execution. SubprocessMocks can be used in unit tests for quick and highly customizable mocking and verification of Subprocess usage.
The Subprocess class can be used for command execution.
let inputData = Data("hello world".utf8)
let data = try await Subprocess.data(for: ["/usr/bin/grep", "hello"], standardInput: inputData)let data = try await Subprocess.data(for: ["/usr/bin/grep", "hello"], standardInput: "hello world")let data = try await Subprocess.data(for: ["/usr/bin/grep", "foo"], standardInput: URL(filePath: "/path/to/input/file"))let data = try await Subprocess.data(for: ["/usr/bin/sw_vers"])let string = try await Subprocess.string(for: ["/usr/bin/sw_vers"])struct LogMessage: Codable {
var subsystem: String
var category: String
var machTimestamp: UInt64
}
let result: [LogMessage] = try await Subprocess.value(for: ["/usr/bin/log", "show", "--style", "json", "--last", "30s"], decoder: JSONDecoder())struct SystemVersion: Codable {
enum CodingKeys: String, CodingKey {
case version = "ProductVersion"
}
var version: String
}
let result: SystemVersion = try await Subprocess.value(for: ["/bin/cat", "/System/Library/CoreServices/SystemVersion.plist"], decoder: PropertyListDecoder())let enabled = try await Subprocess(["/usr/bin/csrutil", "status"]).run().standardOutput.lines.first(where: { $0.contains("enabled") } ) != nillet errorText = try await Subprocess.string(for: ["/usr/bin/cat", "/non/existent/file.txt"], options: .returnStandardError)
let outputText = try await Subprocess.string(for: ["/usr/bin/sw_vers"])
async let (standardOutput, standardError, _) = try Subprocess(["/usr/bin/csrutil", "status"]).run()
let combinedOutput = try await [standardOutput.string(), standardError.string()]let (stream, input) = {
var input: AsyncStream<UInt8>.Continuation!
let stream: AsyncStream<UInt8> = AsyncStream { continuation in
input = continuation
}
return (stream, input!)
}()
let subprocess = Subprocess(["/bin/cat"])
let (standardOutput, _, waitForExit) = try subprocess.run(standardInput: stream)
input.yield("hello\n")
Task {
for await line in standardOutput.lines {
switch line {
case "hello":
input.yield("world\n")
case "world":
input.yield("and\nuniverse")
input.finish()
case "universe":
await waitForExit()
break
default:
continue
}
}
}let process = Subprocess(["/usr/bin/csrutil", "status"])
let (standardOutput, standardError, waitForExit) = try process.run()
async let (stdout, stderr) = (standardOutput, standardError)
let combinedOutput = await [stdout.data(), stderr.data()]
await waitForExit()
if process.exitCode == 0 {
// Do something with output data
} else {
// Handle failure
}let command: [String] = ...
let process = Subprocess(command)
nonisolated(unsafe) var outputData: Data?
nonisolated(unsafe) var errorData: Data?
// The outputHandler and errorHandler are invoked serially
try process.launch(outputHandler: { data in
// Handle new data read from stdout
outputData = data
}, errorHandler: { data in
// Handle new data read from stderr
errorData = data
}, terminationHandler: { process in
// Handle process termination, all scheduled calls to
// the outputHandler and errorHandler are guaranteed to
// have completed.
})let command: [String] = ...
let process = Subprocess(command)
try process.launch { (process, outputData, errorData) in
if process.exitCode == 0 {
// Do something with output data
} else {
// Handle failure
}let package = Package(
// name, platforms, products, etc.
dependencies: [
// other dependencies
.package(url: "https://github.com/jamf/Subprocess.git", .upToNextMajor(from: "3.0.0")),
],
targets: [
.target(name: "<target>",
dependencies: [
// other dependencies
.product(name: "Subprocess"),
]),
// other targets
]
)Add SubprocessMocks and SubprocessTesting as dependencies of your test target:
.testTarget(
name: "MyTests",
dependencies: [
.product(name: "SubprocessMocks", package: "Subprocess"),
.product(name: "SubprocessTesting", package: "Subprocess"),
]
)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:
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:
@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"])
}
}
}Because each test's mocks are stored in a @TaskLocal, parameterised and parallel tests work without any extra setup:
@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()
}