This commit is contained in:
Leif 2023-03-05 16:34:38 -07:00
commit b6ff6e900c
16 changed files with 942 additions and 0 deletions

23
.github/workflows/CI.yml vendored Normal file
View File

@ -0,0 +1,23 @@
# This workflow will build a Swift project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift
name: CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Build
run: swift build -v
- name: Run tests
run: swift test -v

38
.github/workflows/docc.yml vendored Normal file
View File

@ -0,0 +1,38 @@
name: docc
on:
push:
branches: ["main"]
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
pages:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: macos-12
steps:
- name: git checkout
uses: actions/checkout@v3
- name: docbuild
run: |
xcodebuild docbuild -scheme Test \
-derivedDataPath /tmp/docbuild \
-destination 'generic/platform=iOS';
$(xcrun --find docc) process-archive \
transform-for-static-hosting /tmp/docbuild/Build/Products/Debug-iphoneos/Test.doccarchive \
--hosting-base-path Test \
--output-path docs;
echo "<script>window.location.href += \"/documentation/test\"</script>" > docs/index.html;
- name: artifacts
uses: actions/upload-pages-artifact@v1
with:
path: 'docs'
- name: deploy
id: deployment
uses: actions/deploy-pages@v1

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

59
Package.resolved Normal file
View File

@ -0,0 +1,59 @@
{
"pins" : [
{
"identity" : "fork",
"kind" : "remoteSourceControl",
"location" : "https://github.com/0xLeif/Fork",
"state" : {
"revision" : "7682617694979adcf1f8b08d1507c865cad81ae4",
"version" : "1.2.0"
}
},
{
"identity" : "o",
"kind" : "remoteSourceControl",
"location" : "https://github.com/0xOpenBytes/o",
"state" : {
"revision" : "3e83362434c82f318a8d72e0d3b0786ffb3ba640",
"version" : "2.1.0"
}
},
{
"identity" : "plugin",
"kind" : "remoteSourceControl",
"location" : "https://github.com/0xLeif/Plugin",
"state" : {
"revision" : "bd5449fc40720589881bacb4ed7373ddfcb2d214",
"version" : "2.0.2"
}
},
{
"identity" : "scribe",
"kind" : "remoteSourceControl",
"location" : "https://github.com/0xLeif/Scribe",
"state" : {
"revision" : "79881803f6942346941421de516fefe690ea4db3",
"version" : "1.3.0"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log",
"state" : {
"revision" : "32e8d724467f8fe623624570367e3d50c5638e46",
"version" : "1.5.2"
}
},
{
"identity" : "t",
"kind" : "remoteSourceControl",
"location" : "https://github.com/0xOpenBytes/t",
"state" : {
"revision" : "c111675ac4d84af23d2d9b65bffaf1829c376986",
"version" : "1.0.4"
}
}
],
"version" : 2
}

38
Package.swift Normal file
View File

@ -0,0 +1,38 @@
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "Test",
platforms: [
.iOS(.v14),
.macOS(.v11),
.watchOS(.v7),
.tvOS(.v14)
],
products: [
.library(
name: "Test",
targets: ["Test"]),
],
dependencies: [
.package(url: "https://github.com/0xOpenBytes/t", from: "1.0.0"),
.package(url: "https://github.com/0xLeif/Scribe", from: "1.3.0"),
.package(url: "https://github.com/0xLeif/Plugin", from: "2.0.0")
],
targets: [
.target(
name: "Test",
dependencies: [
"t",
"Scribe",
"Plugin"
]
),
.testTarget(
name: "TestTests",
dependencies: ["Test"]
)
]
)

47
README.md Normal file
View File

@ -0,0 +1,47 @@
# Test
*Expect and assert*
## What is `Test`?
`Test` is a simple testing function that allows you to create test suites with multiple steps, expectations, and assertions. You can specify a name for the test suite, an instance of the `Tester` class to be used for running the tests, and a closure that contains the test steps.
## Where can `Test` be used?
`Test` can be used anywhere! `Test` can be used to test quickly inside a function to make sure something is working as expected. It is especially useful when you want to test a complex piece of code with multiple steps and assertions. `Test` can even be used for your unit tests!
## Examples
### Simple test with assertions
```swift
try await Test(named: "Test someMethod()") { tester in
try tester.assert(SomeClass.someMethod())
try await tester.assertNoThrows(try await SomeClass.someOtherMethod())
try tester.assert(SomeClass.someBooleanValue, isEqualTo: false)
}
```
### Test with expectations
```swift
try Test(named: "Test someMethod() with expectations") { tester in
try Expect("First step should succeed") {
try SomeClass.someMethod()
}
tester.logInfo("Just finished first step")
try Expect("Second step should succeed") {
try SomeClass.someOtherMethod()
}
tester.logWarning("Something unexpected happened during the second step")
try Expect("Final assertion should be true") {
try tester.assert(SomeClass.someBooleanValue)
}
tester.logSuccess("All steps and assertions passed!")
}
```

