
A frustration of mine with Swift is having to go through all the ceremony of creating a new project/package every time I want to explore something.
Xcode is heavy and brings baggage that becomes noise in these early stages of a project, so I usually start with a single file and a lightweight environment like CodeRunner or Neovim, but something I miss is having a testing environment.
A naive solution is to write a mini library and copy it into each new exploration.
// mytest.test.swift
test("some test that will fail") {
assertEqual("a", "b")
}
import Foundation
struct StandardError: TextOutputStream, Sendable {
private static let handle = FileHandle.standardError
public func write(_ string: String) {
Self.handle.write(Data(string.utf8))
}
}
var stderr = StandardError()
func renderError(
file: StaticString,
line: UInt,
message: String,
to stderr: inout StandardError
) {
print("\(file):\(line): \(message)", to: &stderr)
}
func assertEqual<Type: Equatable>(
_ a: Type,
_ b: Type,
_ message: String? = nil,
file: StaticString = #file,
line: UInt = #line
) {
if a != b {
renderError(
file: file,
line: line,
message: message ?? "assert equal failed",
to: &stderr
)
}
}
func test(_ name: String, action: () -> Void) {
action()
}
For the sake of simplicity, the library itself is intentionally minimal. The goal is not to build a full testing framework, but to establish a baseline that you can extend to your needs.
This approach has several drawbacks, among them the fact that the source code of the mini lib takes up space in the test file itself, adding visual noise.
It also implies having to copy it manually every time it is needed, which is painful, besides leading to duplication and diverging versions as new asserts are added and the library evolves.
A more appropriate solution is to create a small dynamic library to link against.
The idea is that, given a file some.test.swift:
import MiniTests
test("some test that will fail") {
assertEqual("a", "b")
}
I can run it directly from the command line or in CodeRunner with something like:
$ test some
The flow would be:
Compile the library into a dylib (and recompile whenever it is updated)
Link it each time we want to run a *.test.swift file
This setup is more involved than copying a few functions into a file. The difference is that you only pay this cost once: after that, every new script gets a clean testing setup with zero duplication.
libMiniTests
Compiling to a dylib:
mkdir -p ~/.swift_libs/MiniTests
# ~/.swift_libs/MiniTests/build.sh:
swiftc -emit-library -emit-module src.swift \
-module-name MiniTests \
-o libMiniTests.dylib \
-Xlinker -install_name -Xlinker "@rpath/libMiniTests.dylib"
Don’t forget to mark assertEqual and test as public!
Make it executable and run it:
chmod +x build.sh && ./build.sh
Linking when running a *.test.swift:
swift -I "$HOME/.swift_libs" -L "$HOME/.swift_libs" -lMiniTests <filename>.test.swift
zsh alias:
test() {
local base="${1%.test.swift}"
base="${base%.swift}"
local file="${base}.test.swift"
if [[ -f "$file" ]]; then
swift -I "$HOME/.swift_libs" -L "$HOME/.swift_libs" -lMiniTests "$file"
else
echo "File not found: $file"
fi
}
We can also use the compiler, useful when working with a debugger, for example in CodeRunner, you could tweak your run script as follows:
#!/bin/bash
[ -z "$CR_SUGGESTED_OUTPUT_FILE" ] && CR_SUGGESTED_OUTPUT_FILE="$PWD/${CR_FILENAME%.*}"
if [ "$CR_FILENAME" = "main.swift" ]; then
# Filter out test files
SOURCES=$(ls *.swift | grep -v "\.test\.swift$")
xcrun -sdk macosx swiftc -o "$CR_SUGGESTED_OUTPUT_FILE" $SOURCES "${@:1}" ${CR_DEBUGGING:+-g}
else
# Check if file is a test
case "$CR_FILENAME" in
*.test.swift)
TEST_LIB_PATH="$HOME/.swift_libs/MiniTests"
xcrun -sdk macosx swiftc \
-I "$TEST_LIB_PATH" \
-L "$TEST_LIB_PATH" \
-lMiniTests \
-o "$CR_SUGGESTED_OUTPUT_FILE" \
-Xlinker -rpath -Xlinker "$TEST_LIB_PATH" \
"$CR_FILENAME" "${@:1}" ${CR_DEBUGGING:+-g}
;;
*)
xcrun -sdk macosx swiftc -o "$CR_SUGGESTED_OUTPUT_FILE" "$CR_FILENAME" "${@:1}" ${CR_DEBUGGING:+-g}
;;
esac
fi
status=$?
if [ $status -eq 0 ]; then
echo "$CR_SUGGESTED_OUTPUT_FILE"
fi
exit $status
Original draft written in spanish.