* Add test command and service

* Fix warnings.

* Run test with device and ios specified.

* Prefer booted device.

* Recognize host application.

* Add AppCore framework for tests fixture.

* Add Derived folder to .gitignore.

* Error when no available device was found.

* Change input arguments.

* Add TestServiceTests.

* Add XcodeBuildController + SimulatorController tests.

* Run only tests scheme with single test target.

* Add test command documentation.

* Add test acceptance tests.

* Run swiftformat.

* Rename --os-version argument to --os.

* Add fixture for test command.

* Fix compilation issues.

Co-authored-by: Pedro Piñera <pedro@ppinera.es>
This commit is contained in:
Marek Fořt 2020-10-26 19:23:42 +01:00 committed by GitHub
parent 8f6fa475af
commit c3de526717
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 1399 additions and 91 deletions

View File

@ -85,6 +85,7 @@ jobs:
'scaffold',
'up',
'build',
'test',
]
steps:
- uses: actions/checkout@v1

View File

@ -24,7 +24,7 @@
"repositoryURL": "https://github.com/rnine/Checksum.git",
"state": {
"branch": null,
"revision": "cd1ae53384dd578a84a0afef492a4f5d6202b068",
"revision": "9dde3d1d898a5074608a1420791ef0a80c2399f2",
"version": "1.0.2"
}
},

View File

