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
| Concept | Context | Why it matters | Common confusion |
|---|---|---|---|
| Code coverage | % of lines executed by tests | Identifies untested code | Treating it as a quality metric (it isn’t) |
| Branch coverage | % of if/switch branches | Stronger signal than line coverage | Most tools report line only |
xccov | Apple’s coverage CLI | Parse .xcresult for coverage data | Reading the Xcode UI manually |
| SwiftLint | Style + simple bug linter | Catches dozens of issues pre-review | Adding too many rules → review-cycle paralysis |
--strict | SwiftLint flag turning warnings into errors | Failures gate CI | Without it, warnings ignored forever |
| Custom rules | Project-specific regex linters | Encode your team’s conventions | Over-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 state | Recommended gate |
|---|---|
| Greenfield | 80% on changed lines, 70% project-wide |
| Legacy w/ low coverage | 70% on changed lines, no project minimum |
| Pure infrastructure (CLI, libraries) | 90% on changed lines |
| UI-heavy app | 60% 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
xccovoutput, posts PR comments with coverage diffs.
Common misconceptions
- “Higher coverage = fewer bugs.” Loose correlation, no causation. A project at 90% coverage with shallow assertions can be buggier than 60% with strong ones.
- “100% is the goal.” It’s a useless goal — the last 10% is usually generated code, unreachable branches, and trivial accessors.
- “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.
- “More lint rules = better code.” More rules = more PR cycles arguing about style. Pick the rules that prevent real damage; let the rest go.
- “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 --fixas a pre-commit hook (vialefthookorpre-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.