Skip to content

Commit

Permalink
Introduce the require-dsl. expect, but it returns the result of the e…
Browse files Browse the repository at this point in the history
…xpression

As implied, this is basically a copy-paste of the functionality of expect. It's not complete, and it's not well-tested. But it gets the idea across.
Future work is cleaning this up, backfilling tests for features (such as the unwrap function), and adding support for polling requirements (i.e. toEventually).

Other than functionality, require also files the errorThrown issue type with XCTest, whereas expect files the assertionFailed issue type. This has minor differences that are mostly semantics.

Also, in addition to the require dsl, this also adds unwrap, which is a shorthand for `require(...).toNot(beNil())`.
  • Loading branch information
younata committed Nov 30, 2023
1 parent d616f15 commit db8206d
Show file tree
Hide file tree
Showing 8 changed files with 282 additions and 4 deletions.
8 changes: 8 additions & 0 deletions Nimble.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@
857D1849253610A900D8693A /* BeWithin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 857D1848253610A900D8693A /* BeWithin.swift */; };
857D184F2536124400D8693A /* BeWithinTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 857D184D2536123F00D8693A /* BeWithinTest.swift */; };
8913649429E6925F00AD535E /* utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F14FB63194180C5009F2A08 /* utils.swift */; };
891729D52B1842D6005CC866 /* DSL+Require.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891729D42B1842D6005CC866 /* DSL+Require.swift */; };
891729D72B18431D005CC866 /* Requirement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891729D62B18431D005CC866 /* Requirement.swift */; };
891A04712AB0164500B46613 /* AsyncTimerSequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891A04702AB0164500B46613 /* AsyncTimerSequence.swift */; };
892FDF1329D3EA7700523A80 /* AsyncExpression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892FDF1229D3EA7700523A80 /* AsyncExpression.swift */; };
896962412A5FABD000A7929D /* AsyncAllPass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 896962402A5FABD000A7929D /* AsyncAllPass.swift */; };
Expand Down Expand Up @@ -310,6 +312,8 @@
7B5358C11C39155600A23FAA /* ObjCSatisfyAnyOfTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ObjCSatisfyAnyOfTest.m; sourceTree = "<group>"; };
857D1848253610A900D8693A /* BeWithin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeWithin.swift; sourceTree = "<group>"; };
857D184D2536123F00D8693A /* BeWithinTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeWithinTest.swift; sourceTree = "<group>"; };
891729D42B1842D6005CC866 /* DSL+Require.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DSL+Require.swift"; sourceTree = "<group>"; };
891729D62B18431D005CC866 /* Requirement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Requirement.swift; sourceTree = "<group>"; };
891A04702AB0164500B46613 /* AsyncTimerSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncTimerSequence.swift; sourceTree = "<group>"; };
892FDF1229D3EA7700523A80 /* AsyncExpression.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AsyncExpression.swift; sourceTree = "<group>"; };
896962402A5FABD000A7929D /* AsyncAllPass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncAllPass.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -447,6 +451,8 @@
1FD8CD041968AB07008ED995 /* Adapters */,
892FDF1229D3EA7700523A80 /* AsyncExpression.swift */,
1FD8CD081968AB07008ED995 /* DSL.swift */,
891729D62B18431D005CC866 /* Requirement.swift */,
891729D42B1842D6005CC866 /* DSL+Require.swift */,
899441F32902EF0900C1FAF9 /* DSL+AsyncAwait.swift */,
DA9E8C811A414BB9002633C2 /* DSL+Wait.swift */,
1FD8CD091968AB07008ED995 /* Expectation.swift */,
Expand Down Expand Up @@ -959,6 +965,7 @@
1FD8CD451968AB07008ED995 /* BeginWith.swift in Sources */,
1FD8CD4B1968AB07008ED995 /* BeIdenticalTo.swift in Sources */,
1FD8CD431968AB07008ED995 /* BeEmpty.swift in Sources */,
891729D52B1842D6005CC866 /* DSL+Require.swift in Sources */,
1F1871D41CA89EEE00A34BF2 /* NMBStringify.m in Sources */,
A8F6B5BD2070186D00FCB5ED /* SatisfyAllOf.swift in Sources */,
1FD8CD531968AB07008ED995 /* BeNil.swift in Sources */,
Expand All @@ -968,6 +975,7 @@
1FD8CD351968AB07008ED995 /* DSL.swift in Sources */,
7B5358BF1C38479700A23FAA /* SatisfyAnyOf.swift in Sources */,
896962412A5FABD000A7929D /* AsyncAllPass.swift in Sources */,
891729D72B18431D005CC866 /* Requirement.swift in Sources */,
1FD8CD391968AB07008ED995 /* Expression.swift in Sources */,
891A04712AB0164500B46613 /* AsyncTimerSequence.swift in Sources */,
89EEF5A52A03293100988224 /* AsyncMatcher.swift in Sources */,
Expand Down
1 change: 1 addition & 0 deletions Sources/Nimble/Adapters/AdapterProtocols.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// Protocol for the assertion handler that Nimble uses for all expectations.
public protocol AssertionHandler {
func assert(_ assertion: Bool, message: FailureMessage, location: SourceLocation)
func require(_ passed: Bool, message: FailureMessage, location: SourceLocation)
}

