BookaMac Engineering-Notizen

Swift-Makro-Expansionen auf einem Cloud-Mac reproduzierbar testen

Swift-Makro-Expansionen auf einem Cloud-Mac reproduzierbar testen

Nachdem eine Makroimplementierung zusammengeführt worden war, liefen lokal alle Tests erfolgreich. Der nächste Build auf dem Cloud-Mac erzeugte jedoch eine andere Reihenfolge der Member, und auch die Diagnoseposition war um eine Zeile verschoben. Das Endprodukt ließ sich weiterhin kompilieren, doch im Code-Review war nicht mehr dieselbe API zu sehen. Swift-Makros werden während der Kompilierung ausgeführt. Ihr Ergebnis hängt daher zugleich von der Makroimplementierung, der SwiftSyntax-Abhängigkeit und der aktuell verwendeten Toolchain ab. Sie dürfen deshalb nicht wie gewöhnliche Funktionen getestet werden.

Zuerst das festzuschreibende Ergebnis definieren

Regressionstests sollten drei Ebenen abdecken. Auf der ersten Ebene wird der vollständig expandierte Quellcode verglichen, um Änderungen an Membern, Zugriffsrechten, Attributen und Formatierung zu erfassen. Die zweite Ebene prüft Fehler, Warnungen und Korrekturvorschläge, insbesondere das Token, auf das eine Diagnose verweist. Erst auf der dritten Ebene folgen Kompilierungs- und Laufzeittests, die bestätigen, dass sich der generierte Code tatsächlich aufrufen lässt.

Ebene Prüfgegenstand Typischer Fehler
Expansionsebene Generierter Quellcode und Reihenfolge der Deklarationen Fehlendes Attribut, geänderte Zugriffsebene
Diagnoseebene Meldung, Position und Korrekturvorschlag Fehler verweist auf den falschen Knoten
Verhaltensebene Kompilierungs- und Laufzeitergebnis Generierte Implementierung ist kompilierbar, verhält sich aber falsch

„Der Test lässt sich kompilieren“ bedeutet nicht, dass sich die Makroausgabe nicht geändert hat. Der öffentliche Vertrag eines Makros besteht häufig gerade in der Form des von ihm generierten Quellcodes.

Legen Sie zunächst mit einer minimalen Eingabe eine Referenz fest. Ergänzen Sie anschließend leere Deklarationen, Generics, verschachtelte Typen, bereits vorhandene gleichnamige Member und ungültige Argumente. Jeder Testfall sollte nur eine Regel prüfen, damit sich bei einem Fehler schnell feststellen lässt, ob eine Regression in der Implementierung oder eine Änderung der Toolchain vorliegt.

Generierten Quellcode mit Expansionstests fixieren

Das Test-Target eines Makropakets kann SwiftSyntaxMacrosTestSupport einbinden und über assertMacroExpansion sowohl die Eingabe als auch den erwarteten Quellcode angeben. Im folgenden Beispiel wird angenommen, dass TraceIDMacro einer Struktur eine String-Eigenschaft hinzufügt:

import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport
import XCTest
@testable import TraceIDMacros

private let macros: [String: Macro.Type] = [
    "TraceID": TraceIDMacro.self
]

final class TraceIDMacroTests: XCTestCase {
    func testAddsTraceID() {
        assertMacroExpansion(
            """
            @TraceID
            struct Job {}
            """,
            expandedSource:
            """
            struct Job {
                let traceID: String
            }
            """,
            macros: macros
        )
    }
}

Der erwartete Text sollte vollständig verglichen werden. Prüfen Sie nicht lediglich, ob traceID vorkommt. Teilstring-Assertions übersehen doppelte Member, fehlerhafte Einrückungen und unbeabsichtigt hinzugefügte Schnittstellen. Wenn das Makro Argumente akzeptiert, sollten außerdem getrennte Testfälle für Standardwerte, explizite Werte und ungültige Ausdrücke angelegt werden.

Fehlerpfade separat testen

Ungültige Eingaben sollten nicht im selben Test wie eine erfolgreiche Expansion geprüft werden. Erstellen Sie separate Assertions für Diagnosetext, Zeilen- und Spaltenposition sowie Korrekturvorschläge. Der Testname sollte die auslösende Bedingung eindeutig benennen. Wird eine Fehlermeldung angepasst, muss das Team dadurch nicht zugleich den gesamten Snapshot einer erfolgreichen Expansion erneut akzeptieren.

Toolchain-Fingerabdruck protokollieren

Cloud-Macs von BookaMac können dieselbe Pipeline dauerhaft ausführen. Ein stabiler Knoten bedeutet jedoch nicht, dass die Toolchain für immer unverändert bleibt. Bei jedem Test sollten deshalb der ausgewählte Xcode-Pfad, die Swift-Version, die Hostarchitektur und die Lockdatei der Abhängigkeiten gemeinsam archiviert werden.

set -euo pipefail
export LANG=C
export LC_ALL=C

mkdir -p artifacts
xcode-select -p | tee artifacts/xcode-path.txt
xcrun swift --version | tee artifacts/swift-version.txt
uname -m | tee artifacts/host-arch.txt
cp Package.resolved artifacts/Package.resolved
xcrun swift test 2>&1 | tee artifacts/swift-test.log

