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

  1. New iOS App → SwiftUI → NotesTestSuiteLab.
  2. Add dependencies via File → Add Package Dependencies:
    • https://github.com/pointfreeco/swift-snapshot-testing (version 1.17+)
  3. Add SwiftLint via SPM plugin: package https://github.com/SimplyDanny/SwiftLintPlugins. Attach SwiftLintBuildToolPlugin to 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

  1. Branch coverage — switch -enableCodeCoverage YES analysis to use xcrun xccov view --report --has-branch-coverage and report branch coverage in CI.
  2. Codecov integration — sign up at codecov.io, add the token as a GitHub secret, replace the inline gate with codecov/codecov-action@v4 and configure status checks on changed-lines-only.
  3. Mutation testing — install muter; run it on NoteStore.swift and improve the suite until the mutation score is ≥ 70%.
  4. Parallel UI tests — add a UI test target with the login pattern from Lab 8.2; configure parallel execution with 4 simulator clones.
  5. PR coverage comment — write a tiny script that diffs coverage.json between main and your branch, posts the delta as a PR comment.

Notes

  • withSnapshotTesting(record:) is the modern API in swift-snapshot-testing 1.16+. Older guides reference a global isRecording — 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.