29
Sources/Test/Expect.swift Normal file
View File

@ -0,0 +1,29 @@
import t
/**
The `Expect` function is used to define a test expectation within a `Test` function. The expectation closure contains the code that should succeed and produce the expected result.
- Parameters:
- description: A string describing the expectation.
- expectation: A closure that contains the code to test the expectation.
*/
public func Expect(
_ description: String? = nil,
expectation: @escaping () throws -> Void
) throws {
try t.expect(description, expectation: expectation)
}
/**
The `Expect` function is used to define a test expectation within a `Test` function. The expectation closure contains the code that should succeed and produce the expected result.
- Parameters:
- description: A string describing the expectation.
- expectation: A closure that contains the code to test the expectation.
*/
public func Expect(
_ description: String? = nil,
expectation: @escaping () async throws -> Void
) async throws {
try await t.expect(description, expectation: expectation)
}

View File

@ -0,0 +1,45 @@
import t
import Scribe
/**
The `TestConfiguration` enum provides a global configuration for tests.
Use this enum to customize the logger and scribe for the test framework.
Example usage:
TestConfiguration.logger = { print($0) }
TestConfiguration.scribe = Scribe(label: "Custom Scribe")
*/
public enum TestConfiguration {
/**
A closure that receives a string and logs it. This is used by the framework to log messages when tests run.
By default, the logger is set to `t.logger` from the `t` library.
- Note: To change the logger, set `TestConfiguration.logger`.
Example usage:
TestConfiguration.logger = { print($0) }
*/
public static var logger: (String) -> Void {
get { t.logger }
set { t.logger = newValue }
}
/**
The scribe used by the test framework.
By default, the scribe is set to a new instance of `Scribe` with a label of "Test.Scribe".
- Note: To change the scribe, set `TestConfiguration.scribe`.
Example usage:
TestConfiguration.scribe = Scribe(label: "Custom Scribe")
*/
public static var scribe: Scribe = Scribe(label: "Test.Scribe")
}

View File

@ -0,0 +1,10 @@
import Plugin
/**
The `TestPlugin` protocol defines a contract for plugins that can be used to extend the behavior of the `Tester` class.
`TestPlugin` extends the `ImmutablePlugin` protocol, which allows the plugin to be treated as an immutable object.
`TestPlugin` is used for conveince when creating a `Plugin` for a `Tester`.
*/
public protocol TestPlugin: ImmutablePlugin { }

105
Sources/Test/Test.swift Normal file
View File

@ -0,0 +1,105 @@
import t
/**
The `Test` function is used to create a test suite with multiple steps, expectations, and assertions.
- Parameters:
- named: An optional name for the test suite.
- tester: An instance of the `Tester` class to be used for running the test suite.
- operation: A closure that contains the test steps, expectations, and assertions.
- lineNumber: The line number where the `Test` function is called. Defaults to the line number where the function is called.
- functionName: The name of the function where the `Test` function is called. Defaults to the name of the function where the function is called.
- fileName: The name of the file where the `Test` function is called. Defaults to the name of the file where the function is called.
- Throws: A `TestError` if the test suite fails.
Example usage:
try Test(named: "My Test Suite") { tester in
try Expect("First step should succeed") {
try SomeClass.someMethod()
}
try Expect("Second step should succeed") {
try SomeClass.someOtherMethod()
}
try Expect("Final assertion should be true") {
try tester.assert(SomeClass.someBooleanValue)
}
}
*/
public func Test(
named name: String? = nil,
tester: Tester = Tester(),
operation: (Tester) throws -> Void,
lineNumber: Int = #line,
functionName: String = #function,
fileName: String = #file
) throws {
let result = t.suite(named: name) {
try operation(tester)
}
guard result == true else {
let testName = name.map { "\($0) Test" } ?? "Test"
throw TestError(
description: "\(testName) failed.",
lineNumber: lineNumber,
functionName: functionName,
fileName: fileName
)
}
}
/**
The `Test` function is used to create a test suite with multiple steps, expectations, and assertions.
- Parameters:
- named: An optional name for the test suite.
- tester: An instance of the `Tester` class to be used for running the test suite.
- operation: A closure that contains the test steps, expectations, and assertions.
- lineNumber: The line number where the `Test` function is called. Defaults to the line number where the function is called.
- functionName: The name of the function where the `Test` function is called. Defaults to the name of the function where the function is called.
- fileName: The name of the file where the `Test` function is called. Defaults to the name of the file where the function is called.
- Throws: A `TestError` if the test suite fails.
Example usage:
try await Test(named: "My Test Suite") { tester in
try Expect("First step should succeed") {
try SomeClass.someMethod()
}
try await Expect("Second step should succeed") {
try await SomeClass.someOtherMethod()
}
try Expect("Final assertion should be true") {
try tester.assert(SomeClass.someBooleanValue)
}
}
*/
public func Test(
named name: String? = nil,
tester: Tester = Tester(),
operation: (Tester) async throws -> Void,
lineNumber: Int = #line,
functionName: String = #function,
fileName: String = #file
) async throws {
let result = await t.suite(named: name) {
try await operation(tester)
}
guard result == true else {
let testName = name.map { "\($0) Test" } ?? "Test"
throw TestError(
description: "\(testName) failed.",
lineNumber: lineNumber,
functionName: functionName,
fileName: fileName
)
}
}