Durch das Festlegen von LANG und LC_ALL ändern sich Diagnosetexte nicht so leicht mit der Spracheinstellung des ausführenden Benutzerkontos. xcrun stellt außerdem sicher, dass der Test die Toolchain des aktuell ausgewählten Xcode verwendet und nicht versehentlich ein anderes swift aus dem Shell-Pfad. Falls das Repository keine Package.resolved enthält, sollte das Skript den Kopiervorgang ausdrücklich überspringen, anstatt eine leere Datei als vermeintliches Lock-Ergebnis anzulegen.

Parallelität und gemeinsamen Zustand isolieren

Liest eine Makroimplementierung Umgebungsvariablen, das aktuelle Verzeichnis, die Uhrzeit oder temporäre Dateien aus, können Tests bei paralleler Ausführung leicht instabil werden. Robuster ist es, wenn ein Makro sein Ergebnis ausschließlich aus dem Syntaxbaum und expliziten Argumenten erzeugt. Sind externe Daten tatsächlich erforderlich, sollte die Leselogik in einen separaten Typ ausgelagert werden, dem der Test feste Werte übergibt.

Führen Sie zunächst eine minimale Testmenge seriell aus und aktivieren Sie erst danach die parallele Ausführung. Treten Fehler ausschließlich im Parallelbetrieb auf, prüfen Sie zuerst fest vorgegebene Dateinamen, gemeinsam genutzte Cache-Verzeichnisse, globale veränderliche Variablen und die Reihenfolge der Testbereinigung. Erhöhen Sie nicht einfach die Anzahl der Wiederholungsversuche. Wiederholungen kaschieren Race Conditions als sporadisch erfolgreiche Testläufe.

Makrotests sollten außerdem niemals den echten Quellcode des Projekts verändern. Temporäre Eingaben gehören in ein eigenes Verzeichnis pro Testfall und sollten anschließend entfernt werden. Bei einem Fehler werden nur bereinigte Eingaben, Expansionsergebnisse und Protokolle aufbewahrt. So bleibt der Fehler reproduzierbar, ohne dass Überreste eines früheren Testlaufs den nächsten beeinflussen.

Toolchain sicher aktualisieren

Führen Sie vor einem Upgrade von Xcode oder SwiftSyntax zunächst die vollständige Testsuite mit der alten Toolchain aus und speichern Sie die Referenzergebnisse. Wechseln Sie anschließend die Toolchain und führen Sie denselben Befehl erneut aus. Prüfen Sie Unterschiede in dieser Reihenfolge:

  1. Bestätigen Sie den tatsächlich ausgewählten Xcode-Pfad und die Swift-Version.
  2. Prüfen Sie, ob sich Package.resolved wie erwartet geändert hat.
  3. Unterscheiden Sie Formatierungsänderungen wie Leerzeichen und Einrückungen von semantischen Änderungen an Membern, Typen oder Zugriffsrechten.
  4. Führen Sie die Verhaltenstests erneut aus und bestätigen Sie, dass sich die generierte Schnittstelle aufrufen lässt.
  5. Aktualisieren Sie die erwarteten Expansionsergebnisse erst nach Abschluss dieser Prüfung.

Treten in allen Testfällen gleichzeitig ähnliche Formatierungsunterschiede auf, sollten zunächst Änderungen an den Ausgaberegeln oder der Toolchain untersucht werden. Ändert sich nur ein einzelner Grenzfall, liegt die Ursache eher in der Makroimplementierung selbst. Aktualisieren Sie Snapshots nicht pauschal per Suchen und Ersetzen, da sonst eine echte API-Regression zwischen zahlreichen Textänderungen verborgen bleiben kann.

Am Ende sollten drei Arten von Artefakten vorliegen: maschinenlesbare Testergebnisse, die tatsächlichen und erwarteten Unterschiede fehlgeschlagener Expansionen sowie der Toolchain-Fingerabdruck. Wenn der nächste Build auf dem Cloud-Mac fehlschlägt, beginnt die Fehlersuche damit nicht mehr bei der vagen Vermutung, die Umgebung könne anders sein. Stattdessen lässt sich eindeutig beantworten, welche Vertragsebene sich ab welcher Version geändert hat.

Häufig gestellte Fragen

Reicht ein erfolgreicher Build als Test für ein Swift-Makro aus?

Nein. Er zeigt nur, dass der erzeugte Code kompiliert. Expansionstests müssen zusätzlich die Form der generierten API sowie Fehler, Warnungen und Korrekturhinweise prüfen.

Wie gehe ich mit vielen Änderungen nach einem Xcode-Upgrade um?

Vergleichen Sie zuerst Xcode-Pfad, Swift-Version und Sperrdatei. Aktualisieren Sie Referenzen erst, nachdem Formatänderungen von semantischen API- oder Diagnoseänderungen getrennt wurden.

Dedizierter physischer Mac mini

Bewährte Workflows auf einen dauerhaft verfügbaren Cloud-Mac verlagern

Wählen Sie je nach Einsatz BookaMac M4 oder BookaMac M4 Pro und prüfen Sie bei der Bestellung Region, Laufzeit und optionale Speichererweiterungen.

Mietoption auswählen