Skip to content

Commit dd677d0

Browse files
authored
Merge pull request #583 from aciidb0mb3r/parallel-tests
[SwiftTestTool] Add support for parallel test execution.
2 parents d3bdbe4 + 1ae9e60 commit dd677d0

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)