View File

@ -0,0 +1,30 @@
import t
/**
The `TestError` function creates an instance of a test error with a given description and location information.
- Parameters:
- description: A string describing the error.
- lineNumber: The line number where the error occurred. Defaults to the line number where the function is called.
- functionName: The name of the function where the error occurred. Defaults to the name of the function where the function is called.
- fileName: The name of the file where the error occurred. Defaults to the name of the file where the function is called.
- Returns: An instance of `Error` representing the test error.
Example usage:
throw TestError(description: "Test failed.")
*/
public func TestError(
description: String,
lineNumber: Int = #line,
functionName: String = #function,
fileName: String = #file
) -> Error {
t.error(
description: description,
lineNumber: lineNumber,
functionName: functionName,
fileName: fileName
)
}

93
Sources/Test/Tested.swift Normal file
View File

@ -0,0 +1,93 @@
import t
/**
The Tested function is used to test a single operation and return its output.
Parameters:
description: A description of the operation being tested.
operation: A closure that contains the operation to be tested.
Returns: The output of the tested operation.
Throws: Any errors that occur during the operation.
Example usage:
let result = try Tested("Tested operation") {
return SomeClass.someMethod()
}
*/
public func Tested<Output>(
_ description: String,
operation: @escaping () throws -> Output
) throws -> Output {
try t.tested(description, operation)
}
/**
The Tested function is used to test a single operation and return its output.
Parameters:
operation: A closure that contains the operation to be tested.
Returns: The output of the tested operation.
Throws: Any errors that occur during the operation.
Example usage:
let result = try Tested("Tested operation") {
return SomeClass.someMethod()
}
*/
public func Tested<Output>(
operation: @escaping () throws -> Output
) throws -> Output {
try t.tested(operation)
}
/**
The Tested function is used to test a single operation and return its output.
Parameters:
description: A description of the operation being tested.
operation: A closure that contains the operation to be tested.
Returns: The output of the tested operation.
Throws: Any errors that occur during the operation.
Example usage:
let result = try await Tested("Tested operation") {
try await SomeClass.someMethod()
}
*/
public func Tested<Output>(
_ description: String,
operation: @escaping () async throws -> Output
) async throws -> Output {
try await t.tested(description, operation)
}
/**
The Tested function is used to test a single operation and return its output.
Parameters:
operation: A closure that contains the operation to be tested.
Returns: The output of the tested operation.
Throws: Any errors that occur during the operation.
Example usage:
let result = try await Tested("Tested operation") {
try await SomeClass.someMethod()
}
*/
public func Tested<Output>(
operation: @escaping () async throws -> Output
) async throws -> Output {
try await t.tested(operation)
}

View File

