Last active
June 23, 2023 15:53
-
-
Save jakehawken/529a92256e1ce8d78e08ae6d8e8eecc4 to your computer and use it in GitHub Desktop.
Testable Swift Playgrounds! Just drop this in the Sources folder of a Swift Playground, and you'll be able to write XCTest-style tests right there in your code!
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
Ever wanted to write unit tests in a Swift Playground? … No? Oh. | |
I’m really the only person? Well ok, then never mind. | |
If you change your mind though, you can drop this file directly | |
into the Sources folder and be able to write XCTest-style code | |
directly in your playground. A proper git repo with a README | |
file and code examples is on the way. | |
*/ | |
import Foundation | |
public typealias VoidBlock = () -> Void | |
public typealias ThrowableVoidBlock = () throws -> Void | |
// MARK: - Global Functions | |
// Test execution | |
public var enableVerboseTestOutput: Bool { | |
get { | |
TestRunner.shared.useVerboseLogging | |
} | |
set { | |
TestRunner.shared.useVerboseLogging = newValue | |
} | |
} | |
public func setUp(_ setupBlock: VoidBlock?) { | |
TestRunner.shared.setupBlock = setupBlock | |
} | |
public func tearDown(_ tearDownBlock: VoidBlock?) { | |
TestRunner.shared.tearDownBlock = tearDownBlock | |
} | |
public func test(_ name: String, testBlock: ThrowableVoidBlock) { | |
TestRunner.shared.runTest(name, testBlock: testBlock) | |
} | |
public func runTestSuite(_ name: String, executionBlock: VoidBlock) { | |
TestRunner.shared.runTestSuite(name, executionBlock: executionBlock) | |
} | |
// Expectations | |
public func expectation(description: String) -> TestExpectation { | |
return TestRunner.shared.expectation(description: description) | |
} | |
public func waitForExpectations(timeout: TimeInterval = 1) throws { | |
try TestRunner.shared.waitForExpectations(timeout: timeout) | |
} | |
// Assertions | |
public func assertCondition(_ isTrue: Bool, description: String) throws { | |
if isTrue { | |
if enableVerboseTestOutput { | |
TestRunner.shared.log("- \(description) ✅") | |
} | |
return | |
} | |
TestRunner.shared.failureCallback?(description) | |
throw TestRunner.TestFailure(message: description) | |
} | |
public func assertEqual<T: Equatable>(_ lhs: T, _ rhs: T, customMessage: String? = nil) throws { | |
try assertCondition(lhs == rhs, description: customMessage ?? "\(lhs) should equal \(rhs).") | |
} | |
// MARK: - Test Runner | |
public class TestRunner { | |
public static let shared = TestRunner() | |
private(set) fileprivate var failureCallback: ((String) -> Void)? | |
private let dispatchGroup = DispatchGroup() | |
private let workQueue = DispatchQueue(label: "\(UUID().uuidString)", attributes: .concurrent) | |
var allExpectations = [TestExpectation]() | |
var setupBlock: VoidBlock? | |
var tearDownBlock: VoidBlock? | |
var useVerboseLogging = false | |
private var indentaionLevel = 0 | |
func configureSetup(_ setupBlock: VoidBlock?) { | |
self.setupBlock = setupBlock | |
} | |
func configureTeardown(_ teardownBlock: VoidBlock?) { | |
self.tearDownBlock = teardownBlock | |
} | |
public func runTestSuite(_ name: String, executionBlock: VoidBlock) { | |
log("⏱ EXECUTING TEST SUITE: \"\(name)\" >------------------------------------------------------------>") | |
if useVerboseLogging { | |
print("\n") | |
} | |
let start = Date() | |
incrementIndentation() | |
executionBlock() | |
decrementIndentation() | |
let addendum = timeAddendumString(start: start) | |
log("🏁 TEST SUITE \"\(name)\" COMPLETED\(addendum). <-------------------------------------<") | |
} | |
func runTest(_ name: String, testBlock: ThrowableVoidBlock) { | |
let start = useVerboseLogging ? Date() : nil | |
log("RUNNING TEST: \"\(name)\" ", terminator: useVerboseLogging ? "\n" : "") | |
incrementIndentation() | |
var errorMessages = [String]() | |
failureCallback = { | |
errorMessages.append($0) | |
} | |
setupBlock?() | |
do { | |
try testBlock() | |
decrementIndentation() | |
let timeAddendum = timeAddendumString(start: start) | |
let message = "✅ TEST PASSED\(timeAddendum)." | |
useVerboseLogging ? log(message) : print(message) | |
} | |
catch { | |
if useVerboseLogging { | |
errorMessages.forEach { log("- \($0) ❌") } | |
} | |
decrementIndentation() | |
let timeAddendum = timeAddendumString(start: start) | |
let baseFailureMessage = "❌ TEST FAILED\(timeAddendum):" | |
if !useVerboseLogging { | |
log("\(baseFailureMessage) \(error)") | |
} | |
else { | |
log("\(baseFailureMessage) \n\tCAUSE: \(error)") | |
} | |
} | |
if useVerboseLogging { | |
print("\n") | |
} | |
tearDownBlock?() | |
} | |
public func expectation(description: String) -> TestExpectation { | |
let newExpectation = TestExpectation(description: description) | |
allExpectations.append(newExpectation) | |
return newExpectation | |
} | |
public func clearExpectations() { | |
allExpectations.removeAll() | |
} | |
public func waitForExpectations(timeout: TimeInterval) throws { | |
let expectations = allExpectations | |
allExpectations.removeAll() | |
try wait(for: expectations, timeout: timeout) | |
} | |
public func wait(for expectations: [TestExpectation], timeout: TimeInterval) throws { | |
let zipped = TestExpectation.zip(expectations) | |
dispatchGroup.enter() | |
zipped.onFulfilled { [weak self] in | |
self?.dispatchGroup.leave() | |
} | |
switch dispatchGroup.wait(timeout: .now() + timeout) { | |
case .timedOut: | |
try handleTimeout(for: expectations) | |
case .success: | |
break | |
} | |
} | |
private func handleTimeout(for expectations: [TestExpectation]) throws { | |
let unfulfilled = expectations.filter { !$0.isFulfilled } | |
unfulfilled.forEach { | |
$0.onFulfilled(nil) | |
} | |
guard !unfulfilled.isEmpty else { | |
return | |
} | |
unfulfilled.forEach { failureCallback?("\($0)") } | |
var errorMessage = "Timed out waiting for expectation" | |
let hasMultiple = unfulfilled.count > 1 | |
if hasMultiple { | |
errorMessage += "s" | |
} | |
guard useVerboseLogging else { | |
throw TestFailure(message: errorMessage + ".") | |
} | |
errorMessage += ": " | |
if hasMultiple { | |
errorMessage += "[" | |
unfulfilled.forEach { | |
errorMessage += "\n\t\t- \"\($0)\"" | |
} | |
errorMessage += "\n\t]" | |
} | |
else if let first = unfulfilled.first { | |
errorMessage += "\"\(first)\"" | |
} | |
throw TestFailure(message: errorMessage) | |
} | |
private func timeAddendumString(start: Date?) -> String { | |
guard let start = start else { | |
return "" | |
} | |
let time = Date().timeIntervalSince(start) | |
let significantDecimalPlace: Double = 10000 | |
let significantSeconds = Double(Int(time * significantDecimalPlace))/significantDecimalPlace | |
return " (after \(significantSeconds) seconds)" | |
} | |
fileprivate func log(_ message: String, terminator: String = "\n") { | |
guard indentaionLevel > 0 else { | |
print(message) | |
return | |
} | |
let prefix = (1...indentaionLevel).reduce(into: "") { result, _ in result += "\t" } | |
let output = message | |
.split(separator: "\n") | |
.map { prefix + $0 } | |
.joined(separator: "\n") | |
print(output, terminator: terminator) | |
} | |
private func incrementIndentation() { | |
indentaionLevel += 1 | |
} | |
private func decrementIndentation() { | |
indentaionLevel -= 1 | |
} | |
} | |
// MARK: - Test Expecation | |
public class TestExpectation: CustomStringConvertible { | |
private(set) public var isFulfilled = false | |
public let description: String | |
private var fulfillmentCallback: VoidBlock? | |
init(description: String) { | |
self.description = description | |
} | |
public func fulfill() { | |
isFulfilled = true | |
fulfillmentCallback?() | |
fulfillmentCallback = nil | |
} | |
func onFulfilled(_ callback: VoidBlock?) { | |
fulfillmentCallback = callback | |
} | |
static func zip(_ childExpectations: [TestExpectation]) -> TestExpectation { | |
let parentExpectation = TestExpectation(description: "CombinedExpectation: \(childExpectations)") | |
childExpectations.forEach { expectation in | |
expectation.onFulfilled { | |
if TestRunner.shared.useVerboseLogging { | |
TestRunner.shared.log("- \(expectation.description) ✅") | |
} | |
if childExpectations.allSatisfy({ $0.isFulfilled }) { | |
parentExpectation.fulfill() | |
} | |
} | |
} | |
return parentExpectation | |
} | |
} | |
// MARK: - TestFailure | |
public extension TestRunner { | |
struct TestFailure: Error, CustomStringConvertible { | |
let message: String | |
public var description: String { | |
return message | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment