Lab 8.3 — Full Test Suite
Goal: take a small data module (Notes) from zero tests to a production-grade suite: unit, snapshot, performance, plus SwiftLint and coverage gating in CI. Hit ≥ 80% coverage on changed code with meaningful assertions.
Time: 120–180 minutes.
Prereqs: Labs 8.1 + 8.2 completed (or equivalent comfort with XCTest, mocking, and CI).
Setup
- New iOS App → SwiftUI →
NotesTestSuiteLab. - Add dependencies via
File → Add Package Dependencies:https://github.com/pointfreeco/swift-snapshot-testing(version 1.17+)
- Add SwiftLint via SPM plugin: package
https://github.com/SimplyDanny/SwiftLintPlugins. AttachSwiftLintBuildToolPluginto the app target.
The module under test
Notes/Note.swift:
import Foundation
struct Note: Identifiable, Codable, Equatable, Sendable {
let id: UUID
var title: String
var body: String
var createdAt: Date
var tags: Set<String>
}
extension Note {
var preview: String {
body.split(separator: "\n").first.map(String.init) ?? ""
}
}
Notes/NoteStore.swift:
import Foundation
protocol Clock: Sendable { func now() -> Date }
struct SystemClock: Clock { func now() -> Date { Date() } }
protocol NotePersistence: Sendable {
func load() throws -> [Note]
func save(_ notes: [Note]) throws
}
@MainActor
@Observable
final class NoteStore {
private(set) var notes: [Note] = []
private let persistence: NotePersistence
private let clock: Clock
init(persistence: NotePersistence, clock: Clock = SystemClock()) {
self.persistence = persistence
self.clock = clock
}
func loadNotes() throws { notes = try persistence.load() }
@discardableResult
func create(title: String, body: String, tags: Set<String> = []) throws -> Note {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw NoteError.emptyTitle }
let note = Note(id: UUID(), title: trimmed, body: body, createdAt: clock.now(), tags: tags)
notes.append(note)
try persistence.save(notes)
return note
}
func delete(id: UUID) throws {
notes.removeAll { $0.id == id }
try persistence.save(notes)
}
func search(_ query: String) -> [Note] {
let q = query.lowercased()
guard !q.isEmpty else { return notes }
return notes.filter { note in
note.title.lowercased().contains(q) ||
note.body.lowercased().contains(q) ||
note.tags.contains { $0.lowercased().contains(q) }
}
}
}
enum NoteError: Error, Equatable { case emptyTitle }
Notes/NoteRow.swift:
import SwiftUI
struct NoteRow: View {
let note: Note
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(note.title)
.font(.headline)
.lineLimit(1)
Text(note.preview)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
if !note.tags.isEmpty {
HStack(spacing: 4) {
ForEach(Array(note.tags.sorted()), id: \.self) { tag in
Text(tag)
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(.tint.opacity(0.2), in: .capsule)
}
}
}
}
.padding(.vertical, 4)
}
}
Unit tests
NoteStoreTests.swift:
import XCTest
@testable import NotesTestSuiteLab
@MainActor
final class NoteStoreTests: XCTestCase {
private var persistence: InMemoryPersistence!
private var clock: FixedClock!
private var sut: NoteStore!
override func setUpWithError() throws {
try super.setUpWithError()
persistence = InMemoryPersistence()
clock = FixedClock(date: Date(timeIntervalSince1970: 1_700_000_000))
sut = NoteStore(persistence: persistence, clock: clock)
}
func test_create_appendsNote() throws {
try sut.create(title: "Hello", body: "World")
XCTAssertEqual(sut.notes.count, 1)
XCTAssertEqual(sut.notes[0].title, "Hello")
}
func test_create_trimsTitle() throws {
let note = try sut.create(title: " spaced ", body: "")
XCTAssertEqual(note.title, "spaced")
}
func test_create_emptyTitle_throws() {
XCTAssertThrowsError(try sut.create(title: " ", body: "x")) { error in
XCTAssertEqual(error as? NoteError, .emptyTitle)
}
XCTAssertTrue(sut.notes.isEmpty)
}
func test_create_usesInjectedClock() throws {
let note = try sut.create(title: "T", body: "")
XCTAssertEqual(note.createdAt, clock.fixed)
}
func test_create_persists() throws {
try sut.create(title: "T", body: "")
XCTAssertEqual(persistence.saved.last?.count, 1)
}
func test_loadNotes_restoresFromPersistence() throws {
persistence.stored = [Note(id: UUID(), title: "X", body: "",
createdAt: clock.now(), tags: [])]
try sut.loadNotes()
XCTAssertEqual(sut.notes.count, 1)
}
func test_delete_removesNote() throws {
let n = try sut.create(title: "T", body: "")
try sut.delete(id: n.id)
XCTAssertTrue(sut.notes.isEmpty)
}
func test_search_emptyQuery_returnsAll() throws {
try sut.create(title: "A", body: "")
try sut.create(title: "B", body: "")
XCTAssertEqual(sut.search("").count, 2)
}
func test_search_caseInsensitive_matchesTitle() throws {
try sut.create(title: "Swift Tips", body: "")
try sut.create(title: "Kotlin", body: "")
XCTAssertEqual(sut.search("SWIFT").count, 1)
}
func test_search_matchesTags() throws {
try sut.create(title: "T", body: "", tags: ["work"])
try sut.create(title: "U", body: "", tags: ["personal"])
XCTAssertEqual(sut.search("work").count, 1)
}
func test_preview_extractsFirstLine() {
let note = Note(id: UUID(), title: "T", body: "first\nsecond",
createdAt: .now, tags: [])
XCTAssertEqual(note.preview, "first")
}
}
// MARK: - Test doubles
final class InMemoryPersistence: NotePersistence {
var stored: [Note] = []
var saved: [[Note]] = []
func load() throws -> [Note] { stored }
func save(_ notes: [Note]) throws { saved.append(notes); stored = notes }
}
struct FixedClock: Clock {
let fixed: Date
init(date: Date) { self.fixed = date }
func now() -> Date { fixed }
}
Run. All GREEN. Open the Coverage tab — NoteStore should be ≥ 95%.
Snapshot tests
NoteRowSnapshotTests.swift:
import XCTest
import SnapshotTesting
import SwiftUI
@testable import NotesTestSuiteLab
final class NoteRowSnapshotTests: XCTestCase {
// Flip to true once, run, commit __Snapshots__/ folder, flip back to false.
let isRecordingMode = false
override func invokeTest() {
withSnapshotTesting(record: isRecordingMode ? .all : .never) {
super.invokeTest()
}
}
private func host<V: View>(_ view: V, width: CGFloat = 375) -> some View {
view.frame(width: width).background(.background)
}
func test_row_simple() {
let note = Note(id: UUID(), title: "Meeting notes",
body: "Discuss roadmap\nNext steps", createdAt: .now, tags: [])
assertSnapshot(of: host(NoteRow(note: note)), as: .image(layout: .sizeThatFits))
}
func test_row_withTags() {
let note = Note(id: UUID(), title: "Tagged",
body: "Body", createdAt: .now,
tags: ["work", "urgent", "q4"])
assertSnapshot(of: host(NoteRow(note: note)), as: .image(layout: .sizeThatFits))
}
func test_row_longTitle_truncates() {
let note = Note(id: UUID(),
title: "This is a very long title that should truncate after one line because we set lineLimit(1)",
body: "x", createdAt: .now, tags: [])
assertSnapshot(of: host(NoteRow(note: note)), as: .image(layout: .sizeThatFits))
}
}
Flip isRecordingMode = true, run once, commit __Snapshots__/, flip back.
Performance test
NoteStorePerformanceTests.swift:
import XCTest
@testable import NotesTestSuiteLab
@MainActor
final class NoteStorePerformanceTests: XCTestCase {
func test_search_10kNotes_performance() throws {
let persistence = InMemoryPersistence()
let store = NoteStore(persistence: persistence)
for i in 0..<10_000 {
_ = try store.create(title: "Note \(i)", body: "body \(i)", tags: ["tag\(i % 50)"])
}
measure(metrics: [XCTClockMetric()]) {
_ = store.search("tag5")
}
}
}
Set the baseline once you have a representative number. CI gates regressions over 10%.
SwiftLint config
.swiftlint.yml:
included:
- NotesTestSuiteLab
- NotesTestSuiteLabTests
opt_in_rules:
- force_unwrapping
- empty_count
- first_where
- sorted_imports
disabled_rules:
- todo
force_unwrapping: error
force_cast: error
force_try: error
Build the app. SwiftLint runs as a build phase via the plugin.
CI pipeline
.github/workflows/test.yml:
name: Test
on: [pull_request]
jobs:
test:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '16.0'
- name: Run tests with coverage
run: |
xcodebuild test \
-scheme NotesTestSuiteLab \
-destination 'platform=iOS Simulator,name=iPhone 15,OS=18.0' \
-enableCodeCoverage YES \
-resultBundlePath build/result.xcresult \
| xcpretty
- name: Generate coverage report
run: |
xcrun xccov view --report --json build/result.xcresult > coverage.json
- name: Coverage gate
run: |
COVERAGE=$(jq '.targets[] | select(.name=="NotesTestSuiteLab.app") | .lineCoverage' coverage.json)
THRESHOLD=0.80
echo "Coverage: $COVERAGE (gate: $THRESHOLD)"
awk -v c="$COVERAGE" -v t="$THRESHOLD" 'BEGIN { exit (c < t) }'
- name: SwiftLint
run: |
brew install swiftlint
swiftlint --strict --reporter github-actions-logging
Commit. Open a PR. Watch CI: tests run, coverage extracted, gate enforced at 80%, lint runs strict.
Stretch
- Branch coverage — switch
-enableCodeCoverage YESanalysis to usexcrun xccov view --report --has-branch-coverageand report branch coverage in CI. - Codecov integration — sign up at codecov.io, add the token as a GitHub secret, replace the inline gate with
codecov/codecov-action@v4and configure status checks on changed-lines-only. - Mutation testing — install
muter; run it onNoteStore.swiftand improve the suite until the mutation score is ≥ 70%. - Parallel UI tests — add a UI test target with the login pattern from Lab 8.2; configure parallel execution with 4 simulator clones.
- PR coverage comment — write a tiny script that diffs
coverage.jsonbetweenmainand your branch, posts the delta as a PR comment.
Notes
withSnapshotTesting(record:)is the modern API inswift-snapshot-testing1.16+. Older guides reference a globalisRecording— both work.- The performance test will fail the first time (no baseline). Right-click the gray diamond → “Set Baseline” once you’re satisfied.
- SwiftLint’s SPM plugin requires Xcode to grant the plugin permission on first build — accept the prompt.
- CI runtime should be under 4 minutes for the full suite. If it’s longer, profile and split into matrix jobs.
Phase 8 complete. Phase 9 (Security & Secure Coding) starts with OWASP Mobile Top 10, Keychain, biometrics, and TLS pinning.