/// Global backing interface for assertions that Nimble creates.
Expand Down
6 changes: 6 additions & 0 deletions Sources/Nimble/Adapters/AssertionDispatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,10 @@ public class AssertionDispatcher: AssertionHandler {
handler.assert(assertion, message: message, location: location)
}
}

public func require(_ passed: Bool, message: FailureMessage, location: SourceLocation) {
for handler in handlers {
handler.require(passed, message: message, location: location)
}
}
}
16 changes: 14 additions & 2 deletions Sources/Nimble/Adapters/AssertionRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ public struct AssertionRecord: CustomStringConvertible {
/// The source location the expectation occurred on.
public let location: SourceLocation

public let issueType: IssueType

public var description: String {
return "AssertionRecord { success=\(success), message='\(message.stringValue)', location=\(location) }"
return "AssertionRecord { success=\(success), message='\(message.stringValue)', location=\(location), issueType=\(issueType) }"
}
}

Expand All @@ -31,7 +33,17 @@ public class AssertionRecorder: AssertionHandler {
AssertionRecord(
success: assertion,
message: message,
location: location))
location: location,
issueType: .assertionFailure))
}

public func require(_ passed: Bool, message: FailureMessage, location: SourceLocation) {
assertions.append(
AssertionRecord(
success: passed,
message: message,
location: location,
issueType: .thrownError))
}
}

Expand Down
40 changes: 38 additions & 2 deletions Sources/Nimble/Adapters/NimbleXCTestHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ public class NimbleXCTestHandler: AssertionHandler {
recordFailure("\(message.stringValue)\n", location: location)
}
}

public func require(_ passed: Bool, message: FailureMessage, location: SourceLocation) {
if !passed {
recordFailure("\(message.stringValue)\n", issueType: .thrownError, location: location)
}
}
}

/// Alternative handler for Nimble. This assertion handler passes failures along
Expand All @@ -25,6 +31,18 @@ public class NimbleShortXCTestHandler: AssertionHandler {
recordFailure("\(msg)\n", location: location)
}
}

public func require(_ passed: Bool, message: FailureMessage, location: SourceLocation) {
if !passed {
let msg: String
if let actual = message.actualValue {
msg = "got: \(actual) \(message.postfixActual)"
} else {
msg = "expected \(message.to) \(message.postfixMessage)"
}
recordFailure("\(msg)\n", issueType: .thrownError, location: location)
}
}
}

/// Fallback handler in case XCTest is unavailable. This assertion handler will abort
Expand All @@ -33,6 +51,10 @@ class NimbleXCTestUnavailableHandler: AssertionHandler {
func assert(_ assertion: Bool, message: FailureMessage, location: SourceLocation) {
fatalError("XCTest is not available and no custom assertion handler was configured. Aborting.")
}

func require(_ passed: Bool, message: FailureMessage, location: SourceLocation) {
fatalError("XCTest is not available and no custom assertion handler was configured. Aborting.")
}
}

#if canImport(Darwin)
Expand Down Expand Up @@ -72,15 +94,29 @@ func isXCTestAvailable() -> Bool {
#endif
}

