iOS-Bitmengen testen: Bitreihenfolge, Füllbits und Längengrenzen
Ein Team speichert den Lesestatus von Einträgen als Bitmenge. Mitglieder im asiatisch-pazifischen Raum schreiben die Daten, Kollegen in Nordamerika lesen sie anschließend aus. Lokal funktioniert das Zurücklesen zuverlässig. Mit einer anderen Clientversion zeigt sich jedoch: Die erste und die achte Markierung sind vertauscht. Meist liegt das nicht am Netzwerk, sondern an einer unzureichend definierten Binärdarstellung. Auf einem Cloud-Mac lassen sich solche Prüfungen als feste Aufgabe einrichten. So wird jede Änderung mit denselben Referenzbytes verglichen, statt nur zu kontrollieren, ob die Oberfläche korrekt aussieht.
Zuerst das Übertragungsformat festlegen, nicht das Speicherlayout übernehmen
Dieser Artikel implementiert ein Referenzformat, das unabhängig von der Anwendungslogik ist: Die ersten vier Bytes enthalten die vorzeichenlose Anzahl der Bits in Big-Endian-Reihenfolge. Direkt danach folgt die Nutzlast der Bitmenge. Die Bitindizes beginnen bei null; innerhalb jedes Bytes kommt das niederwertigste Bit zuerst. Index null entspricht somit dem niederwertigsten Bit des ersten Bytes, Index acht dem niederwertigsten Bit des zweiten Bytes.
Die Anzahl der Nutzlastbytes ergibt sich aus der Bitanzahl geteilt durch acht, zuzüglich eines Bytes, falls ein Rest bleibt. Bei null Bits besteht die Darstellung nur aus dem Header. Nicht verwendete höherwertige Bits im letzten Byte müssen null sein; zusätzliche Bytes werden ebenfalls abgewiesen. Dieses Format enthält kein Versionsfeld. Bei der Einbindung in ein bestehendes Protokoll muss die Version deshalb auf einer übergeordneten Ebene ausdrücklich festgelegt werden. Vorhandene Daten dürfen nicht einfach durch das neue Format ersetzt werden.
Ein erfolgreicher Roundtrip zeigt nur, dass beide Seiten dieselbe Konvention verwenden. Wenn Encoder und Decoder die Bitreihenfolge gleichermaßen vertauschen, besteht der Test trotzdem. Deshalb braucht es zusätzlich von Hand berechnete Bytevektoren.
Serialisieren Sie weder den Speicherinhalt einer Struktur noch Maschinenwörter oder Puffer nativer Ganzzahlen direkt. Andernfalls werden Speicherausrichtung, Byte-Reihenfolge und Implementierungsdetails Teil des persistenten Formats.
Eine Referenzimplementierung mit Eingabegrenze schreiben
Speichern Sie den folgenden Code auf dem Cloud-Mac in einem separaten Verzeichnis als BitsetCheck.swift und führen Sie ihn mit Swift aus der Entwickler-Toolchain aus. Die Obergrenze liegt hier bei einer Million Bits. Das ist eine beispielhafte Schutzmaßnahme, keine theoretische Grenze des Dateiformats. Die Eingabe wird zuerst auf diese Obergrenze und die vollständige Länge geprüft. Erst danach wird das Ergebnisarray angelegt, damit ein nicht vertrauenswürdiger Header keine große Speicherbelegung auslösen kann.
import Foundation
enum FormatError: Error {
case invalid
}
let maxBits = 1_000_000
func encode(_ bits: [Bool]) throws -> [UInt8] {
guard bits.count <= maxBits else {
throw FormatError.invalid
}
let count = UInt32(bits.count)
var bytes = [
UInt8(truncatingIfNeeded: count >> 24),
UInt8(truncatingIfNeeded: count >> 16),
UInt8(truncatingIfNeeded: count >> 8),
UInt8(truncatingIfNeeded: count)
]
let size = bits.count / 8 + (bits.count % 8 == 0 ? 0 : 1)
bytes += Array(repeating: UInt8(0), count: size)
for index in bits.indices where bits[index] {
bytes[4 + index / 8] |= UInt8(1) << (index % 8)
}
return bytes
}
func decode(_ bytes: [UInt8]) throws -> [Bool] {
guard bytes.count >= 4 else {
throw FormatError.invalid
}
let raw = bytes.prefix(4).reduce(UInt32(0)) {
($0 << 8) | UInt32($1)
}
guard raw <= UInt32(maxBits) else {
throw FormatError.invalid
}
let count = Int(raw)
let size = count / 8 + (count % 8 == 0 ? 0 : 1)
guard bytes.count == 4 + size else {
throw FormatError.invalid
}
let remainder = count % 8
if remainder != 0 {
let allowed = UInt8((1 << remainder) - 1)
guard bytes[bytes.count - 1] & ~allowed == 0 else {
throw FormatError.invalid
}
}
return (0..<count).map {
bytes[4 + $0 / 8] & (UInt8(1) << ($0 % 8)) != 0
}
}
func require(_ condition: Bool) {
if !condition {
fatalError("Bitset check failed")
}
}
func mustReject(_ bytes: [UInt8]) {
do {
_ = try decode(bytes)
} catch {
return
}
fatalError("Invalid input accepted")
}
let expected: [UInt8] = [0, 0, 0, 9, 0x81, 0x01]
var sample = Array(repeating: false, count: 9)
for index in [0, 7, 8] {
sample[index] = true
}
require(try encode(sample) == expected)
require(try decode(expected) == sample)
for count in [0, 1, 7, 8, 9, 63, 64, 65] {
let bits = (0..<count).map { $0 % 3 == 0 }
let bytes = try encode(bits)
require(try decode(bytes) == bits)
}
mustReject([])
mustReject([0, 0, 0, 9, 0x81])
mustReject(expected + [0])
mustReject([0, 0, 0, 1, 0x80])
mustReject([0xFF, 0xFF, 0xFF, 0xFF])
Führen Sie xcrun swift BitsetCheck.swift aus. Bei Erfolg endet das Programm ohne Ausgabe. Eine fehlgeschlagene Prüfung oder ein unbehandelter Fehler führt zu einem Fehlerstatus des Prozesses. Ersetzen Sie nach der Übernahme ins Projekt die Referenzfunktionen durch die tatsächlichen Encoder- und Decoder-Einstiegspunkte. Behalten Sie dabei die Vektoren und die Tests zur Zurückweisung ungültiger Eingaben bei. Andernfalls wird weiterhin nur das Beispiel geprüft.
Kodierung und Dekodierung mit festen Referenzvektoren getrennt prüfen
Sind von neun Bits die Bits null, sieben und acht gesetzt, muss die Nutzlast hexadezimal 81 01 lauten. Das erste Byte prüft sowohl das niederwertigste als auch das höchstwertige Bit. Das zweite prüft den Übergang über die Bytegrenze hinweg. Der Header stellt außerdem klar, dass die logische Bitanzahl nicht mit der Nutzlastlänge identisch ist.
Ergänzen Sie vier weitere Muster: alle Bits gelöscht, alle Bits gesetzt, nur das erste Bit gesetzt und nur das letzte Bit gesetzt. Acht gesetzte Bits müssen FF ergeben, neun gesetzte Bits FF 01, nicht FF FF. Unbenutzte Bits sind kein beliebiger Restbereich, nur weil sie beim Lesen nicht ausgewertet werden. Erst eine kanonische Darstellung sorgt für konsistente Bytevergleiche, Hashwerte und Cache-Schlüssel.
Feste Referenzvektoren gehören in die Versionsverwaltung. Bei Protokolländerungen werden zuerst die erwarteten Bytes geprüft und erst danach die Implementierung angepasst. Lassen Sie die erwarteten Dateien nicht automatisch vom zu testenden Encoder erzeugen und überschreiben. Das wäre, als dürfte der Prüfling seine Musterlösung selbst vorgeben.
Ungültige Eingaben als eigenen Testbereich behandeln
Tests mit abgeschnittenen Daten müssen sowohl einen Header mit weniger als vier Bytes als auch einen vollständigen Header mit einem fehlenden Nutzlastbyte abdecken. Auch zusätzliche Bytes müssen zur Zurückweisung führen, sonst bleiben Fehler beim Zusammensetzen von Datenpaketen auf der sendenden Seite unbemerkt. Als Test für nicht null gesetzte Füllbits eignet sich die Bitanzahl eins mit der Nutzlast 80: Damit lassen sich sowohl eine verwechselte Bitreihenfolge als auch eine fehlende Prüfung der Füllbits erkennen.
Legen Sie die Längengrenze anhand der in der Anwendung zulässigen Anzahl von Einträgen fest. Begrenzen Sie außerdem beim Empfang auf Anwendungsebene die Gesamtzahl der Bytes. Das Array, das der Beispield ecoder erhält, existiert bereits; der Decoder kann also nicht verhindern, dass die Netzwerkschicht zuvor eine übergroße Antwort vollständig eingelesen hat. Beim Streaming-Empfang ist zusätzlich ein eigenes Budget für die kumulierte Byteanzahl erforderlich.
Reservieren Sie nicht zuerst Speicher anhand der Länge aus dem Header, um erst danach die tatsächliche Nutzlast zu prüfen. Verwenden Sie bei beliebigen externen Ganzzahlwerten auch nicht die Formel „Länge plus sieben, geteilt durch acht“. Hier wird zunächst die Länge begrenzt und anschließend mit Division und Rest gerechnet. Auch die Reihenfolge der Prüfungen gehört zum Testvertrag.
Bei Regressionstests vergleichbare Nachweise erfassen
Legen Sie das Skript im Testverzeichnis des Repositorys ab und lassen Sie damit die Anwendungsimplementierung aus demselben Commit prüfen. Protokollieren Sie die Commit-Kennung, die Ausgabe von xcrun swift --version und den Namen des fehlgeschlagenen Testvektors. Zeichnen Sie weder den tatsächlichen Lesestatus von Nutzern noch vollständige Anwendungsnutzlasten auf.
Jede Fehlerart sollte einen stabilen Namen erhalten, etwa für abgeschnittene Daten, ungültige Füllbits oder eine überschrittene Längengrenze. Fassen Sie nicht alle Fehler als Netzwerkfehler zusammen. Der einheitliche Fehler im Beispiel dient nur dazu, den Referenzcode kurz zu halten. Eine produktive Implementierung sollte diagnostisch aussagekräftige Fehlerkategorien beibehalten.
Führen Sie abschließend eine gezielte Fehlerprobe durch: Vertauschen Sie absichtlich die Bitreihenfolge innerhalb der Bytes und prüfen Sie, ob die festen Referenzvektoren fehlschlagen. Entfernen Sie anschließend die Prüfung der Füllbits und stellen Sie sicher, dass die Tests ungültige Füllbits erkennen. Erst wenn diese Fehler die Aufgabe tatsächlich scheitern lassen, sichern die Regressionstests auf dem Cloud-Mac die Formatgrenzen ab, statt lediglich einen weiteren erfolgreichen Roundtrip zu wiederholen.
Häufig gestellte Fragen
Warum genügt ein Roundtrip-Test nicht?
Encoder und Decoder können denselben Fehler bei der Bitreihenfolge machen. Handberechnete Bytevektoren prüfen deshalb das tatsächliche Format unabhängig von beiden Implementierungen.
Was passiert mit ungenutzten Bits im letzten Byte?
Im gezeigten Format müssen die ungenutzten höherwertigen Bits null sein. Der Decoder weist andere Werte zurück und erzwingt damit eine kanonische Darstellung.
Mieten Sie einen Mac mini passend zum Projektrhythmus
Wählen Sie einen exklusiven physischen Server und zahlen Sie tage-, wochen-, monats- oder quartalsweise. In der Hochsaison können Sie zusätzliche Laufzeit buchen. Läuft die Mietdauer ab, endet der Dienst automatisch. Maßgeblich ist der Verfügbarkeitsstatus, den die Konsole in Echtzeit anzeigt.