Author Avatar

Bootstrapping a tiny testing library for Swift scripts

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:

  1. Compile the library into a dylib (and recompile whenever it is updated)

  2. 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.