@ -19,6 +19,12 @@ public protocol BuildGraphInspecting {
/// - graph: Dependency graph.
func buildableTarget(scheme: Scheme, graph: Graph) -> Target?
/// From the list of testable targets of the given scheme, it returns the first one.
/// - Parameters:
/// - scheme: Scheme in which to look up the target.
/// - graph: Dependency graph.
func testableTarget(scheme: Scheme, graph: Graph) -> Target?
/// Given a graph, it returns a list of buildable schemes.
/// - Parameter graph: Dependency graph.
func buildableSchemes(graph: Graph) -> [Scheme]
@ -27,6 +33,14 @@ public protocol BuildGraphInspecting {
/// - Parameters:
/// - graph: Dependency graph
func buildableEntrySchemes(graph: Graph) -> [Scheme]
/// Given a graph, it returns a list of test schemes (those that include only one test target).
/// - Parameter graph: Dependency graph.
func testSchemes(graph: Graph) -> [Scheme]
/// Given a graph, it returns a list of testable schemes.
/// - Parameter graph: Dependency graph.
func testableSchemes(graph: Graph) -> [Scheme]
}
public class BuildGraphInspector: BuildGraphInspecting {
@ -53,11 +67,22 @@ public class BuildGraphInspector: BuildGraphInspecting {
}
public func buildableTarget(scheme: Scheme, graph: Graph) -> Target? {
if scheme.buildAction?.targets.count == 0 {
guard
scheme.buildAction?.targets.isEmpty == false,
let buildTarget = scheme.buildAction?.targets.first
else {
return nil
}
let buildTarget = scheme.buildAction!.targets.first!
return graph.target(path: buildTarget.projectPath, name: buildTarget.name)!.target
return graph.target(path: buildTarget.projectPath, name: buildTarget.name)?.target
}
public func testableTarget(scheme: Scheme, graph: Graph) -> Target? {
if scheme.testAction?.targets.count == 0 {
return nil
}
let testTarget = scheme.testAction!.targets.first!
return graph.target(path: testTarget.target.projectPath, name: testTarget.target.name)!.target
}
public func buildableSchemes(graph: Graph) -> [Scheme] {
@ -74,6 +99,25 @@ public class BuildGraphInspector: BuildGraphInspecting {
.sorted(by: { $0.name < $1.name })
}
public func testableSchemes(graph: Graph) -> [Scheme] {
graph.schemes
.filter { $0.testAction?.targets.isEmpty == false }
.sorted(by: { $0.name < $1.name })
}
public func testSchemes(graph: Graph) -> [Scheme] {
graph.targets.values.flatMap { target -> [Scheme] in
target
.filter { $0.target.product == .unitTests || $0.target.product == .uiTests }
.flatMap { target -> [Scheme] in
target.project.schemes
.filter { $0.targetDependencies().map(\.name) == [target.name] }
}
}
.filter { $0.testAction?.targets.isEmpty == false }
.sorted(by: { $0.name < $1.name })
}
public func workspacePath(directory: AbsolutePath) throws -> AbsolutePath? {
try directory.glob("**/*.xcworkspace")
.filter {

View File

@ -53,6 +53,40 @@ public final class XcodeBuildController: XcodeBuildControlling {
return run(command: command)
}
public func test(
_ target: XcodeBuildTarget,
scheme: String,
clean: Bool = false,
destination: XcodeBuildDestination,
arguments: [XcodeBuildArgument]
) -> Observable<SystemEvent<XcodeBuildOutput>> {
var command = ["/usr/bin/xcrun", "xcodebuild"]
// Action
if clean {
command.append("clean")
}
command.append("test")
// Scheme
command.append(contentsOf: ["-scheme", scheme])
// Target
command.append(contentsOf: target.xcodebuildArguments)
// Arguments
command.append(contentsOf: arguments.flatMap { $0.arguments })
switch destination {
case let .device(udid):
command.append(contentsOf: ["-destination", "id=\(udid)"])
case .mac:
break
}
return run(command: command)
}
public func archive(_ target: XcodeBuildTarget,
scheme: String,
clean: Bool,

View File

@ -43,4 +43,27 @@ public final class MockBuildGraphInspector: BuildGraphInspecting {
return []
}
}
public var testableTargetStub: ((Scheme, Graph) -> Target?)?
public func testableTarget(scheme: Scheme, graph: Graph) -> Target? {
if let testableTargetStub = testableTargetStub {
return testableTargetStub(scheme, graph)
} else {
return Target.test()
}
}
public var testableSchemesStub: ((Graph) -> [Scheme])?
public func testableSchemes(graph: Graph) -> [Scheme] {
if let testableSchemesStub = testableSchemesStub {
return testableSchemesStub(graph)
} else {
return []
}
}
public var testSchemesStub: ((Graph) -> [Scheme])?
public func testSchemes(graph: Graph) -> [Scheme] {
testSchemesStub?(graph) ?? []
}
}

View File

@ -3,6 +3,11 @@ import RxSwift
import TSCBasic
import TuistSupport
public enum XcodeBuildDestination: Equatable {
case device(String)
case mac
}
public protocol XcodeBuildControlling {
/// Returns an observable to build the given project using xcodebuild.
/// - Parameters:
@ -15,6 +20,20 @@ public protocol XcodeBuildControlling {
clean: Bool,
arguments: [XcodeBuildArgument]) -> Observable<SystemEvent<XcodeBuildOutput>>
/// Returns an observable to test the given project using xcodebuild.
/// - Parameters:
/// - target: The project or workspace to be built.
/// - scheme: The scheme of the project that should be built.
/// - clean: True if xcodebuild should clean the project before building.
/// - arguments: Extra xcodebuild arguments.
func test(
_ target: XcodeBuildTarget,
scheme: String,
clean: Bool,
destination: XcodeBuildDestination,
arguments: [XcodeBuildArgument]
) -> Observable<SystemEvent<XcodeBuildOutput>>
/// Returns an observable that archives the given project using xcodebuild.
/// - Parameters:
/// - target: The project or workspace to be archived.

View File

@ -551,6 +551,12 @@ public class Graph: Encodable, Equatable {
} ?? nil
}
/// - Returns: Host application for a given `targetNode`, if it exists
public func hostApplication(for targetNode: TargetNode) -> TargetNode? {
targetDependencies(path: targetNode.path, name: targetNode.name)
.first(where: { $0.target.product == .app })
}
/// Returns a copy of the graph with the given projects set.
/// - Parameter projects: Projects to be set to the copy.
public func with(projects: [Project]) -> Graph {
@ -643,11 +649,6 @@ public class Graph: Encodable, Equatable {
.product(target: targetNode.target.name, productName: targetNode.target.productNameWithExtension)
}
fileprivate func hostApplication(for targetNode: TargetNode) -> TargetNode? {
targetDependencies(path: targetNode.path, name: targetNode.name)
.first(where: { $0.target.product == .app })
}
fileprivate func isStaticLibrary(targetNode: TargetNode) -> Bool {
targetNode.target.product.isStatic
}

View File

@ -1,5 +1,6 @@
import Foundation
import RxSwift
import struct TSCUtility.Version
import TuistSupport
public protocol SimulatorControlling {
@ -9,22 +10,43 @@ public protocol SimulatorControlling {
/// Returns the list of simulator runtimes that are available in the system.
func runtimes() -> Single<[SimulatorRuntime]>
/// Returns the list of simulator devices and runtimes.
/// - Parameters:
/// - platform: Optionally filter by platform
/// - deviceName: Optionally filter by device name
/// - Returns: the list of simulator devices and runtimes.
func devicesAndRuntimes() -> Single<[SimulatorDeviceAndRuntime]>
/// Finds first available device defined by given parameters
/// - Parameters:
/// - platform: Given platform
/// - version: Specific version, ignored if nil
/// - minVersion: Minimum version of the OS
/// - deviceName: Specific device name (eg. iPhone X)
func findAvailableDevice(
platform: Platform,
version: Version?,
minVersion: Version?,
deviceName: String?
) -> Single<SimulatorDevice>
}
public enum SimulatorControllerError: FatalError {
case simctlError(String)
case deviceNotFound(Platform, Version?, String?, [SimulatorDeviceAndRuntime])
public var type: ErrorType {
switch self {
case .simctlError: return .abort
case .simctlError, .deviceNotFound:
return .abort
}
}
public var description: String {
switch self {
case let .simctlError(output): return output
case let .simctlError(output):
return output
case let .deviceNotFound(platform, version, deviceName, devices):
return "Could not find a suitable device for \(platform.caseValue)\(version.map { " \($0)" } ?? "")\(deviceName.map { ", device name \($0)" } ?? ""). Did find \(devices.map { "\($0.device.name) (\($0.runtime.description))" }.joined(separator: ", "))"
}
}
}
@ -101,4 +123,36 @@ public final class SimulatorController: SimulatorControlling {
}
}
}
public func findAvailableDevice(
platform: Platform,
version: Version?,
minVersion: Version?,
deviceName: String?
) -> Single<SimulatorDevice> {
devicesAndRuntimes()
.flatMap { devicesAndRuntimes in
let availableDevices = devicesAndRuntimes
.filter { simulatorDeviceAndRuntime in
let nameComponents = simulatorDeviceAndRuntime.runtime.name.components(separatedBy: " ")
guard nameComponents.first == platform.caseValue else { return false }
let deviceVersion = nameComponents.last?.version()
if let version = version {
guard deviceVersion == version else { return false }
} else if let minVersion = minVersion, let deviceVersion = deviceVersion {
guard deviceVersion >= minVersion else { return false }
}
if let deviceName = deviceName {
guard simulatorDeviceAndRuntime.device.name == deviceName else { return false }
}
return true
}
.map(\.device)
guard
let device = availableDevices.first(where: { !$0.isShutdown }) ?? availableDevices.first
else { return .error(SimulatorControllerError.deviceNotFound(platform, version, deviceName, devicesAndRuntimes)) }
return .just(device)
}
}
}

View File

@ -39,16 +39,17 @@ public struct SimulatorDevice: Decodable, Hashable, CustomStringConvertible {
name
}
public init(dataPath: AbsolutePath,
logPath: AbsolutePath,
udid: String,
isAvailable: Bool,
deviceTypeIdentifier: String,
state: String,
name: String,
availabilityError: String?,
runtimeIdentifier: String)
{
public init(
dataPath: AbsolutePath,
logPath: AbsolutePath,
udid: String,
isAvailable: Bool,
deviceTypeIdentifier: String,
state: String,
name: String,
availabilityError: String?,
runtimeIdentifier: String
) {
self.dataPath = dataPath
self.logPath = logPath
self.udid = udid

View File

@ -25,14 +25,15 @@ public struct SimulatorRuntime: Decodable, Hashable, CustomStringConvertible {
// Name of the runtime (e.g. iOS 13.5)
public let name: String
init(bundlePath: AbsolutePath,
buildVersion: String,
runtimeRoot: AbsolutePath,
identifier: String,
version: SimulatorRuntimeVersion,
isAvailable: Bool,
name: String)
{
public init(
bundlePath: AbsolutePath,
buildVersion: String,
runtimeRoot: AbsolutePath,
identifier: String,
version: SimulatorRuntimeVersion,
isAvailable: Bool,
name: String
) {
self.bundlePath = bundlePath
self.buildVersion = buildVersion
self.runtimeRoot = runtimeRoot

View File

@ -19,6 +19,21 @@ final class MockXcodeBuildController: XcodeBuildControlling {
}
}
var testStub: ((XcodeBuildTarget, String, Bool, XcodeBuildDestination, [XcodeBuildArgument]) -> Observable<SystemEvent<XcodeBuildOutput>>)?
func test(
_ target: XcodeBuildTarget,
scheme: String,
clean: Bool,
destination: XcodeBuildDestination,
arguments: [XcodeBuildArgument]
) -> Observable<SystemEvent<XcodeBuildOutput>> {
if let testStub = testStub {
return testStub(target, scheme, clean, destination, arguments)
} else {
return Observable.error(TestError("\(String(describing: MockXcodeBuildController.self)) received an unexpected call to test"))
}
}
var archiveStub: ((XcodeBuildTarget, String, Bool, AbsolutePath, [XcodeBuildArgument]) -> Observable<SystemEvent<XcodeBuildOutput>>)?
func archive(_ target: XcodeBuildTarget,
scheme: String,

View File

@ -1,5 +1,7 @@
import Foundation
import RxSwift
import struct TSCUtility.Version
import TuistCore
import TuistSupport
@testable import TuistCore
@ -43,4 +45,9 @@ public final class MockSimulatorController: SimulatorControlling {
return .error(TestError("call to non-stubbed method runtimesAndDevices"))
}
}
public var findAvailableDeviceStub: ((Platform, Version?, Version?, String?) -> Single<SimulatorDevice>)?
public func findAvailableDevice(platform: Platform, version: Version?, minVersion: Version?, deviceName: String?) -> Single<SimulatorDevice> {
findAvailableDeviceStub?(platform, version, minVersion, deviceName) ?? .just(SimulatorDevice.test())
}
}

View File

@ -0,0 +1,69 @@
import ArgumentParser
import Foundation
import TSCBasic
import TuistSupport
/// Command that tests a target from the project in the current directory.
struct TestCommand: ParsableCommand {
static var configuration: CommandConfiguration {
CommandConfiguration(commandName: "test",
abstract: "Tests a project")
}
@Argument(
help: "The scheme to be tested. By default it tests all the testable targets of the project in the current directory."
)
var scheme: String?
@Flag(
help: "Force the generation of the project before testing."
)
var generate: Bool = false
@Flag(
help: "When passed, it cleans the project before testing it."
)
var clean: Bool = false
@Option(
name: .shortAndLong,
help: "The path to the directory that contains the project to be tested."
)
var path: String?
@Option(
name: .shortAndLong,
help: "Test on a specific device."
)
var device: String?
@Option(
name: .shortAndLong,
help: "Test with a specific version of the OS."
)
var os: String?
@Option(
name: [.long, .customShort("C")],
help: "The configuration to be used when testing the scheme."
)
var configuration: String?
func run() throws {
let absolutePath: AbsolutePath
if let path = path {
absolutePath = AbsolutePath(path)
} else {
absolutePath = FileHandler.shared.currentPath
}
try TestService().run(
schemeName: scheme,
generate: generate,
clean: clean,
configuration: configuration,
path: absolutePath,
deviceName: device,
osVersion: os
)
}
}

View File

@ -19,6 +19,7 @@ public struct TuistCommand: ParsableCommand {
LintCommand.self,
VersionCommand.self,
BuildCommand.self,
TestCommand.self,
CreateIssueCommand.self,
ScaffoldCommand.self,
InitCommand.self,

View File

@ -0,0 +1,171 @@
import Foundation
import RxBlocking
import TSCBasic
import struct TSCUtility.Version
import TuistAutomation
import TuistCore
import TuistSupport
enum TestServiceError: FatalError {
case schemeNotFound(scheme: String, existing: [String])
case schemeWithoutTestableTargets(scheme: String)
// Error description
var description: String {
switch self {
case let .schemeNotFound(scheme, existing):
return "Couldn't find scheme \(scheme). The available schemes are: \(existing.joined(separator: ", "))."
case let .schemeWithoutTestableTargets(scheme):
return "The scheme \(scheme) cannot be built because it contains no buildable targets."
}
}
// Error type
var type: ErrorType {
switch self {
case .schemeNotFound:
return .abort
case .schemeWithoutTestableTargets:
return .abort
}
}
}
final class TestService {
/// Project generator
let generator: Generating
/// Xcode build controller.
let xcodebuildController: XcodeBuildControlling
/// Build graph inspector.
let buildGraphInspector: BuildGraphInspecting
/// Simulator controller
let simulatorController: SimulatorControlling
init(
generator: Generating = Generator(),
xcodebuildController: XcodeBuildControlling = XcodeBuildController(),
buildGraphInspector: BuildGraphInspecting = BuildGraphInspector(),
simulatorController: SimulatorControlling = SimulatorController()
) {
self.generator = generator
self.xcodebuildController = xcodebuildController
self.buildGraphInspector = buildGraphInspector
self.simulatorController = simulatorController
}
func run(
schemeName: String?,
generate: Bool,
clean: Bool,
configuration: String?,
path: AbsolutePath,
deviceName: String?,
osVersion: String?
) throws {
let graph: Graph
if try (generate || buildGraphInspector.workspacePath(directory: path) == nil) {
graph = try generator.generateWithGraph(path: path, projectOnly: false).1
} else {
graph = try generator.load(path: path)
}
let version = osVersion?.version()
let testableSchemes = buildGraphInspector.testableSchemes(graph: graph)
logger.log(
level: .debug,
"Found the following testable schemes: \(testableSchemes.map(\.name).joined(separator: ", "))"
)
if let schemeName = schemeName {
guard let scheme = testableSchemes.first(where: { $0.name == schemeName }) else {
throw TestServiceError.schemeNotFound(scheme: schemeName, existing: testableSchemes.map(\.name))
}
try testScheme(
scheme: scheme,
graph: graph,
path: path,
clean: clean,
configuration: configuration,
version: version,
deviceName: deviceName
)
} else {
var cleaned: Bool = false
let testSchemes = buildGraphInspector.testSchemes(graph: graph)
try testSchemes.forEach {
try testScheme(
scheme: $0,
graph: graph,
path: path,
clean: !cleaned && clean,
configuration: configuration,
version: version,
deviceName: deviceName
)
cleaned = true
}
}
logger.log(level: .notice, "The project tests ran successfully", metadata: .success)
}
// MARK: - private
private func testScheme(
scheme: Scheme,
graph: Graph,
path: AbsolutePath,
clean: Bool,
configuration: String?,
version: Version?,
deviceName: String?
) throws {
logger.log(level: .notice, "Testing scheme \(scheme.name)", metadata: .section)
guard let buildableTarget = buildGraphInspector.testableTarget(scheme: scheme, graph: graph) else {
throw TestServiceError.schemeWithoutTestableTargets(scheme: scheme.name)
}
let destination: XcodeBuildDestination
switch buildableTarget.platform {
case .iOS, .tvOS, .watchOS:
let minVersion: Version?
if let deploymentTarget = buildableTarget.deploymentTarget {
minVersion = deploymentTarget.version.version()
} else {
minVersion = scheme.targetDependencies()
.compactMap { graph.findTargetNode(path: $0.projectPath, name: $0.name) }
.flatMap { $0.targetDependencies.compactMap { $0.target.deploymentTarget?.version } }
.compactMap { $0.version() }
.sorted()
.first
}
let device = try simulatorController.findAvailableDevice(
platform: buildableTarget.platform,
version: version,
minVersion: minVersion,
deviceName: deviceName
)
.toBlocking()
.single()
destination = .device(device.udid)
case .macOS:
destination = .mac
}
let workspacePath = try buildGraphInspector.workspacePath(directory: path)!
_ = try xcodebuildController.test(
.workspace(workspacePath),
scheme: scheme.name,
clean: clean,
destination: destination,
arguments: buildGraphInspector.buildArguments(target: buildableTarget, configuration: configuration)
)
.printFormattedOutput()
.toBlocking()
.last()
}
}

View File

@ -1,4 +1,5 @@
import Foundation
import struct TSCUtility.Version
extension String {
// swiftlint:disable:next force_try
@ -69,6 +70,14 @@ extension String {
length: utf16.distance(from: from, to: to))
}
public func version() -> Version? {
if components(separatedBy: ".").count == 2 {
return Version(string: self + ".0")
} else {
return Version(string: self)
}
}
public func capitalizingFirstLetter() -> String {
prefix(1).capitalized + dropFirst()
}

View File

@ -48,4 +48,19 @@ final class SimulatorControllerIntegrationTests: TuistTestCase {
let runtimes = try XCTUnwrap(got)
XCTAssertNotEmpty(runtimes)
}
func test_findAvailableDevice() throws {
// When
let got = try subject.findAvailableDevice(
platform: .iOS,
version: nil,
minVersion: nil,
deviceName: nil
)
.toBlocking()
.single()
// Then
XCTAssertTrue(got.isAvailable)
}
}

View File

@ -156,6 +156,138 @@ final class BuildGraphInspectorTests: TuistUnitTestCase {
)
}
func test_testSchemes() throws {
// Given
let path = try temporaryPath()
let projectPath = path.appending(component: "Project.xcodeproj")
let coreProjectPath = path.appending(component: "CoreProject.xcodeproj")
let coreScheme = Scheme.test(
name: "Core",
testAction: .test(
targets: [.init(target: .init(projectPath: projectPath, name: "CoreTests"))]
)
)
let coreTestsScheme = Scheme(
name: "CoreTests",
testAction: .test(
targets: [.init(target: .init(projectPath: projectPath, name: "CoreTests"))]
)
)
let kitScheme = Scheme.test(
name: "Kit",
testAction: .test(
targets: [.init(target: .init(projectPath: projectPath, name: "KitTests"))]
)
)
let kitTestsScheme = Scheme(
name: "KitTests",
testAction: .test(
targets: [.init(target: .init(projectPath: projectPath, name: "KitTests"))]
)
)
let coreTarget = Target.test(name: "Core")
let coreProject = Project.test(
path: coreProjectPath,
schemes: [coreScheme, coreTestsScheme]
)
let coreTargetNode = TargetNode.test(
project: coreProject,
target: coreTarget
)
let coreTestsTarget = Target.test(
name: "CoreTests",
product: .unitTests,
dependencies: [.target(name: "Core")]
)
let coreTestsTargetNode = TargetNode.test(
project: coreProject,
target: coreTestsTarget
)
let kitTarget = Target.test(name: "Kit", dependencies: [.target(name: "Core")])
let kitProject = Project.test(
path: projectPath,
schemes: [kitScheme, kitTestsScheme]
)
let kitTargetNode = TargetNode.test(
project: kitProject,
target: kitTarget
)
let kitTestsTarget = Target.test(
name: "KitTests",
product: .unitTests,
dependencies: [.target(name: "Kit")]
)
let kitTestsTargetNode = TargetNode.test(
project: kitProject,
target: kitTestsTarget
)
let graph = Graph.test(
entryNodes: [kitTargetNode],
targets: [
projectPath: [kitTargetNode, kitTestsTargetNode],
coreProjectPath: [coreTargetNode, coreTestsTargetNode],
]
)
// When
let got = subject.testSchemes(graph: graph)
// Then
XCTAssertEqual(
got,
[
coreTestsScheme,
kitTestsScheme,
]
)
}
func test_testableSchemes() throws {
// Given
let path = try temporaryPath()
let projectPath = path.appending(component: "Project.xcodeproj")
let coreProjectPath = path.appending(component: "CoreProject.xcodeproj")
let coreScheme = Scheme.test(
name: "Core",
testAction: .test(
targets: [.init(target: .init(projectPath: projectPath, name: "CoreTests"))]
)
)
let coreTestsScheme = Scheme(
name: "CoreTests",
testAction: .test(
targets: [.init(target: .init(projectPath: projectPath, name: "CoreTests"))]
)
)
let coreTarget = Target.test(name: "Core")
let coreProject = Project.test(
path: coreProjectPath,
schemes: [coreScheme, coreTestsScheme]
)
let coreTargetNode = TargetNode.test(
project: coreProject,
target: coreTarget
)
let graph = Graph.test(
entryNodes: [coreTargetNode],
projects: [
coreProject,
]
)
// When
let got = subject.testableSchemes(graph: graph)
// Then
XCTAssertEqual(
got,
[
coreScheme,
coreTestsScheme,
]
)
}
func test_buildableEntrySchemes_only_includes_entryTargets() throws {
// Given
let path = try temporaryPath()

View File

@ -68,4 +68,105 @@ final class XcodeBuildControllerTests: TuistUnitTestCase {
XCTFail("The command was not expected to fail")
}
}
func test_test_when_device() throws {
// Given
let path = try temporaryPath()
let xcworkspacePath = path.appending(component: "Project.xcworkspace")
let target = XcodeBuildTarget.workspace(xcworkspacePath)
let scheme = "Scheme"
let shouldOutputBeColoured = true
environment.shouldOutputBeColoured = shouldOutputBeColoured
var command = [
"/usr/bin/xcrun",
"xcodebuild",
"clean",
"test",
"-scheme",
scheme,
]
command.append(contentsOf: target.xcodebuildArguments)
command.append(contentsOf: ["-destination", "id=device-id"])
system.succeedCommand(command, output: "output")
var parseCalls: [(String, Bool)] = []
parser.parseStub = { output, colored in
parseCalls.append((output, colored))
return "formated-output"
}
// When
let events = subject.test(
target,
scheme: scheme,
clean: true,
destination: .device("device-id"),
arguments: []
)
.toBlocking()
.materialize()
// Then
XCTAssertEqual(parseCalls.count, 1)
XCTAssertEqual(parseCalls.first?.0, "output")
XCTAssertEqual(parseCalls.first?.1, shouldOutputBeColoured)
switch events {
case let .completed(output):
XCTAssertEqual(output, [.standardOutput(XcodeBuildOutput(raw: "output\n", formatted: "formated-output\n"))])
case .failed:
XCTFail("The command was not expected to fail")
}
}
func test_test_when_mac() throws {
// Given
let path = try temporaryPath()
let xcworkspacePath = path.appending(component: "Project.xcworkspace")
let target = XcodeBuildTarget.workspace(xcworkspacePath)
let scheme = "Scheme"
let shouldOutputBeColoured = true
environment.shouldOutputBeColoured = shouldOutputBeColoured
var command = [
"/usr/bin/xcrun",
"xcodebuild",
"clean",
"test",
"-scheme",
scheme,
]
command.append(contentsOf: target.xcodebuildArguments)
system.succeedCommand(command, output: "output")
var parseCalls: [(String, Bool)] = []
parser.parseStub = { output, colored in
parseCalls.append((output, colored))
return "formated-output"
}
// When
let events = subject.test(
target,
scheme: scheme,
clean: true,
destination: .mac,
arguments: []
)
.toBlocking()
.materialize()
// Then
XCTAssertEqual(parseCalls.count, 1)
XCTAssertEqual(parseCalls.first?.0, "output")
XCTAssertEqual(parseCalls.first?.1, shouldOutputBeColoured)
switch events {
case let .completed(output):
XCTAssertEqual(output, [.standardOutput(XcodeBuildOutput(raw: "output\n", formatted: "formated-output\n"))])
case .failed:
XCTFail("The command was not expected to fail")
}
}
}

View File

@ -0,0 +1,204 @@
import Foundation
import RxSwift
import TSCBasic
import TuistAutomation
import TuistCore
import XCTest
@testable import TuistAutomationTesting
@testable import TuistCoreTesting
@testable import TuistKit
@testable import TuistSupportTesting
final class TestServiceTests: TuistUnitTestCase {
private var subject: TestService!
private var generator: MockGenerator!
private var xcodebuildController: MockXcodeBuildController!
private var buildGraphInspector: MockBuildGraphInspector!
private var simulatorController: MockSimulatorController!
override func setUp() {
super.setUp()
generator = .init()
xcodebuildController = .init()
buildGraphInspector = .init()
simulatorController = .init()
subject = TestService(
generator: generator,
xcodebuildController: xcodebuildController,
buildGraphInspector: buildGraphInspector,
simulatorController: simulatorController
)
}
override func tearDown() {
generator = nil
xcodebuildController = nil
buildGraphInspector = nil
simulatorController = nil
subject = nil
super.tearDown()
}
func test_run_when_the_project_is_already_generated() throws {
// Given
let path = try temporaryPath()
let workspacePath = path.appending(component: "App.xcworkspace")
let graph = Graph.test()
let scheme = Scheme.test()
let target = Target.test()
let buildArguments: [XcodeBuildArgument] = [.sdk("iphoneos")]
generator.loadStub = { _path in
XCTAssertEqual(_path, path)
return graph
}
buildGraphInspector.testableSchemesStub = { _ in
[scheme]
}
buildGraphInspector.testableTargetStub = { _scheme, _ in
XCTAssertEqual(_scheme, scheme)
return target
}
buildGraphInspector.workspacePathStub = { _path in
XCTAssertEqual(_path, path)
return workspacePath
}
buildGraphInspector.buildArgumentsStub = { _target, _ in
XCTAssertEqual(_target, target)
return buildArguments
}
let availableDevice: SimulatorDevice = .test()
simulatorController.findAvailableDeviceStub = { _, _, _, _ in
.just(availableDevice)
}
xcodebuildController.testStub = { _target, _scheme, _clean, _destination, _arguments in
XCTAssertEqual(_target, .workspace(workspacePath))
XCTAssertEqual(_scheme, scheme.name)
XCTAssertTrue(_clean)
XCTAssertEqual(_arguments, buildArguments)
XCTAssertEqual(_destination, .device(availableDevice.udid))
return Observable.just(.standardOutput(.init(raw: "success", formatted: nil)))
}
// Then
try subject.testRun(
schemeName: scheme.name,
clean: true,
path: path
)
}
func test_run_only_cleans_the_first_time() throws {
// Given
let path = try temporaryPath()
let workspacePath = path.appending(component: "App.xcworkspace")
let graph = Graph.test()
let schemeA = Scheme.test(name: "A")
let schemeB = Scheme.test(name: "B")
let targetA = Target.test(name: "A")
let targetB = Target.test(name: "B")
let buildArguments: [XcodeBuildArgument] = [.sdk("iphoneos")]
generator.loadStub = { _path in
XCTAssertEqual(_path, path)
return graph
}
buildGraphInspector.buildableSchemesStub = { _ in
[schemeA, schemeB]
}
buildGraphInspector.buildableTargetStub = { _scheme, _ in
if _scheme == schemeA { return targetA }
else if _scheme == schemeB { return targetB }
else { XCTFail("unexpected scheme"); return targetA }
}
buildGraphInspector.workspacePathStub = { _path in
XCTAssertEqual(_path, path)
return workspacePath
}
buildGraphInspector.buildArgumentsStub = { _, _ in
buildArguments
}
xcodebuildController.testStub = { _target, _scheme, _clean, _, _arguments in
XCTAssertEqual(_target, .workspace(workspacePath))
XCTAssertEqual(_arguments, buildArguments)
if _scheme == "A" {
XCTAssertEqual(_scheme, "A")
XCTAssertTrue(_clean)
} else if _scheme == "B" {
// When running the second scheme clean should be false
XCTAssertEqual(_scheme, "B")
XCTAssertFalse(_clean)
} else {
XCTFail("unexpected scheme \(_scheme)")
}
return Observable.just(.standardOutput(.init(raw: "success", formatted: nil)))
}
// Then
try subject.testRun(
path: path
)
}
func test_run_lists_schemes() throws {
// Given
let path = try temporaryPath()
let workspacePath = path.appending(component: "App.xcworkspace")
let graph = Graph.test()
let schemeA = Scheme.test(name: "A")
let schemeB = Scheme.test(name: "B")
generator.loadStub = { _path in
XCTAssertEqual(_path, path)
return graph
}
buildGraphInspector.workspacePathStub = { _path in
XCTAssertEqual(_path, path)
return workspacePath
}
buildGraphInspector.testableSchemesStub = { _ in
[
schemeA,
schemeB,
]
}
xcodebuildController.testStub = { _, _, _, _, _ in
.just(.standardOutput(.init(raw: "success", formatted: nil)))
}
// When
try subject.testRun(
path: path
)
// Then
XCTAssertPrinterContains("Found the following testable schemes: A, B", at: .debug, ==)
}
}
// MARK: - Helpers
private extension TestService {
func testRun(
schemeName: String? = nil,
generate: Bool = false,
clean: Bool = false,
configuration: String? = nil,
path: AbsolutePath,
deviceName: String? = nil,
osVersion: String? = nil
) throws {
try run(
schemeName: schemeName,
generate: generate,
clean: clean,
configuration: configuration,
path: path,
deviceName: deviceName,
osVersion: osVersion
)
}
}

View File

@ -0,0 +1,15 @@
Then(/^tuist tests the project$/) do
system("swift", "run", "tuist", "test", "--path", @dir)
end
Then(/^tuist tests the scheme ([a-zA-Z]+) from the project$/) do |scheme|
system("swift", "run", "tuist", "test", scheme, "--path", @dir)
end
Then(/^tuist tests the scheme ([a-zA-Z]+) and configuration ([a-zA-Z]+) from the project$/) do |scheme, configuration|
system("swift", "run", "tuist", "test", scheme, "--path", @dir, "--configuration", configuration)
end
Then(/^tuist tests the project at (.+)$/) do |path|
system("swift", "run", "tuist", "test", "--path", File.join(@dir, path))
end

10
features/test.feature Normal file
View File

@ -0,0 +1,10 @@
Feature: Tests projects using Tuist test
Scenario: The project is an application with tests (app_with_tests)
Given that tuist is available
And I have a working directory
Then I copy the fixture app_with_tests into the working directory
Then tuist generates the project
Then tuist tests the project
Then tuist tests the scheme AppTests from the project
Then tuist tests the scheme MacFrameworkTests from the project
Then tuist tests the scheme App and configuration Debug from the project

67
fixtures/app_with_tests/.gitignore vendored Normal file
View File

@ -0,0 +1,67 @@
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Xcode ###
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
## User settings
xcuserdata/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint
*.xccheckout
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
build/
DerivedData/
*.moved-aside
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
### Xcode Patch ###
*.xcodeproj/*
!*.xcodeproj/project.pbxproj
!*.xcodeproj/xcshareddata/
!*.xcworkspace/contents.xcworkspacedata
/*.gcno
### Projects ###
*.xcodeproj
*.xcworkspace
### Tuist derived files ###
graph.dot
Derived/

View File

@ -0,0 +1,78 @@
import ProjectDescription
let project = Project(
name: "App",
organizationName: "tuist.io",
targets: [
Target(
name: "App",
platform: .iOS,
product: .app,
bundleId: "io.tuist.app",
deploymentTarget: .iOS(targetVersion: "13.0", devices: .iphone),
infoPlist: .default,
sources: ["Targets/App/Sources/**"]
),
Target(
name: "AppTests",
platform: .iOS,
product: .unitTests,
bundleId: "io.tuist.AppTests",
infoPlist: .default,
sources: ["Targets/App/Tests/**"],
dependencies: [
.target(name: "App")
]
),
Target(
name: "tvOSFramework",
platform: .tvOS,
product: .framework,
bundleId: "io.tuist.tvOSFramework",
infoPlist: .default,
sources: "Targets/tvOSFramework/Sources/**"
),
Target(
name: "tvOSFrameworkTests",
platform: .tvOS,
product: .unitTests,
bundleId: "io.tuist.tvOSFrameworkTests",
infoPlist: .default,
sources: "Targets/tvOSFramework/Tests/**",
dependencies: [
.target(name: "tvOSFramework"),
]
),
Target(
name: "MacFramework",
platform: .macOS,
product: .framework,
bundleId: "io.tuist.MacFramework",
infoPlist: .default,
sources: "Targets/MacFramework/Sources/**",
settings: Settings(
base: [
"CODE_SIGN_IDENTITY": "",
"CODE_SIGNING_REQUIRED": "NO"
]
)
),
Target(
name: "MacFrameworkTests",
platform: .macOS,
product: .unitTests,
bundleId: "io.tuist.MacFrameworkTests",
infoPlist: .default,
sources: "Targets/MacFramework/Tests/**",
dependencies: [
.target(name: "MacFramework"),
],
settings: Settings(
base: [
"CODE_SIGN_IDENTITY": "",
"CODE_SIGNING_REQUIRED": "NO"
]
)
),
]
)

View File

@ -0,0 +1,21 @@
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil
) -> Bool {
window = UIWindow(frame: UIScreen.main.bounds)
let viewController = UIViewController()
viewController.view.backgroundColor = .white
window?.rootViewController = viewController
window?.makeKeyAndVisible()
return true
}
}

View File

@ -0,0 +1,8 @@
import Foundation
import XCTest
final class AppTests: XCTestCase {
func test_twoPlusTwo_isFour() {
XCTAssertEqual(2+2, 4)
}
}

View File

@ -0,0 +1,7 @@
import Foundation
final class MacFramework {
func hello() -> String {
return "MacFramework.hello()"
}
}

View File

@ -0,0 +1,12 @@
import Foundation
import XCTest
@testable import MacFramework
final class MacFrameworkTests: XCTestCase {
func testHello() {
let sut = MacFramework()
XCTAssertEqual("MacFramework.hello()", sut.hello())
}
}

View File

@ -0,0 +1,7 @@
import Foundation
public final class tvOSFramework {
func hello() -> String {
return "tvOSFramework.hello()"
}
}

View File

@ -0,0 +1,12 @@
import Foundation
import XCTest
@testable import tvOSFramework
final class tvOSFrameworkTests: XCTestCase {
func testHello() {
let sut = tvOSFramework()
XCTAssertEqual("tvOSFramework.hello()", sut.hello())
}
}

View File

@ -60,4 +60,6 @@ DerivedData/
### Projects ###
*.xcodeproj
*.xcworkspace
*.xcworkspace
Derived

View File

@ -4,11 +4,9 @@ import XCTest
@testable import App
final class AppTests: XCTestCase {
func testHello() {
let sut = AppDelegate()
XCTAssertEqual("AppDelegate.hello()", sut.hello())
}
}

View File

@ -0,0 +1,7 @@
import Foundation
final class AppCore {
func hello() -> String {
return "AppCore.hello()"
}
}

View File

@ -0,0 +1,12 @@
import Foundation
import XCTest
@testable import AppCore
final class AppCoreTests: XCTestCase {
func testHello() {
let sut = AppCore()
XCTAssertEqual("AppCore.hello()", sut.hello())
}
}

View File

@ -0,0 +1,7 @@
import Foundation
final class MacFramework {
func hello() -> String {
return "MacFramework.hello()"
}
}

View File

@ -0,0 +1,12 @@
import Foundation
import XCTest
@testable import MacFramework
final class MacFrameworkTests: XCTestCase {
func testHello() {
let sut = MacFramework()
XCTAssertEqual("MacFramework.hello()", sut.hello())
}
}

View File

@ -1,60 +1,113 @@
import ProjectDescription
let project = Project(name: "App",
targets: [
Target(name: "App",
platform: .iOS,
product: .app,
bundleId: "io.tuist.App",
infoPlist: .file(path: .relativeToManifest("Info.plist")),
sources: .paths([.relativeToManifest("Sources/**")]),
dependencies: [
/* Target dependencies can be defined here */
/* .framework(path: "framework") */
],
settings: Settings(base: ["CODE_SIGN_IDENTITY": "",
"CODE_SIGNING_REQUIRED": "NO"])),
Target(name: "AppTests",
platform: .iOS,
product: .unitTests,
bundleId: "io.tuist.AppTests",
infoPlist: "Tests.plist",
sources: "Tests/**",
dependencies: [
.target(name: "App"),
],
settings: Settings(base: ["CODE_SIGN_IDENTITY": "",
"CODE_SIGNING_REQUIRED": "NO"])),
Target(name: "AppUITests",
platform: .iOS,
product: .uiTests,
bundleId: "io.tuist.AppUITests",
infoPlist: "Tests.plist",
sources: "UITests/**",
dependencies: [
.target(name: "App"),
]),
Target(name: "App-dash",
platform: .iOS,
product: .app,
bundleId: "io.tuist.AppDash",
infoPlist: "Info.plist",
sources: .paths([.relativeToManifest("Sources/**")]),
dependencies: [
/* Target dependencies can be defined here */
/* .framework(path: "framework") */
],
settings: Settings(base: ["CODE_SIGN_IDENTITY": "",
"CODE_SIGNING_REQUIRED": "NO"])),
Target(name: "App-dashUITests",
platform: .iOS,
product: .uiTests,
bundleId: "io.tuist.AppDashUITests",
infoPlist: "Tests.plist",
sources: "UITests/**",
dependencies: [
.target(name: "App-dash"),
]),
])
let project = Project(
name: "App",
targets: [
Target(
name: "AppCore",
platform: .iOS,
product: .framework,
bundleId: "io.tuist.AppCore",
deploymentTarget: .iOS(targetVersion: "12.0", devices: .iphone),
infoPlist: .default,
sources: .paths([.relativeToManifest("AppCore/Sources/**")])
),
Target(
name: "AppCoreTests",
platform: .iOS,
product: .unitTests,
bundleId: "io.tuist.AppCoreTests",
deploymentTarget: .iOS(targetVersion: "12.0", devices: .iphone),
infoPlist: "Tests.plist",
sources: "AppCore/Tests/**",
dependencies: [
.target(name: "AppCore"),
]
),
Target(
name: "App",
platform: .iOS,
product: .app,
bundleId: "io.tuist.App",
infoPlist: .file(path: .relativeToManifest("Info.plist")),
sources: .paths([.relativeToManifest("App/Sources/**")]),
dependencies: [
.target(name: "AppCore")
],
settings: Settings(base: ["CODE_SIGN_IDENTITY": "",
"CODE_SIGNING_REQUIRED": "NO"])
),
Target(
name: "AppTests",
platform: .iOS,
product: .unitTests,
bundleId: "io.tuist.AppTests",
infoPlist: "Tests.plist",
sources: "App/Tests/**",
dependencies: [
.target(name: "App"),
],
settings: Settings(base: ["CODE_SIGN_IDENTITY": "",
"CODE_SIGNING_REQUIRED": "NO"])
),
Target(
name: "MacFramework",
platform: .macOS,
product: .framework,
bundleId: "io.tuist.MacFramework",
infoPlist: .file(path: .relativeToManifest("Info.plist")),
sources: .paths([.relativeToManifest("MacFramework/Sources/**")]),
settings: Settings(base: ["CODE_SIGN_IDENTITY": "",
"CODE_SIGNING_REQUIRED": "NO"])
),
Target(
name: "MacFrameworkTests",
platform: .macOS,
product: .unitTests,
bundleId: "io.tuist.MacFrameworkTests",
infoPlist: "Tests.plist",
sources: "MacFramework/Tests/**",
dependencies: [
.target(name: "MacFramework"),
],
settings: Settings(base: ["CODE_SIGN_IDENTITY": "",
"CODE_SIGNING_REQUIRED": "NO"])
),
Target(
name: "AppUITests",
platform: .iOS,
product: .uiTests,
bundleId: "io.tuist.AppUITests",
infoPlist: "Tests.plist",
sources: "App/UITests/**",
dependencies: [
.target(name: "App"),
]
),
Target(
name: "App-dash",
platform: .iOS,
product: .app,
bundleId: "io.tuist.AppDash",
infoPlist: "Info.plist",
sources: .paths([.relativeToManifest("App/Sources/**")]),
dependencies: [
/* Target dependencies can be defined here */
/* .framework(path: "framework") */
],
settings: Settings(base: ["CODE_SIGN_IDENTITY": "",
"CODE_SIGNING_REQUIRED": "NO"])
),
Target(
name: "App-dashUITests",
platform: .iOS,
product: .uiTests,
bundleId: "io.tuist.AppDashUITests",
infoPlist: "Tests.plist",
sources: "App/UITests/**",
dependencies: [
.target(name: "App-dash"),
]
),
]
)

View File

@ -0,0 +1,78 @@
---
name: Test
excerpt: 'Learn how to test your Tuist projects by simply using the test command that is optimized for minimal configuration.'
---
# Test your App
Whenever you don't want to run tests from Xcode, for whatever reason, you have to resort to `xcodebuild`.
While being a fine piece of software, it's really hard to get all its arguments **just right**
when you want to do something simple.
This is why we think we can do better - just by running `tuist test` we will run *all* test targets in your app.
But not only that, we will automatically choose the right device for you - preferring the device you have already booted
or choosing one with the correct iOS version and boot it for you. Easy!
## Command
As we said, we strive for the test command being really simple - but it should be powerful enough to be useful for all your
test-related wishes. Let's see it in more detail below.
**Test the project in the current directory**
```bash
tuist test
```
**Test a specific scheme**
```bash
tuist test MyScheme
```
**Test on a specific device and OS version**
```bash
tuist test --device "iPhone X" --os 14.0
```
<Message
info
title="Standard commands"
description="One of the benefits of using Tuist over other automation tools is that developers can get familiar with a set of commands that they can use in any Tuist project."
/>
### Arguments
<ArgumentsTable
args={[
{
long: '`--generate`',
description: 'Force the generation of the project before testing.',
},
{
long: '`--clean`',
description: 'When passed, it cleans the project before testing it.',
},
{
long: '`--path`',
short: '`-p`',
description: ' The path to the directory that contains the project to be tested.',
},
{
long: '`--device`',
short: '`-d`',
description: 'Test on a specific device.',
},
{
long: '`--os`',
short: '`-o`',
description: 'Test with a specific version of the OS.',
},
{
long: '`--configuration`',
short: '`-C`',
description: 'The configuration to be used when building the scheme.',
},
]}
/>