8.8 — Code Coverage & SwiftLint

Opening scenario

Two engineers join your team in the same week. One opens a PR with 600 lines of new code and 0 tests; CI is happy because there’s no coverage gate. The other opens a PR with 200 lines fixing a real bug; CI fails because SwiftLint flags a 5-line function as “too long.” The first PR ships; the second waits. Your tooling is rewarding the wrong behavior.

Coverage and lint are powerful — when configured to actually serve the team, and ruthless when they’re not.

Context taxonomy

ConceptContextWhy it mattersCommon confusion
Code coverage% of lines executed by testsIdentifies untested codeTreating it as a quality metric (it isn’t)
Branch coverage% of if/switch branchesStronger signal than line coverageMost tools report line only
xccovApple’s coverage CLIParse .xcresult for coverage dataReading the Xcode UI manually
SwiftLintStyle + simple bug linterCatches dozens of issues pre-reviewAdding too many rules → review-cycle paralysis
--strictSwiftLint flag turning warnings into errorsFailures gate CIWithout it, warnings ignored forever
Custom rulesProject-specific regex lintersEncode your team’s conventionsOver-customization → rules nobody understands

Concept → Why → How → Code

Concept: code coverage is the percentage of executable lines that any test exercises. SwiftLint statically analyzes source and flags violations of style rules and common bug patterns.

Why: coverage shows you where tests don’t reach. Lint catches a class of obvious issues (force unwraps, dead code, fragile patterns) before code review starts, freeing reviewers for the things linters can’t see.

How: enable coverage in the test scheme, parse xccov output in CI, gate PRs on a minimum threshold for changed lines only. Install SwiftLint via Homebrew or SPM plugin, configure .swiftlint.yml, run with --strict in CI.

Enabling coverage in Xcode

Edit Scheme → Test → Options → Code Coverage: Gather coverage for: all targets.

Run tests. In Xcode → Report Navigator → latest test run → Coverage tab. Drill into per-file, per-function coverage.

Coverage from the command line

xcodebuild test \
  -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 15' \
  -enableCodeCoverage YES \
  -resultBundlePath build/result.xcresult

xcrun xccov view --report --json build/result.xcresult > coverage.json

Parse coverage.json to extract per-file coverage and feed into a CI gate.

Code coverage on changed lines only

The trick that makes coverage gates work in practice:

# Get the lines changed in this PR
git diff --unified=0 origin/main...HEAD | parse-diff > changed-lines.json

# Intersect with xccov output
jq --slurpfile changed changed-lines.json \
   '.targets[] | .files[] | select(...)' coverage.json

Tools like Codecov, Coveralls, and SonarCloud do this for you and post a PR comment with “+12 lines added, 9 covered (75%).” Gate the PR on a threshold for changed lines, not whole-project coverage. This makes the metric actionable without forcing test backfill on legacy code.

SwiftLint setup

Install:

brew install swiftlint

Or via SPM plugin (Xcode 14+):