@ -0,0 +1,148 @@
import Scribe
extension Tester {
public typealias PluginPayload = Scribe.PluginPayload
public typealias Message = Scribe.Message
public typealias Level = Scribe.Level
public typealias Metadata = Scribe.Metadata
/// Logs a message with the trace log level.
///
/// - Parameters:
/// - message: The message to be logged.
/// - metadata: Optional metadata to be included with the log message.
/// - source: Optional source information to be included with the log message.
/// - Returns: A task representing the log operation.
@discardableResult
public func logTrace(
_ message: Message,
metadata: Metadata? = nil,
source: String? = nil
) -> Task<Void, Error> {
scribe.trace(
message,
metadata: metadata,
source: source
)
}
/// Logs a message with the debug log level.
///
/// - Parameters:
/// - message: The message to be logged.
/// - metadata: Optional metadata to be included with the log message.
/// - source: Optional source information to be included with the log message.
/// - Returns: A task representing the log operation.
@discardableResult
public func logDebug(
_ message: Message,
metadata: Metadata? = nil,
source: String? = nil
) -> Task<Void, Error> {
scribe.debug(
message,
metadata: metadata,
source: source
)
}
/// Logs a message with the info log level.
///
/// - Parameters:
/// - message: The message to be logged.
/// - metadata: Optional metadata to be included with the log message.
/// - source: Optional source information to be included with the log message.
/// - Returns: A task representing the log operation.
@discardableResult
public func logInfo(
_ message: Message,
metadata: Metadata? = nil,
source: String? = nil
) -> Task<Void, Error> {
scribe.info(
message,
metadata: metadata,
source: source
)
}
/// Logs a message with the notice log level.
///
/// - Parameters:
/// - message: The message to be logged.
/// - metadata: Optional metadata to be included with the log message.
/// - source: Optional source information to be included with the log message.
/// - Returns: A task representing the log operation.
@discardableResult
public func logNotice(
_ message: Message,
metadata: Metadata? = nil,
source: String? = nil
) -> Task<Void, Error> {
scribe.notice(
message,
metadata: metadata,
source: source
)
}
/// Logs a message with the warning log level.
///
/// - Parameters:
/// - message: The message to be logged.
/// - metadata: Optional metadata to be included with the log message.
/// - source: Optional source information to be included with the log message.
/// - Returns: A task representing the log operation.
@discardableResult
public func logWarning(
_ message: Message,
metadata: Metadata? = nil,
source: String? = nil
) -> Task<Void, Error> {
scribe.warning(
message,
metadata: metadata,
source: source
)
}
/// Logs a message with the error log level.
///
/// - Parameters:
/// - message: The message to be logged.
/// - metadata: Optional metadata to be included with the log message.
/// - source: Optional source information to be included with the log message.
/// - Returns: A task representing the log operation.
@discardableResult
public func logError(
_ message: Message,
metadata: Metadata? = nil,
source: String? = nil
) -> Task<Void, Error> {
scribe.error(
message,
metadata: metadata,
source: source
)
}
/// Logs a message with the critical log level.
///
/// - Parameters:
/// - message: The message to be logged.
/// - metadata: Optional metadata to be included with the log message.
/// - source: Optional source information to be included with the log message.
/// - Returns: A task representing the log operation.
@discardableResult
public func logCritical(
_ message: Message,
metadata: Metadata? = nil,
source: String? = nil
) -> Task<Void, Error> {
scribe.critical(
message,
metadata: metadata,
source: source
)
}
}

View File

