Skip to content

Commit 1ae9e60

Browse files
committed
[SwiftTestTool] Add support for parallel test execution.
Adds a new `--parallel` mode to SwiftTestTool which spawns threads to execute the tests in parallel. The number of threads spawned is determined by the active processor count. The tests are executed in parallel by first determining test specifiers and then executing one specifier per thread.
1 parent 72e9232 commit 1ae9e60

File tree

7 files changed

+279
-33
lines changed

7 files changed

+279
-33
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
/*.xcodeproj
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import PackageDescription
2+
3+
let package = Package(
4+
name: "ParallelTestsPkg"
5+
)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
struct ParallelTests {
2+
3+
var text = "Hello, World!"
4+
var bool = false
5+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import XCTest
2+
@testable import ParallelTestsTests
3+
4+
XCTMain([
5+
testCase(ParallelTestsTests.allTests),
6+
])
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import XCTest
2+
@testable import ParallelTestsPkg
3+
4+
class ParallelTestsTests: XCTestCase {
5+
6+
func testExample1() {
7+
XCTAssertEqual(ParallelTests().text, "Hello, World!")
8+
}
9+
10+
func testExample2() {
11+
XCTAssertEqual(ParallelTests().bool, false)
12+
}
13+
14+
static var allTests : [(String, (ParallelTestsTests) -> () throws -> Void)] {
15+
return [
16+
("testExample1", testExample1),
17+
("testExample2", testExample2),
18+
]
19+
}
20+
}

Sources/Commands/SwiftTestTool.swift

Lines changed: 222 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public enum TestMode: Argument, Equatable, CustomStringConvertible {
4141
case version
4242
case listTests
4343
case run(String?)
44+
case runInParallel
4445

4546
public init?(argument: String, pop: @escaping () -> String?) throws {
4647
switch argument {
@@ -53,6 +54,8 @@ public enum TestMode: Argument, Equatable, CustomStringConvertible {
5354
self = .run(specifier)
5455
case "--version":
5556
self = .version
57+
case "--parallel":
58+
self = .runInParallel
5659
default:
5760
return nil
5861
}
@@ -67,6 +70,8 @@ public enum TestMode: Argument, Equatable, CustomStringConvertible {
6770
case .run(let specifier):
6871
return specifier ?? ""
6972
case .version: return "--version"
73+
case .runInParallel:
74+
return "--parallel"
7075
}
7176
}
7277
}
@@ -141,7 +146,7 @@ public class SwiftTestTool: SwiftTool<TestMode, TestToolOptions> {
141146

142147
case .listTests:
143148
let testPath = try buildTestsIfNeeded(options)
144-
let testSuites = try getTestSuites(path: testPath)
149+
let testSuites = try SwiftTestTool.getTestSuites(path: testPath)
145150
// Print the tests.
146151
for testSuite in testSuites {
147152
for testCase in testSuite.tests {
@@ -153,8 +158,14 @@ public class SwiftTestTool: SwiftTool<TestMode, TestToolOptions> {
153158

154159
case .run(let specifier):
155160
let testPath = try buildTestsIfNeeded(options)
156-
let success = test(path: testPath, xctestArg: specifier)
161+
let success: Bool = TestRunner(path: testPath, xctestArg: specifier).test()
157162
exit(success ? 0 : 1)
163+
164+
case .runInParallel:
165+
let testPath = try buildTestsIfNeeded(options)
166+
let runner = ParallelTestRunner(testPath: testPath)
167+
try runner.run()
168+
exit(runner.success ? 0 : 1)
158169
}
159170
}
160171

@@ -244,39 +255,11 @@ public class SwiftTestTool: SwiftTool<TestMode, TestToolOptions> {
244255
return (mode ?? .run(nil), options)
245256
}
246257

247-
/// Executes the XCTest binary with given arguments.
248-
///
249-
/// - Parameters:
250-
/// - path: Path to a valid XCTest binary.
251-
/// - xctestArg: Arguments to pass to the XCTest binary.
252-
///
253-
/// - Returns: True if execution exited with return code 0.
254-
private func test(path: AbsolutePath, xctestArg: String? = nil) -> Bool {
255-
var args: [String] = []
256-
#if os(macOS)
257-
args = ["xcrun", "xctest"]
258-
if let xctestArg = xctestArg {
259-
args += ["-XCTest", xctestArg]
260-
}
261-
args += [path.asString]
262-
#else
263-
args += [path.asString]
264-
if let xctestArg = xctestArg {
265-
args += [xctestArg]
266-
}
267-
#endif
268-
269-
// Execute the XCTest with inherited environment as it is convenient to pass senstive
270-
// information like username, password etc to test cases via environment variables.
271-
let result: Void? = try? system(args, environment: ProcessInfo.processInfo.environment)
272-
return result != nil
273-
}
274-
275258
/// Locates XCTestHelper tool inside the libexec directory and bin directory.
276259
/// Note: It is a fatalError if we are not able to locate the tool.
277260
///
278261
/// - Returns: Path to XCTestHelper tool.
279-
private func xctestHelperPath() -> AbsolutePath {
262+
private static func xctestHelperPath() -> AbsolutePath {
280263
let xctestHelperBin = "swiftpm-xctest-helper"
281264
let binDirectory = AbsolutePath(CommandLine.arguments.first!, relativeTo: currentWorkingDirectory).parentDirectory
282265
// XCTestHelper tool is installed in libexec.
@@ -303,11 +286,11 @@ public class SwiftTestTool: SwiftTool<TestMode, TestToolOptions> {
303286
/// - Throws: TestError, SystemError, Utility.Errror
304287
///
305288
/// - Returns: Array of TestSuite
306-
private func getTestSuites(path: AbsolutePath) throws -> [TestSuite] {
289+
fileprivate static func getTestSuites(path: AbsolutePath) throws -> [TestSuite] {
307290
// Run the correct tool.
308291
#if os(macOS)
309292
let tempFile = try TemporaryFile()
310-
let args = [xctestHelperPath().asString, path.asString, tempFile.path.asString]
293+
let args = [SwiftTestTool.xctestHelperPath().asString, path.asString, tempFile.path.asString]
311294
try system(args, environment: ["DYLD_FRAMEWORK_PATH": try platformFrameworksPath().asString])
312295
// Read the temporary file's content.
313296
let data = try fopen(tempFile.path).readFileContents()
@@ -320,6 +303,212 @@ public class SwiftTestTool: SwiftTool<TestMode, TestToolOptions> {
320303
}
321304
}
322305

306+
/// A class to run tests on a XCTest binary.
307+
///
308+
/// Note: Executes the XCTest with inherited environment as it is convenient to pass senstive
309+
/// information like username, password etc to test cases via enviornment variables.
310+
final class TestRunner {
311+
/// Path to valid XCTest binary.
312+
private let path: AbsolutePath
313+
314+
/// Arguments to pass to XCTest if any.
315+
private let xctestArg: String?
316+
317+
/// Creates an instance of TestRunner.
318+
///
319+
/// - Parameters:
320+
/// - path: Path to valid XCTest binary.
321+
/// - xctestArg: Arguments to pass to XCTest.
322+
init(path: AbsolutePath, xctestArg: String? = nil) {
323+
self.path = path
324+
self.xctestArg = xctestArg
325+
}
326+
327+
/// Constructs arguments to execute XCTest.
328+
private func args() -> [String] {
329+
var args: [String] = []
330+
#if os(macOS)
331+
args = ["xcrun", "xctest"]
332+
if let xctestArg = xctestArg {
333+
args += ["-XCTest", xctestArg]
334+
}
335+
args += [path.asString]
336+
#else
337+
args += [path.asString]
338+
if let xctestArg = xctestArg {
339+
args += [xctestArg]
340+
}
341+
#endif
342+
return args
343+
}
344+
345+
/// Current inherited enviornment variables.
346+
private var environment: [String: String] {
347+
return ProcessInfo.processInfo.environment
348+
}
349+
350+
/// Executes the tests without printing anything on standard streams.
351+
///
352+
/// - Returns: A tuple with first bool member indicating if test execution returned code 0
353+
/// and second argument containing the output of the execution.
354+
func test() -> (Bool, String) {
355+
var output = ""
356+
var success = true
357+
do {
358+
try popen(args(), redirectStandardError: true, environment: environment) { line in
359+
output += line
360+
}
361+
} catch {
362+
success = false
363+
}
364+
return (success, output)
365+
}
366+
367+
/// Executes and returns execution status. Prints test output on standard streams.
368+
func test() -> Bool {
369+
let result: Void? = try? system(args(), environment: environment)
370+
return result != nil
371+
}
372+
}
373+
374+
/// A class to run tests in parallel.
375+
final class ParallelTestRunner {
376+
/// A structure representing an individual unit test.
377+
struct UnitTest {
378+
/// The name of the unit test.
379+
let name: String
380+
381+
/// The name of the test case.
382+
let testCase: String
383+
384+
/// The specifier argument which can be passed to XCTest.
385+
var specifier: String {
386+
return testCase + "/" + name
387+
}
388+
}
389+
390+
/// An enum representing result of a unit test execution.
391+
enum TestResult {
392+
case success(UnitTest)
393+
case failure(UnitTest, output: String)
394+
}
395+
396+
/// Path to XCTest binary.
397+
private let testPath: AbsolutePath
398+
399+
/// The queue containing list of tests to run (producer).
400+
private let pendingTests = SynchronizedQueue<UnitTest?>()
401+
402+
/// The queue containing tests which are finished running.
403+
private let finishedTests = SynchronizedQueue<TestResult?>()
404+
405+
/// Number of parallel workers to spawn.
406+
private var numJobs: Int {
407+
return ProcessInfo.processInfo.activeProcessorCount
408+
}
409+
410+
/// Instance of progress bar. Animating progress bar if stream is a terminal otherwise
411+
/// a simple progress bar.
412+
private let progressBar: ProgressBarProtocol
413+
414+
/// Number of tests that will be executed.
415+
private var numTests = 0
416+
417+
/// Number of the current tests that has been executed.
418+
private var numCurrentTest = 0
419+
420+
/// True if all tests executed successfully.
421+
private(set) var success: Bool = true
422+
423+
init(testPath: AbsolutePath) {
424+
self.testPath = testPath
425+
progressBar = createProgressBar(forStream: stdoutStream, header: "Tests")
426+
}
427+
428+
/// Updates the progress bar status.
429+
private func updateProgress(for test: UnitTest) {
430+
numCurrentTest += 1
431+
progressBar.update(percent: 100*numCurrentTest/numTests, text: test.specifier)
432+
}
433+
434+
func enqueueTests() throws {
435+
// Find all the test suites present in the test binary.
436+
let testSuites = try SwiftTestTool.getTestSuites(path: testPath)
437+
// FIXME: Add a count property in SynchronizedQueue.
438+
var numTests = 0
439+
// Enqueue all the tests.
440+
for testSuite in testSuites {
441+
for testCase in testSuite.tests {
442+
for test in testCase.tests {
443+
numTests += 1
444+
pendingTests.enqueue(UnitTest(name: test, testCase: testCase.name))
445+
}
446+
}
447+
}
448+
self.numTests = numTests
449+
self.numCurrentTest = 0
450+
// Enqueue the sentinels, we stop a thread when it encounters a sentinel in the queue.
451+
for _ in 0..<numJobs {
452+
pendingTests.enqueue(nil)
453+
}
454+
}
455+
456+
/// Executes the tests spawning parallel workers. Blocks calling thread until all workers are finished.
457+
func run() throws {
458+
// Enqueue all the tests.
459+
try enqueueTests()
460+
461+
// Create the worker threads.
462+
let workers: [Thread] = (0..<numJobs).map { _ in
463+
let thread = Thread {
464+
// Dequeue a specifier and run it till we encounter nil.
465+
while let test = self.pendingTests.dequeue() {
466+
let testRunner = TestRunner(path: self.testPath, xctestArg: test.specifier)
467+
let (success, output) = testRunner.test()
468+
if success {
469+
self.finishedTests.enqueue(.success(test))
470+
} else {
471+
self.success = false
472+
self.finishedTests.enqueue(.failure(test, output: output))
473+
}
474+
}
475+
}
476+
thread.start()
477+
return thread
478+
}
479+
480+
// Holds the output of test cases which failed.
481+
var failureOutput = [String]()
482+
// Report (consume) the tests which have finished running.
483+
while let result = finishedTests.dequeue() {
484+
switch result {
485+
case .success(let test):
486+
updateProgress(for: test)
487+
case .failure(let test, let output):
488+
updateProgress(for: test)
489+
failureOutput.append(output)
490+
}
491+
// We can't enqueue a sentinel into finished tests queue because we won't know
492+
// which test is last one so exit this when all the tests have finished running.
493+
if numCurrentTest == numTests { break }
494+
}
495+
496+
// Wait till all threads finish execution.
497+
workers.forEach { $0.join() }
498+
progressBar.complete()
499+
printFailures(failureOutput)
500+
}
501+
502+
/// Prints the output of the tests that failed.
503+
private func printFailures(_ failureOutput: [String]) {
504+
stdoutStream <<< "\n"
505+
for error in failureOutput {
506+
stdoutStream <<< error
507+
}
508+
stdoutStream.flush()
509+
}
510+
}
511+
323512
/// A struct to hold the XCTestSuite data.
324513
struct TestSuite {
325514

Tests/FunctionalTests/MiscellaneousTests.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,22 @@ class MiscellaneousTestCase: XCTestCase {
360360
}
361361
}
362362

363+
func testSwiftTestParallel() throws {
364+
// Running swift-test fixtures on linux is not yet possible.
365+
#if os(macOS)
366+
fixture(name: "Miscellaneous/ParallelTestsPkg") { prefix in
367+
// First try normal serial testing.
368+
var output = try SwiftPMProduct.SwiftTest.execute([], chdir: prefix, printIfError: true)
369+
XCTAssert(output.contains("Executed 2 tests"))
370+
// Run tests in parallel.
371+
output = try SwiftPMProduct.SwiftTest.execute(["--parallel"], chdir: prefix, printIfError: true)
372+
XCTAssert(output.contains("testExample2"))
373+
XCTAssert(output.contains("testExample1"))
374+
XCTAssert(output.contains("100%"))
375+
}
376+
#endif
377+
}
378+
363379
static var allTests = [
364380
("testPrintsSelectedDependencyVersion", testPrintsSelectedDependencyVersion),
365381
("testPackageWithNoSources", testPackageWithNoSources),
@@ -388,6 +404,7 @@ class MiscellaneousTestCase: XCTestCase {
388404
("testProductWithMissingModules", testProductWithMissingModules),
389405
("testSpaces", testSpaces),
390406
("testSecondBuildIsNullInModulemapGen", testSecondBuildIsNullInModulemapGen),
407+
("testSwiftTestParallel", testSwiftTestParallel),
391408
("testInitPackageNonc99Directory", testInitPackageNonc99Directory),
392409
]
393410
}

0 commit comments

Comments
 (0)