Skip to content

Commit a578ab1

Browse files
committed
Allow unit tests to import and link any main modules of executables that are implemented in Swift. This uses a new Swift compiler flag to set the name of the entry point when emitting object code, and then uses linker flags to rename the main executable module's entry point back to _main again when actually linking the executable.
This is guarded by a tools version check, since packages written this way won't be testable on older toolchains.
1 parent 5b0597d commit a578ab1

File tree

11 files changed

+201
-28
lines changed

11 files changed

+201
-28
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ Swift v.Next
66
* Improvements
77

88
Adding a dependency requirement can now be done with the convenience initializer `.package(url: String, branch: String)`.
9+
10+
Test targets can now link against executable targets as if they were libraries, so that they can test any data strutures or algorithms in them. All the code in the executable except for the main entry point itself is available to the unit test. Separate executables are still linked, and can be tested as a subprocess in the same way as before. This feature is available to tests defined in packages that have a tools version of `vNext` or newer.
911

1012

1113
Swift 5.4

Fixtures/Miscellaneous/ExeTest/Package.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
// swift-tools-version:5.3
1+
// swift-tools-version: 999.0
22
import PackageDescription
33

44
let package = Package(
55
name: "ExeTest",
66
targets: [
7-
.target(
7+
.executableTarget(
88
name: "Exe",
99
dependencies: []
1010
),
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// swift-tools-version: 999.0
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "TestableExe",
6+
targets: [
7+
.target(
8+
name: "TestableExe1"
9+
),
10+
.target(
11+
name: "TestableExe2"
12+
),
13+
.target(
14+
name: "TestableExe3"
15+
),
16+
.testTarget(
17+
name: "TestableExeTests",
18+
dependencies: [
19+
"TestableExe1",
20+
"TestableExe2",
21+
"TestableExe3",
22+
]
23+
),
24+
]
25+
)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
public func GetGreeting1() -> String {
2+
return "Hello, world"
3+
}
4+
5+
print("\(GetGreeting1())!")
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
public func GetGreeting2() -> String {
2+
return "Hello, planet"
3+
}
4+
5+
print("\(GetGreeting2())!")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
const char * GetGreeting3();
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#include <stdio.h>
2+
#include "include/TestableExe3.h"
3+
4+
const char * GetGreeting3() {
5+
return "Hello, universe";
6+
}
7+
8+
int main() {
9+
printf("%s!\n", GetGreeting3());
10+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import XCTest
2+
import TestableExe1
3+
import TestableExe2
4+
// import TestableExe3
5+
import class Foundation.Bundle
6+
7+
final class TestableExeTests: XCTestCase {
8+
func testExample() throws {
9+
// This is an example of a functional test case.
10+
// Use XCTAssert and related functions to verify your tests produce the correct
11+
// results.
12+
13+
print(GetGreeting1())
14+
XCTAssertEqual(GetGreeting1(), "Hello, world")
15+
print(GetGreeting2())
16+
XCTAssertEqual(GetGreeting2(), "Hello, planet")
17+
// XCTAssertEqual(String(cString: GetGreeting3()), "Hello, universe")
18+
19+
// Some of the APIs that we use below are available in macOS 10.13 and above.
20+
guard #available(macOS 10.13, *) else {
21+
return
22+
}
23+
24+
var execPath = productsDirectory.appendingPathComponent("TestableExe1")
25+
var process = Process()
26+
process.executableURL = execPath
27+
var pipe = Pipe()
28+
process.standardOutput = pipe
29+
try process.run()
30+
process.waitUntilExit()
31+
var data = pipe.fileHandleForReading.readDataToEndOfFile()
32+
var output = String(data: data, encoding: .utf8)
33+
XCTAssertEqual(output, "Hello, world!\n")
34+
35+
execPath = productsDirectory.appendingPathComponent("TestableExe2")
36+
process = Process()
37+
process.executableURL = execPath
38+
pipe = Pipe()
39+
process.standardOutput = pipe
40+
try process.run()
41+
process.waitUntilExit()
42+
data = pipe.fileHandleForReading.readDataToEndOfFile()
43+
output = String(data: data, encoding: .utf8)
44+
XCTAssertEqual(output, "Hello, planet!\n")
45+
46+
execPath = productsDirectory.appendingPathComponent("TestableExe3")
47+
process = Process()
48+
process.executableURL = execPath
49+
pipe = Pipe()
50+
process.standardOutput = pipe
51+
try process.run()
52+
process.waitUntilExit()
53+
data = pipe.fileHandleForReading.readDataToEndOfFile()
54+
output = String(data: data, encoding: .utf8)
55+
XCTAssertEqual(output, "Hello, universe!\n")
56+
}
57+
58+
/// Returns path to the built products directory.
59+
var productsDirectory: URL {
60+
#if os(macOS)
61+
for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") {
62+
return bundle.bundleURL.deletingLastPathComponent()
63+
}
64+
fatalError("couldn't find the products directory")
65+
#else
66+
return Bundle.main.bundleURL
67+
#endif
68+
}
69+
70+
static var allTests = [
71+
("testExample", testExample),
72+
]
73+
}

Sources/Build/BuildPlan.swift

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -515,7 +515,7 @@ public final class SwiftTargetBuildDescription {
515515

516516
/// The path to the swiftmodule file after compilation.
517517
var moduleOutputPath: AbsolutePath {
518-
let dirPath = (target.type == .executable) ? tempsPath : buildParameters.buildPath
518+
let dirPath = (target.type == .executable && toolsVersion < .vNext) ? tempsPath : buildParameters.buildPath
519519
return dirPath.appending(component: target.c99name + ".swiftmodule")
520520
}
521521

@@ -690,6 +690,16 @@ public final class SwiftTargetBuildDescription {
690690
args += buildParameters.sanitizers.compileSwiftFlags()
691691
args += ["-parseable-output"]
692692

693+
// If we're compiling the main module of an executable other than the one that
694+
// implements a test suite, and if the package tools version indicates that we
695+
// should, we rename the `_main` entry point to `_<modulename>_main`. This will
696+
// allow tests to link against them without conflicts. Then, when we link the
697+
// executable, we will ask the linker to rename the entry point symbol to just
698+
// `_main` again.
699+
if target.type == .executable && !isTestTarget && toolsVersion >= .vNext {
700+
args += ["-Xfrontend", "-entry-point-function-name", "-Xfrontend", "\(target.c99name)_main"]
701+
}
702+
693703
// Only add the build path to the framework search path if there are binary frameworks to link against.
694704
if !libraryBinaryPaths.isEmpty {
695705
args += ["-F", buildParameters.buildPath.pathString]
@@ -1047,7 +1057,7 @@ public final class ProductBuildDescription {
10471057
return buildParameters.binaryPath(for: product)
10481058
}
10491059

1050-
/// The objects in this product.
1060+
/// All object files to link into this product.
10511061
///
10521062
// Computed during build planning.
10531063
public fileprivate(set) var objects = SortedArray<AbsolutePath>()
@@ -1167,6 +1177,24 @@ public final class ProductBuildDescription {
11671177
}
11681178
}
11691179
args += ["-emit-executable"]
1180+
1181+
// If we're linking an executable whose main module is implemented in Swift,
1182+
// we rename the `_<modulename>_main` entry point symbol to `_main` again.
1183+
// This is because executable modules implemented in Swift are compiled with
1184+
// a main symbol named that way to allow tests to link against it without
1185+
// conflicts. If we're using a linker that doesn't support symbol renaming,
1186+
// an alternate implementation could use a generated source file with a stub
1187+
// implementation of `_main` to call the renamed main symbol.
1188+
// Support for linking tests againsts executables is conditional on the tools
1189+
// version of the package that defines the executable product.
1190+
if let execModule = product.executableModule.underlyingTarget as? SwiftTarget, toolsVersion >= .vNext {
1191+
if buildParameters.triple.isDarwin() {
1192+
args += ["-Xlinker", "-alias", "-Xlinker", "_\(execModule.c99name)_main", "-Xlinker", "_main"]
1193+
}
1194+
else {
1195+
args += ["-Xlinker", "--defsym", "-Xlinker", "main=\(execModule.c99name)_main"]
1196+
}
1197+
}
11701198
case .plugin:
11711199
throw InternalError("unexpectedly asked to generate linker arguments for a plugin product")
11721200
}
@@ -1669,9 +1697,21 @@ public class BuildPlan {
16691697
switch dependency {
16701698
case .target(let target, _):
16711699
switch target.type {
1672-
// Include executable and tests only if they're top level contents
1673-
// of the product. Otherwise they are just build time dependency.
1674-
case .executable, .test:
1700+
// Executable target have historically only been included if they are directly in the product's
1701+
// target list. Otherwise they have always been just build-time dependencies.
1702+
// In tool version .vNext or greater, we also include executable modules implemented in Swift in
1703+
// any test products... this is to allow testing of executables. Note that they are also still
1704+
// built as separate products that the test can invoke as subprocesses.
1705+
case .executable:
1706+
if product.targets.contains(target) {
1707+
staticTargets.append(target)
1708+
} else if product.type == .test && target.underlyingTarget is SwiftTarget {
1709+
if let toolsVersion = graph.package(for: product)?.manifest.toolsVersion, toolsVersion >= .vNext {
1710+
staticTargets.append(target)
1711+
}
1712+
}
1713+
// Test targets should be included only if they are directly in the product's target list.
1714+
case .test:
16751715
if product.targets.contains(target) {
16761716
staticTargets.append(target)
16771717
}

Tests/FunctionalTests/MiscellaneousTests.swift

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors
4+
Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See http://swift.org/LICENSE.txt for license information
@@ -578,25 +578,20 @@ class MiscellaneousTestCase: XCTestCase {
578578
#endif
579579
}
580580

581-
func testErrorMessageWhenTestLinksExecutable() {
582-
fixture(name: "Miscellaneous/ExeTest") { prefix in
581+
func testTestsCanLinkAgainstExecutable() throws {
582+
// Check if the host compiler supports the '-entry-point-function-name' flag.
583+
try XCTSkipUnless(doesHostSwiftCompilerSupportRenamingMainSymbol(), "skipping because host compiler doesn't support '-entry-point-function-name'")
584+
585+
fixture(name: "Miscellaneous/TestableExe") { prefix in
583586
do {
584-
try executeSwiftTest(prefix)
585-
XCTFail()
586-
} catch SwiftPMProductError.executionFailure(let error, let output, let stderr) {
587-
XCTAssertMatch(stderr + output, .contains("Compiling Exe main.swift"))
588-
XCTAssertMatch(stderr + output, .contains("Compiling ExeTests ExeTests.swift"))
589-
XCTAssertMatch(stderr + output, .regex("error: no such module 'Exe'"))
590-
XCTAssertMatch(stderr + output, .regex("note: module 'Exe' is the main module of an executable, and cannot be imported by tests and other targets"))
591-
592-
if case ProcessResult.Error.nonZeroExit(let result) = error {
593-
// if our code crashes we'll get an exit code of 256
594-
XCTAssertEqual(result.exitStatus, .terminated(code: 1))
595-
} else {
596-
XCTFail("\(stderr + output)")
597-
}
587+
let (stdout, _) = try executeSwiftTest(prefix)
588+
XCTAssertMatch(stdout, .contains("Linking TestableExe1"))
589+
XCTAssertMatch(stdout, .contains("Linking TestableExe2"))
590+
XCTAssertMatch(stdout, .contains("Linking TestableExePackageTests"))
591+
XCTAssertMatch(stdout, .contains("Hello, world"))
592+
XCTAssertMatch(stdout, .contains("Hello, planet"))
598593
} catch {
599-
XCTFail()
594+
XCTFail("\(error)")
600595
}
601596
}
602597
}
@@ -613,3 +608,12 @@ class MiscellaneousTestCase: XCTestCase {
613608
}
614609
}
615610
}
611+
612+
func doesHostSwiftCompilerSupportRenamingMainSymbol() throws -> Bool {
613+
try withTemporaryDirectory { tmpDir in
614+
let hostToolchain = try UserToolchain(destination: .hostDestination())
615+
FileManager.default.createFile(atPath: "\(tmpDir)/foo.swift", contents: Data())
616+
let result = try Process.popen(args: hostToolchain.swiftCompiler.pathString, "-c", "-Xfrontend", "-entry-point-function-name", "-Xfrontend", "foo", "\(tmpDir)/foo.swift", "-o", "\(tmpDir)/foo.o")
617+
return try !result.utf8stderrOutput().contains("unknown argument: '-entry-point-function-name'")
618+
}
619+
}

Tests/FunctionalTests/PluginTests.swift

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ import TSCBasic
1414

1515
class PluginTests: XCTestCase {
1616

17-
func testUseOfBuildToolPluginTargetByExecutableInSamePackage() {
17+
func testUseOfBuildToolPluginTargetByExecutableInSamePackage() throws {
18+
// Check if the host compiler supports the '-entry-point-function-name' flag. It's not needed for this test but is needed to build any executable from a package that uses tools version 999.0.
19+
try XCTSkipUnless(doesHostSwiftCompilerSupportRenamingMainSymbol(), "skipping because host compiler doesn't support '-entry-point-function-name'")
1820

1921
fixture(name: "Miscellaneous/Plugins") { path in
2022
do {
@@ -31,7 +33,10 @@ class PluginTests: XCTestCase {
3133
}
3234
}
3335

34-
func testUseOfBuildToolPluginProductByExecutableAcrossPackages() {
36+
func testUseOfBuildToolPluginProductByExecutableAcrossPackages() throws {
37+
// Check if the host compiler supports the '-entry-point-function-name' flag. It's not needed for this test but is needed to build any executable from a package that uses tools version 999.0.
38+
try XCTSkipUnless(doesHostSwiftCompilerSupportRenamingMainSymbol(), "skipping because host compiler doesn't support '-entry-point-function-name'")
39+
3540
fixture(name: "Miscellaneous/Plugins") { path in
3641
do {
3742
let (stdout, _) = try executeSwiftBuild(path.appending(component: "MySourceGenClient"), configuration: .Debug, extraArgs: ["--product", "MyTool"], env: ["SWIFTPM_ENABLE_PLUGINS": "1"])
@@ -47,7 +52,10 @@ class PluginTests: XCTestCase {
4752
}
4853
}
4954

50-
func testUseOfPrebuildPluginTargetByExecutableAcrossPackages() {
55+
func testUseOfPrebuildPluginTargetByExecutableAcrossPackages() throws {
56+
// Check if the host compiler supports the '-entry-point-function-name' flag. It's not needed for this test but is needed to build any executable from a package that uses tools version 999.0.
57+
try XCTSkipUnless(doesHostSwiftCompilerSupportRenamingMainSymbol(), "skipping because host compiler doesn't support '-entry-point-function-name'")
58+
5159
fixture(name: "Miscellaneous/Plugins") { path in
5260
do {
5361
let (stdout, _) = try executeSwiftBuild(path.appending(component: "MySourceGenPlugin"), configuration: .Debug, extraArgs: ["--product", "MyOtherLocalTool"], env: ["SWIFTPM_ENABLE_PLUGINS": "1"])

0 commit comments

Comments
 (0)