.target(
    name: "App",
    plugins: [.plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins")]
)

.swiftlint.yml at repo root:

included:
  - App
  - AppTests

excluded:
  - Pods
  - .build
  - DerivedData

disabled_rules:
  - todo                    # we use TODOs intentionally
  - line_length             # let formatter handle this

opt_in_rules:
  - empty_count
  - explicit_init
  - first_where
  - force_unwrapping        # 🔥 the killer rule
  - implicitly_unwrapped_optional
  - last_where
  - operator_usage_whitespace
  - sorted_imports
  - unused_import

analyzer_rules:
  - unused_declaration
  - unused_import

force_cast: error
force_try: error
force_unwrapping: error

type_body_length:
  warning: 300
  error: 500

function_body_length:
  warning: 50
  error: 100

cyclomatic_complexity:
  warning: 10
  error: 20

custom_rules:
  no_print_in_production:
    regex: '^\s*print\('
    message: 'Use os_log instead of print() in production code'
    severity: warning
    excluded: '.*Tests\.swift'

Running in CI

swiftlint --strict --reporter github-actions-logging

--strict promotes every warning to error → red CI on any violation. github-actions-logging formats output so violations show up as inline PR annotations.

// swiftlint:disable escape hatches

// swiftlint:disable:next force_unwrapping
let value = optional!

// swiftlint:disable force_cast
let result = thing as! SpecificType
// swiftlint:enable force_cast

Use sparingly. Every disable is a tiny crack; review them in PRs.

What coverage gates should look like

Project stateRecommended gate
Greenfield80% on changed lines, 70% project-wide
Legacy w/ low coverage70% on changed lines, no project minimum
Pure infrastructure (CLI, libraries)90% on changed lines
UI-heavy app60% on changed lines (Views are unrealistic to cover)

Never gate on absolute project coverage going up — engineers will write meaningless tests to pad the number.

In the wild

  • SwiftLint (Realm) — de facto standard. 18k+ stars.
  • SwiftFormat (Nick Lockwood) — code formatter, not a linter, but commonly paired.
  • Periphery — dead code analyzer. Finds unused classes/methods/imports.
  • Slather — coverage reporter that pre-dates xccov; many CIs still use it.
  • Codecov / Coveralls — SaaS that ingests xccov output, posts PR comments with coverage diffs.

Common misconceptions

  1. “Higher coverage = fewer bugs.” Loose correlation, no causation. A project at 90% coverage with shallow assertions can be buggier than 60% with strong ones.
  2. “100% is the goal.” It’s a useless goal — the last 10% is usually generated code, unreachable branches, and trivial accessors.
  3. “SwiftLint catches bugs.” It catches patterns associated with bugs. The killer rules (force_unwrap, force_cast, force_try) catch real crashes. Most style rules catch style only.
  4. “More lint rules = better code.” More rules = more PR cycles arguing about style. Pick the rules that prevent real damage; let the rest go.
  5. “Coverage is meaningless if you don’t have branch coverage.” Line coverage at 80% is still a better signal than no coverage at all.

Seasoned engineer’s take

Coverage and lint are guardrails, not quality measures. Use them to prevent specific failure modes — force unwraps in shipped code, totally untested business logic — not to assert quality. The teams that obsess over 90% coverage typically have brittle, mock-heavy tests. The teams with the right gates (force-unwrap forbidden, changed-line coverage ≥ 70%, function complexity ≤ 15) ship faster with fewer bugs.

[!TIP] Add swiftlint --fix as a pre-commit hook (via lefthook or pre-commit). Auto-fixes whitespace and trivial issues before they hit CI.

[!WARNING] Don’t enable all SwiftLint opt-in rules at once on a legacy codebase. You’ll create thousands of warnings, your team will mass-disable, and the linter loses all credibility. Enable rules one at a time, fix violations, then merge.

Interview corner

Junior — “What’s code coverage?” The percentage of your source lines (or branches) executed by your test suite. A line at 100% coverage was hit by at least one test. Coverage measures execution, not correctness — a covered line can still be wrong if the test asserts the wrong outcome.

Mid — “How do you stop coverage from being gamed?” Gate on changed lines only, not project totals. Engineers can’t pad the metric with trivial tests because the gate only checks code in this PR. Pair with code review for assertion quality — coverage tells you tests exist; review tells you they test the right thing.

Senior — “Roll out SwiftLint to a 500k-LOC legacy codebase. Plan?” Phase one: install with default config + --strict disabled. Generate the violation baseline. Phase two: enable only the killer rules (force_unwrap, force_cast, force_try) as errors; fix the violations across the codebase in dedicated PRs. Phase three: enable five high-value style rules as warnings; let teams fix opportunistically. Phase four: flip warnings to errors via --strict in CI; communicate the cutover date a week ahead. Phase five: introduce one new rule per quarter via the same workflow. Never enable a flood of rules at once — you’ll either drown the team or get them all disabled. I’d also consider Periphery in parallel for dead code; it tends to surface a startling amount in legacy projects.

Red flag — “We require 100% coverage on every PR.” That’s not a quality bar; that’s a recipe for tests-of-tests and resentment.

Lab preview

Lab 8.3 closes the phase with coverage and lint gates: you’ll configure .swiftlint.yml, set up a CI workflow that runs tests with coverage, and post the coverage report to your PR.


Phase 8 complete. Phase 9 (Security & Secure Coding) covers Keychain, biometrics, TLS pinning, OWASP Mobile Top 10, and pentest tooling.