public func recordFailure(_ message: String, location: SourceLocation) {
public enum IssueType {
case assertionFailure
case thrownError

#if canImport(Darwin)
var xctIssueType: XCTIssueReference.IssueType {
switch self {
case .assertionFailure: return .assertionFailure
case .thrownError: return .thrownError
}
}
#endif
}

public func recordFailure(_ message: String, issueType: IssueType = .assertionFailure, location: SourceLocation) {
#if !canImport(Darwin)
XCTFail("\(message)", file: location.file, line: location.line)
#else
if let testCase = CurrentTestCaseTracker.sharedInstance.currentTestCase {
let line = Int(location.line)
let location = XCTSourceCodeLocation(filePath: location.file, lineNumber: line)
let sourceCodeContext = XCTSourceCodeContext(location: location)
let issue = XCTIssue(type: .assertionFailure, compactDescription: message, sourceCodeContext: sourceCodeContext)
let issue = XCTIssue(type: issueType.xctIssueType, compactDescription: message, sourceCodeContext: sourceCodeContext)
testCase.record(issue)
} else {
let msg = """
Expand Down
50 changes: 50 additions & 0 deletions Sources/Nimble/DSL+Require.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/// Make a ``Requirement`` on a given actual value. The value given is lazily evaluated.
public func require<T>(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure @escaping () throws -> T?) -> SyncRequirement<T> {
return SyncRequirement(
expression: Expression(
expression: expression,
location: SourceLocation(file: file, line: line),
isClosure: true))
}

/// Make a ``Requirement`` on a given actual value. The closure is lazily invoked.
public func require<T>(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure () -> (() throws -> T)) -> SyncRequirement<T> {
return SyncRequirement(
expression: Expression(
expression: expression(),
location: SourceLocation(file: file, line: line),
isClosure: true))
}

/// Make a ``Requirement`` on a given actual value. The closure is lazily invoked.
public func require<T>(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure () -> (() throws -> T?)) -> SyncRequirement<T> {
return SyncRequirement(
expression: Expression(
expression: expression(),
location: SourceLocation(file: file, line: line),
isClosure: true))
}

/// Make a ``Requirement`` on a given actual value. The closure is lazily invoked.
public func require(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure () -> (() throws -> Void)) -> SyncRequirement<Void> {
return SyncRequirement(
expression: Expression(
expression: expression(),
location: SourceLocation(file: file, line: line),
isClosure: true))
}

// MARK: - Unwrap

/// Makes sure that the expression evaluates to a non-nil value, otherwise throw an error.
/// As you can tell, this is a much less verbose equivalent to `require(expression).toNot(beNil())`
public func unwrap<T>(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure @escaping () throws -> T?) throws -> T {
try require(file: file, line: line, expression()).toNot(beNil())
}

/// Makes sure that the expression evaluates to a non-nil value, otherwise throw an error.
/// As you can tell, this is a much less verbose equivalent to `require(expression).toNot(beNil())`
@discardableResult
public func unwrap<T>(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure () -> (() throws -> T?)) throws -> T {
try require(file: file, line: line, expression()).toNot(beNil())
}
149 changes: 149 additions & 0 deletions Sources/Nimble/Requirement.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
public struct RequirementError: Error, CustomNSError {
let message: String
let location: SourceLocation

var localizedDescription: String { message }
public var errorUserInfo: [String: Any] {
// Required to prevent Xcode from reporting that we threw an error.
// The default assertionHandlers will report this to XCode for us.
["XCTestErrorUserInfoKeyShouldIgnore": true]
}

static func unknown(_ location: SourceLocation) -> RequirementError {
RequirementError(message: "Nimble error - file a bug if you see this!", location: location)
}
}

public enum RequireError: Error {
case requirementFailed
case exceptionRaised(name: String, reason: String?, userInfo: [AnyHashable: Any]?)
}

internal func executeRequire<T>(_ expression: Expression<T>, _ style: ExpectationStyle, _ matcher: Matcher<T>, to: String, description: String?, captureExceptions: Bool = true) -> (Bool, FailureMessage, T?) {

Check warning on line 22 in Sources/Nimble/Requirement.swift

View workflow job for this annotation

GitHub Actions / lint

Large Tuple Violation: Tuples should have at most 2 members (large_tuple)
func run() -> (Bool, FailureMessage, T?) {

Check warning on line 23 in Sources/Nimble/Requirement.swift

View workflow job for this annotation

GitHub Actions / lint

Large Tuple Violation: Tuples should have at most 2 members (large_tuple)
let msg = FailureMessage()
msg.userDescription = description
msg.to = to
do {
let result = try matcher.satisfies(expression)
let value = try expression.evaluate()
result.message.update(failureMessage: msg)
if msg.actualValue == "" {
msg.actualValue = "<\(stringify(value))>"
}
return (result.toBoolean(expectation: style), msg, value)
} catch let error {
msg.stringValue = "unexpected error thrown: <\(error)>"
return (false, msg, nil)
}
}

var result: (Bool, FailureMessage, T?) = (false, FailureMessage(), nil)

Check warning on line 41 in Sources/Nimble/Requirement.swift

View workflow job for this annotation

GitHub Actions / lint

Large Tuple Violation: Tuples should have at most 2 members (large_tuple)
if captureExceptions {
let capture = NMBExceptionCapture(handler: ({ exception -> Void in

Check warning on line 43 in Sources/Nimble/Requirement.swift

View workflow job for this annotation

GitHub Actions / lint

Redundant Void Return Violation: Returning Void in a function declaration is redundant (redundant_void_return)
let msg = FailureMessage()
msg.stringValue = "unexpected exception raised: \(exception)"
result = (false, msg, nil)
}), finally: nil)
capture.tryBlock {
result = run()
}
} else {
result = run()
}

return result
}

internal func executeRequire<T>(_ expression: AsyncExpression<T>, _ style: ExpectationStyle, _ matcher: AsyncMatcher<T>, to: String, description: String?) async -> (Bool, FailureMessage, T?) {

Check warning on line 58 in Sources/Nimble/Requirement.swift

View workflow job for this annotation

GitHub Actions / lint

Large Tuple Violation: Tuples should have at most 2 members (large_tuple)
let msg = FailureMessage()
msg.userDescription = description
msg.to = to
do {
let result = try await matcher.satisfies(expression)
let value = try await expression.evaluate()
result.message.update(failureMessage: msg)
if msg.actualValue == "" {
msg.actualValue = "<\(stringify(value))>"
}
return (result.toBoolean(expectation: style), msg, value)
} catch let error {
msg.stringValue = "unexpected error thrown: <\(error)>"
return (false, msg, nil)
}
}

import XCTest

public struct SyncRequirement<Value> {
public let expression: Expression<Value>
public let status: ExpectationStatus

public var location: SourceLocation { expression.location }

private init(expression: Expression<Value>, status: ExpectationStatus) {
self.expression = expression
self.status = status
}

public init(expression: Expression<Value>) {
self.init(expression: expression, status: .pending)
}

@discardableResult
public func verify(_ pass: Bool, _ message: FailureMessage, _ value: Value?) throws -> Value {
let handler = NimbleEnvironment.activeInstance.assertionHandler
handler.require(pass, message: message, location: expression.location)
guard pass, let value else {
throw RequirementError(message: message.stringValue, location: self.location)
}
return value

// return try value.get()
}

/// Tests the actual value using a matcher to match.
@discardableResult
public func to(_ matcher: Matcher<Value>, description: String? = nil) throws -> Value {
let (pass, msg, result) = executeRequire(expression, .toMatch, matcher, to: "to", description: description)
return try verify(pass, msg, result)
}

/// Tests the actual value using a matcher to not match.
@discardableResult
public func toNot(_ matcher: Matcher<Value>, description: String? = nil) throws -> Value {
let (pass, msg, result) = executeRequire(expression, .toNotMatch, matcher, to: "to not", description: description)
return try verify(pass, msg, result)
}

/// Tests the actual value using a matcher to not match.
///
/// Alias to toNot().
@discardableResult
public func notTo(_ matcher: Matcher<Value>, description: String? = nil) throws -> Value {
try toNot(matcher, description: description)
}

// MARK: - AsyncMatchers
/// Tests the actual value using a matcher to match.
@discardableResult
public func to(_ matcher: AsyncMatcher<Value>, description: String? = nil) async throws -> Value {
let (pass, msg, result) = await executeRequire(expression.toAsyncExpression(), .toMatch, matcher, to: "to", description: description)
return try verify(pass, msg, result)
}

/// Tests the actual value using a matcher to not match.
@discardableResult
public func toNot(_ matcher: AsyncMatcher<Value>, description: String? = nil) async throws -> Value {
let (pass, msg, result) = await executeRequire(expression.toAsyncExpression(), .toNotMatch, matcher, to: "to not", description: description)
return try verify(pass, msg, result)
}

/// Tests the actual value using a matcher to not match.
///
/// Alias to toNot().
@discardableResult
public func notTo(_ matcher: AsyncMatcher<Value>, description: String? = nil) async throws -> Value {
try await toNot(matcher, description: description)
}
}
16 changes: 16 additions & 0 deletions Tests/NimbleTests/DSLTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,20 @@ final class DSLTest: XCTestCase {
expect { nonThrowingInt() }.to(equal(1))
expects { nonThrowingInt() }.to(equal(1))
}

func testRequire() throws {
expect { try require(1).to(equal(1)) }.toNot(throwError())

let records = gatherExpectations(silently: true) {
do {
try require(1).to(equal(2))
} catch {
expect(error).to(matchError(RequirementError.self))
}
}

expect(records).to(haveCount(2))
expect(records.first?.success).to(beFalse())
expect(records.last?.success).to(beTrue())
}
}

0 comments on commit db8206d

Please sign in to comment.