Skip to content

Instantly share code, notes, and snippets.

@jakehawken
Last active June 23, 2023 15:53
Show Gist options
  • Save jakehawken/529a92256e1ce8d78e08ae6d8e8eecc4 to your computer and use it in GitHub Desktop.
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!
/*
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