@ -0,0 +1,199 @@
import t
extension Tester {
/// Assert that the condition is true.
public func assert(
_ condition: Bool,
_ message: String? = nil,
lineNumber: Int = #line,
functionName: String = #function,
fileName: String = #file
) throws {
try t.assert(
condition,
message,
lineNumber: lineNumber,
functionName: functionName,
fileName: fileName
)
}
/// Assert that the condition is false.
public func assert(
notTrue condition: Bool,
_ message: String? = nil,
lineNumber: Int = #line,
functionName: String = #function,
fileName: String = #file
) throws {
try t.assert(
notTrue: condition,
message,
lineNumber: lineNumber,
functionName: functionName,
fileName: fileName
)
}
/// Assert that the first value is equal to the second.
public func assert<Value: Equatable>(
_ firstValue: Value,
isEqualTo secondValue: Value,
_ message: String? = nil,
lineNumber: Int = #line,
functionName: String = #function,
fileName: String = #file
) throws {
try t.assert(
firstValue,
isEqualTo: secondValue,
message,
lineNumber: lineNumber,
functionName: functionName,
fileName: fileName
)
}
/// Assert that the first value is not equal to the second.
public func assert<Value: Equatable>(
_ firstValue: Value,
isNotEqualTo secondValue: Value,
_ message: String? = nil,
lineNumber: Int = #line,
functionName: String = #function,
fileName: String = #file
) throws {
try t.assert(
firstValue,
isNotEqualTo: secondValue,
message,
lineNumber: lineNumber,
functionName: functionName,
fileName: fileName
)
}
/// Assert that the value is not nil.
public func assert<Value>(
isNotNil value: Value?,
_ message: String? = nil,
lineNumber: Int = #line,
functionName: String = #function,
fileName: String = #file
) throws {
try t.assert(
isNotNil: value,
message,
lineNumber: lineNumber,
functionName: functionName,
fileName: fileName
)
}
/// Assert that the value is nil.
public func assert<Value>(
isNil value: Value?,
_ message: String? = nil,
lineNumber: Int = #line,
functionName: String = #function,
fileName: String = #file
) throws {
try t.assert(
isNil: value,
message,
lineNumber: lineNumber,
functionName: functionName,
fileName: fileName
)
}
/// Assert that the closure should throw
public func assertThrows<Value>(
_ message: String? = nil,
operation: @escaping @autoclosure () throws -> Value,
lineNumber: Int = #line,
functionName: String = #function,
fileName: String = #file
) throws {
try t.assertThrows(
operation,
message: message,
lineNumber: lineNumber,
functionName: functionName,
fileName: fileName
)
}
/// Assert that the closure should not throw
public func assertNoThrows<Value>(
_ message: String? = nil,
operation: @escaping @autoclosure () throws -> Value,
lineNumber: Int = #line,
functionName: String = #function,
fileName: String = #file
) throws {
try t.assertNoThrows(
operation,
message: message,
lineNumber: lineNumber,
functionName: functionName,
fileName: fileName
)
}
///
@discardableResult
public func unwrap<Value>(
_ value: Value?,
message: String? = nil,
lineNumber: Int = #line,
functionName: String = #function,
fileName: String = #file
) throws -> Value {
try t.unwrap(
value,
message: message,
lineNumber: lineNumber,
functionName: functionName,
fileName: fileName
)
}
}
#if os(macOS) || os(iOS) || os(watchOS) || os(tvOS)
extension Tester {
/// Assert that the closure should throw
public func assertThrows<Value>(
_ message: String? = nil,
operation: @escaping @autoclosure () async throws -> Value,
lineNumber: Int = #line,
functionName: String = #function,
fileName: String = #file
) async throws {
try await t.assertThrows(
operation,
message: message,
lineNumber: lineNumber,
functionName: functionName,
fileName: fileName
)
}
/// Assert that the closure should not throw
public func assertNoThrows<Value>(
_ message: String? = nil,
operation: @escaping @autoclosure () async throws -> Value,
lineNumber: Int = #line,
functionName: String = #function,
fileName: String = #file
) async throws {
try await t.assertNoThrows(
operation,
message: message,
lineNumber: lineNumber,
functionName: functionName,
fileName: fileName
)
}
}
#endif

View File

@ -0,0 +1,29 @@
import t
import Scribe
import Plugin
/**
The `Tester` class is responsible for managing the execution of a test suite.
A `Tester` instance contains a `Scribe` object, which is used to log test results and a collection of plugins that are used to perform custom operations during the test run.
*/
open class Tester: ImmutablePluginable {
/// The `Scribe` object used to log test results.
open var scribe: Scribe
/**
Initializes a new `Tester` object.
- Parameters:
- scribe: The `Scribe` object used to log test results. Defaults to `TestConfiguration.scribe`.
- plugins: An array of plugins to use during the test run. Defaults to an empty array.
*/
public init(
scribe: Scribe = TestConfiguration.scribe,
plugins: [any Plugin] = []
) {
self.scribe = scribe
super.init(plugins: plugins)
}
}

View File

@ -0,0 +1,40 @@
import XCTest
@testable import Test
final class TestTests: XCTestCase {
override class func setUp() {
super.setUp()
TestConfiguration.logger = { _ in }
}
func testExample() async throws {
struct ExampleTestPlugin: TestPlugin {
func handle(value: Bool) async throws {
guard value else {
throw TestError(description: "False value")
}
}
}
try await Test(
named: "Development",
tester: Tester(
plugins: [
ExampleTestPlugin()
]
),
operation: { tester in
try await Expect(description) {
tester.logInfo("Info!")
try tester.assert(0, isEqualTo: 0)
try await tester.handle(value: true)
tester.logError("There should have been an error")
}
}
)
}
}