├── .github └── workflows │ ├── ci.yml │ └── documentation.yml ├── .gitignore ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── TAP │ ├── BailOut.swift │ ├── Directive.swift │ ├── Outcome.swift │ ├── Reporter.swift │ ├── TAP.swift │ ├── Test.swift │ └── XCTestTAPObserver.swift └── Tests ├── TAPTests ├── MockTextOutputStream.swift └── TAPTests.swift └── XCTMain.swift /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | macos_big_sur: 11 | runs-on: macos-11.0 12 | 13 | strategy: 14 | matrix: 15 | xcode: 16 | - "12.2" # Swift 5.3 17 | 18 | name: "macOS Big Sur (Xcode ${{ matrix.xcode }})" 19 | 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v1 23 | - uses: actions/cache@v2 24 | with: 25 | path: .build 26 | key: ${{ runner.os }}-spm-xcode-${{ matrix.xcode }}-${{ hashFiles('**/Package.resolved') }} 27 | restore-keys: | 28 | ${{ runner.os }}-spm-xcode-${{ matrix.xcode }}- 29 | - name: Build and Test 30 | run: | 31 | swift test 32 | env: 33 | DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer 34 | 35 | macos_catalina: 36 | runs-on: macos-10.15 37 | 38 | strategy: 39 | matrix: 40 | xcode: 41 | - "12" # Swift 5.3 42 | 43 | name: "macOS Catalina (Xcode ${{ matrix.xcode }})" 44 | 45 | steps: 46 | - name: Checkout 47 | uses: actions/checkout@v1 48 | - uses: actions/cache@v2 49 | with: 50 | path: .build 51 | key: ${{ runner.os }}-spm-xcode-${{ matrix.xcode }}-${{ hashFiles('**/Package.resolved') }} 52 | restore-keys: | 53 | ${{ runner.os }}-spm-xcode-${{ matrix.xcode }}- 54 | - name: Build and Test 55 | run: | 56 | swift test 57 | env: 58 | DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer 59 | 60 | linux: 61 | runs-on: ubuntu-latest 62 | 63 | name: "Ubuntu Linux" 64 | 65 | container: 66 | image: swiftlang/swift:nightly-focal 67 | 68 | steps: 69 | - name: Checkout 70 | uses: actions/checkout@v1 71 | - uses: actions/cache@v2 72 | with: 73 | path: .build 74 | key: ${{ runner.os }}-spm-swift-${{ matrix.swift }}-${{ hashFiles('**/Package.resolved') }} 75 | restore-keys: | 76 | ${{ runner.os }}-spm-swift-${{ matrix.swift }}- 77 | - name: Build and Test 78 | run: | 79 | swift test 80 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - .github/workflows/documentation.yml 9 | - Sources/**.swift 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v1 18 | - name: Generate Documentation 19 | uses: SwiftDocOrg/swift-doc@master 20 | with: 21 | inputs: "Sources" 22 | output: "Documentation" 23 | - name: Upload Documentation to Wiki 24 | uses: SwiftDocOrg/github-wiki-publish-action@v1 25 | with: 26 | path: "Documentation" 27 | env: 28 | GH_PERSONAL_ACCESS_TOKEN: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | .swiftpm 7 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Yams", 6 | "repositoryURL": "https://github.com/jpsim/Yams.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "53741ba55ecca5c7149d8c9f810913ec80845c69", 10 | "version": "3.0.0" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "TAP", 8 | products: [ 9 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 10 | .library( 11 | name: "TAP", 12 | targets: ["TAP"]), 13 | ], 14 | dependencies: [ 15 | // Dependencies declare other packages that this package depends on. 16 | .package(url: "https://github.com/jpsim/Yams.git", "2.0.0"..<"4.0.0"), 17 | ], 18 | targets: [ 19 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 20 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 21 | .target( 22 | name: "TAP", 23 | dependencies: [ 24 | .product(name: "Yams", package: "Yams") 25 | ]), 26 | .testTarget( 27 | name: "TAPTests", 28 | dependencies: [ 29 | .target(name: "TAP") 30 | ]), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TAP 2 | 3 | A Swift package for the [Test Anything Protocol][tap] (v13). 4 | 5 | ## Requirements 6 | 7 | - Swift 5.3+ 8 | 9 | ## Usage 10 | 11 | You can use `TAP` as an alternative to `XCTest` in executable targets 12 | or as a custom reporter in test targets. 13 | 14 | ### Running Tests Directly 15 | 16 | ```swift 17 | import TAP 18 | 19 | try TAP.run([ 20 | test(1 + 1 == 2), // passes 21 | test(true == false) // fails 22 | ]) 23 | // Prints: 24 | /* 25 | TAP version 13 26 | 1..2 27 | ok 1 28 | not ok 2 29 | --- 30 | file: path/to/File.swift 31 | line: 5 32 | ... 33 | 34 | */ 35 | ``` 36 | 37 | ### Custom Test Reporting 38 | 39 | #### Linux 40 | 41 | Swift Package Manager on Linux 42 | uses [swift-corelibs-xctest](https://github.com/apple/swift-corelibs-xctest), 43 | which provides an `XCTMain` that 44 | 45 | Run the following command **on macOS** to (re)-generate your main test file: 46 | 47 | ```terminal 48 | $ swift test --generate-linuxmain 49 | ``` 50 | 51 | Open the resulting `LinuxMain.swift` file, 52 | add an import statement for the `TAP` module 53 | and register `XCTestTAPObserver` as a test observer. 54 | In Swift 5.4 and later, 55 | you can update the `XCTMain` invocation to include an `observers` parameter 56 | with an instance of `XCTestTAPObserver`. 57 | 58 | ```swift 59 | #if os(Linux) 60 | import XCTest 61 | import TAP 62 | @testable import TAPTests 63 | 64 | #if swift(>=5.4) 65 | XCTMain([ 66 | testCase(TAPTests.allTests) 67 | ], 68 | arguments: CommandLine.arguments, 69 | observers: [ 70 | XCTestTAPObserver() 71 | ]) 72 | #else 73 | XCTestObservationCenter.shared.addTestObserver(XCTestTAPObserver()) 74 | XCTMain([ 75 | testCase(TAPTests.allTests) 76 | ]) 77 | #endif 78 | ``` 79 | 80 | When you run the `swift test` command, 81 | your test suite will be reported in TAP format. 82 | 83 | #### macOS and iOS 84 | 85 | As of Swift 5.3, 86 | it's not possible to configure a custom reporter 87 | when running tests directly through Swift Package Manager. 88 | However, Xcode provides a mechanism for loading custom reports via 89 | [`XCTestObservationCenter`](https://developer.apple.com/documentation/xctest/xctestobservationcenter). 90 | 91 | Create a new file named `TestObservation.swift` and add it to your test bundle. 92 | Import the `TAP` module, 93 | declare a subclass of `NSObject` named `TestObservation`, 94 | and override its designated initializer 95 | to register `XCTestTAPObserver` with the shared `XCTestObservationCenter`. 96 | 97 | ```swift 98 | import TAP 99 | 100 | final class TestObservation: NSObject { 101 | override init() { 102 | XCTestObservationCenter.shared.addTestObserver(XCTestTAPObserver()) 103 | } 104 | } 105 | ``` 106 | 107 | Add an entry to your test target's `Info.plist` file 108 | designating the fully-qualified name of this class as the `NSPrincipalClass`. 109 | 110 | ```xml 111 | NSPrincipalClass 112 | YourTestTarget.TestObservation 113 | ``` 114 | 115 | When you run your test bundle, 116 | Xcode will instantiate the principle class first, 117 | ensuring that your test observers are registered in time 118 | to report the progress of all test runs. 119 | 120 | ## Installation 121 | 122 | ### Swift Package Manager 123 | 124 | Add the TAP package to your target dependencies in `Package.swift`: 125 | 126 | ```swift 127 | import PackageDescription 128 | 129 | let package = Package( 130 | name: "YourProject", 131 | dependencies: [ 132 | .package( 133 | url: "https://github.com/SwiftDocOrg/TAP", 134 | from: "0.2.0" 135 | ), 136 | ] 137 | ) 138 | ``` 139 | 140 | Add `TAP` as a dependency to your test target(s): 141 | 142 | ```swift 143 | targets: [ 144 | .testTarget( 145 | name: "YourTestTarget", 146 | dependencies: ["TAP"]), 147 | ``` 148 | 149 | ## License 150 | 151 | MIT 152 | 153 | ## Contact 154 | 155 | Mattt ([@mattt](https://twitter.com/mattt)) 156 | 157 | [tap]: https://testanything.org 158 | -------------------------------------------------------------------------------- /Sources/TAP/BailOut.swift: -------------------------------------------------------------------------------- 1 | /** 2 | An error that can be thrown from a test to stop the execution of further tests. 3 | 4 | From the [TAP specification](https://testanything.org/tap-specification.html): 5 | 6 | > ### Bail out! 7 | > 8 | > As an emergency measure 9 | > a test script can decide that further tests are useless 10 | > (e.g. missing dependencies) 11 | > and testing should stop immediately. 12 | > In that case the test script prints the magic words `Bail out!` 13 | > to standard output. 14 | > Any message after these words must be displayed by the interpreter 15 | > as the reason why testing must be stopped, 16 | > as in `Bail out! MySQL is not running.` 17 | */ 18 | public struct BailOut: Error, LosslessStringConvertible { 19 | public let description: String 20 | 21 | public init(_ description: String = "") { 22 | self.description = description 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/TAP/Directive.swift: -------------------------------------------------------------------------------- 1 | /** 2 | An error that can be thrown from a test to reinterpret its outcome. 3 | 4 | From the [TAP specification](https://testanything.org/tap-specification.html): 5 | 6 | > Directives are special notes that follow a # on the test line. 7 | > Only two are currently defined: `TODO` and `SKIP`. 8 | > Note that these two keywords are not case-sensitive. 9 | */ 10 | public enum Directive: Error { 11 | /** 12 | Indicates that a test isn't expected to pass. 13 | 14 | From the [TAP v13 specification](https://testanything.org/tap-version-13-specification.html): 15 | 16 | > TODO tests 17 | > 18 | > If the directive starts with `# TODO`, 19 | > the test is counted as a todo test, 20 | > and the text after `TODO` is the explanation. 21 | > 22 | > `not ok 13 # TODO bend space and time` 23 | > 24 | > Note that if the `TODO` has an explanation 25 | > it must be separated from `TODO` by a space. 26 | > These tests represent a feature to be implemented or a bug to be fixed 27 | > and act as something of an executable “things to do” list. 28 | > They are not expected to succeed. 29 | > Should a todo test point begin succeeding, 30 | > the harness should report it as a bonus. 31 | > This indicates that whatever you were supposed to do has been done 32 | > and you should promote this to a normal test point. 33 | */ 34 | case todo(explanation: String? = nil) 35 | 36 | /** 37 | Indicates that a test should be skipped. 38 | 39 | From the [TAP v13 specification](https://testanything.org/tap-version-13-specification.html): 40 | 41 | > ### Skipping tests 42 | > 43 | > If the directive starts with `# SKIP`, 44 | > the test is counted as having been skipped. 45 | > If the whole test file succeeds, 46 | > the count of skipped tests is included in the generated output. 47 | > The harness should report the text after `# SKIP\S*\s+` 48 | > as a reason for skipping. 49 | > 50 | > `ok 23 # skip Insufficient flogiston pressure.` 51 | > 52 | > Similarly, one can include an explanation in a plan line, 53 | > emitted if the test file is skipped completely: 54 | > 55 | > `1..0 # Skipped: WWW::Mechanize not installed` 56 | */ 57 | case skip(explanation: String? = nil) 58 | } 59 | -------------------------------------------------------------------------------- /Sources/TAP/Outcome.swift: -------------------------------------------------------------------------------- 1 | /** 2 | The outcome of running a test. 3 | */ 4 | public struct Outcome { 5 | /** 6 | Whether the test passed or failed. 7 | 8 | From the [TAP v13 specification](https://testanything.org/tap-version-13-specification.html): 9 | 10 | > #### • ok or not ok 11 | > 12 | > This tells whether the test point passed or failed. 13 | > It must be at the beginning of the line. 14 | > `/^not ok/` indicates a failed test point. 15 | > `/^ok/` is a successful test point. 16 | > This is the only mandatory part of the line. 17 | > Note that unlike the Directives below, 18 | > ok and not ok are case-sensitive. 19 | */ 20 | public let ok: Bool 21 | 22 | /** 23 | A description of the tested behavior. 24 | 25 | From the [TAP v13 specification](https://testanything.org/tap-version-13-specification.html): 26 | 27 | > #### • Description 28 | > 29 | > Any text after the test number but before a `#` 30 | > is the description of the test point. 31 | > 32 | > `ok 42 this is the description of the test` 33 | > 34 | > Descriptions should not begin with a digit 35 | > so that they are not confused with the test point number. 36 | > The harness may do whatever it wants with the description. 37 | */ 38 | public let description: String? 39 | 40 | /** 41 | A directive for how to interpret a test outcome. 42 | 43 | From the [TAP v13 specification](https://testanything.org/tap-version-13-specification.html): 44 | 45 | > #### • Directive 46 | > 47 | > The test point may include a directive, 48 | > following a hash on the test line. 49 | > There are currently two directives allowed: 50 | > `TODO` and `SKIP`. 51 | */ 52 | public let directive: Directive? 53 | 54 | /** 55 | Additional information about a test, 56 | such as its source location (file / line) 57 | or the actual and expected results. 58 | 59 | Test outcome metadata is encoded as [YAML](https://yaml.org). 60 | 61 | From the [TAP v13 specification](https://testanything.org/tap-version-13-specification.html): 62 | 63 | > ### YAML blocks 64 | > 65 | > If the test line is immediately followed by 66 | > an indented block beginning with `/^\s+---/` and ending with `/^\s+.../` 67 | > that block will be interpreted as an inline YAML document. 68 | > The YAML encodes a data structure that provides 69 | > more detailed information about the preceding test. 70 | > The YAML document is indented to make it 71 | > visually distinct from the surrounding test results 72 | > and to make it easier for the parser to recover 73 | > if the trailing ‘…’ terminator is missing. 74 | > For example: 75 | > 76 | > not ok 3 Resolve address 77 | > --- 78 | > message: "Failed with error 'hostname peebles.example.com not found'" 79 | > severity: fail 80 | > data: 81 | > got: 82 | > hostname: 'peebles.example.com' 83 | > address: ~ 84 | > expected: 85 | > hostname: 'peebles.example.com' 86 | > address: '85.193.201.85' 87 | > ... 88 | */ 89 | public let metadata: [String: Any]? 90 | 91 | /** 92 | Creates a successful test outcome. 93 | 94 | - Parameters: 95 | - description: A description of the tested behavior. 96 | `nil` by default. 97 | - directive: A directive for how to interpret a test outcome. 98 | `nil` by default. 99 | - metadata: Additional information about a test. 100 | `nil` by default. 101 | - Returns: A successful test outcome. 102 | */ 103 | public static func success(_ description: String? = nil, 104 | directive: Directive? = nil, 105 | metadata: [String: Any]? = nil) -> Outcome 106 | { 107 | return Outcome(ok: true, description: description, directive: directive, metadata: metadata) 108 | } 109 | 110 | /** 111 | Creates an unsuccessful test outcome. 112 | 113 | - Parameters: 114 | - description: A description of the tested behavior. 115 | `nil` by default. 116 | - directive: A directive for how to interpret a test outcome. 117 | `nil` by default. 118 | - metadata: Additional information about a test. 119 | `nil` by default. 120 | - Returns: An unsuccessful test outcome. 121 | */ 122 | public static func failure(_ description: String? = nil, 123 | directive: Directive? = nil, 124 | metadata: [String: Any]? = nil) -> Outcome 125 | { 126 | return Outcome(ok: false, description: description, directive: directive, metadata: metadata) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /Sources/TAP/Reporter.swift: -------------------------------------------------------------------------------- 1 | import Yams 2 | 3 | #if canImport(Glibc) 4 | import Glibc 5 | #endif 6 | 7 | #if canImport(Darwin) 8 | import Darwin 9 | #endif 10 | 11 | /** 12 | A class that reports the progress and outcomes of tests 13 | according to the Test Anything Protocol (TAP). 14 | */ 15 | public final class Reporter { 16 | private var testNumber: Int = 1 17 | 18 | /// The text output stream to which results are reported. 19 | public var output: TextOutputStream 20 | 21 | /** 22 | Creates a reporter with a given number of tests 23 | that writes to standard output (`STDOUT`). 24 | 25 | - Parameters: 26 | - version: The TAP version (`TAP.version` by default). 27 | - numberOfTests: The number of tests expected to run. 28 | This should be a positive number 29 | or zero if the number of tests cannot be determined ahead of time. 30 | */ 31 | public convenience init(version: Int = TAP.version, numberOfTests: Int) { 32 | var output = StdoutOutputStream() 33 | self.init(version: version, numberOfTests: numberOfTests, output: &output) 34 | } 35 | 36 | /** 37 | Creates a reporter with a given number of tests 38 | that writes to the specified output target. 39 | 40 | - Parameters: 41 | - version: The TAP version (`TAP.version` by default). 42 | - numberOfTests: The number of tests expected to run. 43 | This should be a positive number 44 | or zero if the number of tests cannot be determined ahead of time. 45 | - ouput: An output stream to receive the reported results. 46 | */ 47 | public required init(version: Int = TAP.version, numberOfTests: Int, output: inout Target) { 48 | self.output = output 49 | 50 | output.write("TAP version \(version)\n") 51 | output.write("1..\(numberOfTests)\n") 52 | } 53 | 54 | /** 55 | Reports the output of a test. 56 | 57 | - Parameter outcome: The test outcome. 58 | */ 59 | public func report(_ outcome: Outcome) { 60 | defer { testNumber += 1 } 61 | 62 | var components: [String?] = [ 63 | outcome.ok ? "ok" : "not ok", 64 | "\(testNumber)", 65 | outcome.description, 66 | ] 67 | 68 | switch outcome.directive { 69 | case .none: break 70 | case .skip(let explanation): 71 | components.append(contentsOf: ["# SKIP", explanation]) 72 | case .todo(let explanation): 73 | components.append(contentsOf: ["# TODO", explanation]) 74 | } 75 | 76 | output.write(components.compactMap { $0 }.joined(separator: " ") + "\n") 77 | 78 | if let metadata = outcome.metadata, 79 | let yaml = try? Yams.dump(object: metadata, explicitStart: true, explicitEnd: true, sortKeys: true) { 80 | output.write(yaml.indented() + "\n") 81 | } 82 | } 83 | 84 | /** 85 | Reports a "Bail out!" directive. 86 | 87 | - Parameter bailOut: The directive. 88 | */ 89 | public func report(_ bailOut: BailOut) { 90 | output.write(["Bail out!", bailOut.description].compactMap { $0 }.joined(separator: " ") + "\n") 91 | } 92 | } 93 | 94 | // MARK: - 95 | 96 | fileprivate extension String { 97 | func indented(by numberOfSpaces: Int = 2) -> String { 98 | split(separator: "\n", omittingEmptySubsequences: false) 99 | .map { String(repeating: " ", count: numberOfSpaces) + $0 } 100 | .joined(separator: "\n") 101 | } 102 | } 103 | 104 | // MARK: - 105 | 106 | fileprivate struct StdoutOutputStream: TextOutputStream { 107 | mutating func write(_ string: String) { 108 | fputs(string, stdout) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Sources/TAP/TAP.swift: -------------------------------------------------------------------------------- 1 | /// A top-level namespace for the Test Anything Protocol (TAP). 2 | public enum TAP { 3 | /** 4 | The TAP version number. 5 | 6 | From the [TAP v13 specification](https://testanything.org/tap-version-13-specification.html): 7 | 8 | > ### The version 9 | > 10 | > To indicate that this is TAP13 the first line must be 11 | > 12 | > `TAP version 13` 13 | */ 14 | public static let version: Int = 13 15 | 16 | /** 17 | Runs the specified tests and prints the results in TAP format to standard output. 18 | 19 | - Parameter tests: The tests to run. 20 | - Throws: If any tests throw an error that isn't `BailOut` or `Directive`. 21 | */ 22 | public static func run(_ tests: [Test]) throws { 23 | let reporter = Reporter(numberOfTests: tests.count) 24 | try run(tests, reporter: reporter) 25 | } 26 | 27 | /** 28 | Runs the specified tests and prints the results in TAP format to a specified output. 29 | 30 | - Parameters 31 | - tests: The tests to run. 32 | - output: Where to report results. 33 | - Throws: If any tests throw an error that isn't `BailOut` or `Directive`. 34 | */ 35 | public static func run(_ tests: [Test], output: inout Target) throws { 36 | let reporter = Reporter(numberOfTests: tests.count, output: &output) 37 | try run(tests, reporter: reporter) 38 | } 39 | 40 | private static func run(_ tests: [Test], reporter: Reporter) throws { 41 | for test in tests { 42 | do { 43 | let outcome = try test() 44 | reporter.report(outcome) 45 | } catch let bailOut as BailOut { 46 | reporter.report(bailOut) 47 | return 48 | } catch { 49 | throw error 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/TAP/Test.swift: -------------------------------------------------------------------------------- 1 | /** 2 | A test that produces an outcome. 3 | 4 | Throw a `Directive` to indicate how a test should be interpreted, 5 | or throw `BailOut` to stop the execution of further tests. 6 | */ 7 | public typealias Test = () throws -> Outcome 8 | 9 | // MARK: - 10 | 11 | /** 12 | Creates a test from an expression that returns a Boolean value. 13 | 14 | - Parameters: 15 | - body: A closure containing the tested behavior, 16 | Return `true` to indicate successful execution 17 | or `false to indicate test failure. 18 | Throw a `Directive` to indicate how a test should be interpreted, 19 | or throw `BailOut` to stop the execution of further tests. 20 | - description: A description of the tested behavior. 21 | `nil` by default. 22 | - file: The source code file in which this test occurs. 23 | - line: The line in source code on which this test occurs. 24 | - Returns: A test. 25 | */ 26 | public func test(_ body: @escaping @autoclosure () throws -> Bool, 27 | _ description: String? = nil, 28 | file: String = #filePath, 29 | line: Int = #line) -> Test 30 | { 31 | return test(body, description, file: file, line: line) 32 | } 33 | 34 | /** 35 | Creates a test from a closure that returns a Boolean value. 36 | 37 | - Parameters: 38 | - body: A closure containing the tested behavior, 39 | Return `true` to indicate successful execution 40 | or `false to indicate test failure. 41 | Throw a `Directive` to indicate how a test should be interpreted, 42 | or throw `BailOut` to stop the execution of further tests. 43 | - description: A description of the tested behavior. 44 | `nil` by default. 45 | - file: The source code file in which this test occurs. 46 | - line: The line in source code on which this test occurs. 47 | - Returns: A test. 48 | */ 49 | public func test(_ body: @escaping () throws -> Bool, 50 | _ description: String? = nil, 51 | file: String = #filePath, 52 | line: Int = #line) -> Test 53 | { 54 | return { 55 | let metadata: [String: Any] = [ 56 | "file": file, 57 | "line": line, 58 | ] 59 | 60 | do { 61 | if try body() { 62 | return .success(description, directive: nil, metadata: nil) 63 | } else { 64 | return .failure(description, directive: nil, metadata: metadata) 65 | } 66 | } catch let directive as Directive { 67 | switch directive { 68 | case .todo: 69 | return .failure(description, directive: directive, metadata: metadata) 70 | case .skip: 71 | return .success(description, directive: directive, metadata: nil) 72 | } 73 | } catch let bailOut as BailOut { 74 | throw bailOut 75 | } catch { 76 | return .failure(description ?? "\(error)", directive: nil, metadata: metadata) 77 | } 78 | } 79 | } 80 | 81 | /** 82 | Creates a test from a closure that produces an outcome. 83 | 84 | - Parameters: 85 | - body: A closure that produces an outcome for some tested behavior. 86 | - Returns: A test. 87 | */ 88 | public func test(_ body: @escaping () -> Outcome) -> Test { 89 | return { body() } 90 | } 91 | -------------------------------------------------------------------------------- /Sources/TAP/XCTestTAPObserver.swift: -------------------------------------------------------------------------------- 1 | #if canImport(XCTest) 2 | import XCTest 3 | 4 | /// A custom test reporter that conforms to `XCTestObservation`. 5 | public class XCTestTAPObserver: NSObject { 6 | private var reporter: Reporter? 7 | } 8 | 9 | // MARK: - XCTestObservation 10 | 11 | extension XCTestTAPObserver: XCTestObservation { 12 | public func testSuiteWillStart(_ testSuite: XCTestSuite) { 13 | reporter = reporter ?? Reporter(numberOfTests: testSuite.testCaseCount) 14 | } 15 | 16 | public func testCaseDidFinish(_ testCase: XCTestCase) { 17 | var directive: Directive? 18 | if let testRun = testCase.testRun, testRun.hasBeenSkipped { 19 | directive = .skip(explanation: nil) 20 | } 21 | 22 | reporter?.report(.success(testCase.name, directive: directive, metadata: nil)) 23 | } 24 | 25 | public func testCase(_ testCase: XCTestCase, didFailWithDescription description: String, inFile filePath: String?, atLine lineNumber: Int) { 26 | let metadata: [String: Any] = [ 27 | "description": description, 28 | "file": filePath as Any, 29 | "line": lineNumber 30 | ].compactMapValues { $0 } 31 | 32 | reporter?.report(.failure(testCase.name, directive: nil, metadata: metadata)) 33 | } 34 | } 35 | 36 | #endif 37 | -------------------------------------------------------------------------------- /Tests/TAPTests/MockTextOutputStream.swift: -------------------------------------------------------------------------------- 1 | class MockOutputStream: TextOutputStream { 2 | var text: String = "" 3 | 4 | func write(_ string: String) { 5 | text += string 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Tests/TAPTests/TAPTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import TAP 3 | 4 | final class TAPTests: XCTestCase { 5 | private var output = MockOutputStream() 6 | 7 | func testTAP() throws { 8 | let expectation = XCTestExpectation() 9 | 10 | let tests: [Test] = [ 11 | test { 12 | expectation.fulfill() 13 | return .success() 14 | } 15 | ] 16 | 17 | try TAP.run(tests, output: &output) 18 | 19 | wait(for: [expectation], timeout: 1.0) 20 | } 21 | 22 | func testEmpty() throws { 23 | let tests: [Test] = [] 24 | 25 | try TAP.run(tests, output: &output) 26 | 27 | let expected = """ 28 | TAP version 13 29 | 1..0 30 | 31 | """ 32 | 33 | let actual = output.text 34 | 35 | XCTAssertEqual(expected, actual) 36 | } 37 | 38 | func testExample() throws { 39 | let lineOffset = #line 40 | 41 | let tests: [Test] = [ 42 | test(true), 43 | test(false), 44 | test({ throw Directive.skip(explanation: "unnecessary") }), 45 | test({ throw Directive.todo(explanation: "unimplemented") }), 46 | test({ throw BailOut("😱") }), 47 | test(true) 48 | ] 49 | 50 | try TAP.run(tests, output: &output) 51 | 52 | let expected = """ 53 | TAP version 13 54 | 1..6 55 | ok 1 56 | not ok 2 57 | --- 58 | file: \(#file) 59 | line: \(lineOffset + 4) 60 | ... 61 | 62 | ok 3 # SKIP unnecessary 63 | not ok 4 # TODO unimplemented 64 | --- 65 | file: \(#file) 66 | line: \(lineOffset + 6) 67 | ... 68 | 69 | Bail out! 😱 70 | 71 | """ 72 | 73 | let actual = output.text 74 | 75 | for (expected, actual) in zip(expected.lines, actual.lines) { 76 | XCTAssertEqual(expected, actual) 77 | } 78 | } 79 | } 80 | 81 | extension TAPTests { 82 | static var allTests = [ 83 | ("testTAP", testTAP), 84 | ("testEmpty", testEmpty), 85 | ("testExample", testExample), 86 | ] 87 | } 88 | 89 | // MARK: - 90 | 91 | fileprivate extension String { 92 | var lines: [String] { 93 | split(separator: "\n", omittingEmptySubsequences: false).map { $0.trimmingCharacters(in: .whitespaces) } 94 | } 95 | } 96 | 97 | -------------------------------------------------------------------------------- /Tests/XCTMain.swift: -------------------------------------------------------------------------------- 1 | #if os(Linux) 2 | import XCTest 3 | import TAP 4 | @testable import TAPTests 5 | 6 | XCTMain([ 7 | testCase(TAPTests.allTests) 8 | ], 9 | arguments: CommandLine.arguments, 10 | observers: [ 11 | XCTestTAPObserver() 12 | ]) 13 | #endif 14 | --------------------------------------------------------------------------------