Testing iOS geofence distance, accuracy and hysteresis

Testing iOS geofence distance, accuracy and hysteresis

Testing iOS geofence distance, accuracy and hysteresis

A user stays near the edge of a store, yet the app keeps recording “entered, exited, entered.” Before changing the location update frequency, separate distance calculations, accuracy filtering and state transitions. Replay fixed samples on a cloud Mac to determine whether the business rules are oscillating or system events do not match the actual location.

These checks validate the app’s own geofencing policy; they do not replace system region monitoring. All coordinates are offline fixtures. No user location history is collected, and the tests do not depend on the remote machine’s location.

Define the entry and exit contract first

The example uses these rules: enter only when the distance from the center is no more than 80 meters; exit at 120 meters or more; retain the previous state between those thresholds. Horizontal location accuracy must be between 0 and 30 meters, inclusive, or the sample must not update the state. These values define a test policy, not universal product settings.

Also distinguish “no trustworthy sample” from “confirmed outside.” Start with an unknown state rather than defaulting to outside just to give the UI a value. Reject negative accuracy values, non-finite numbers and out-of-range coordinates.

Horizontal accuracy describes measurement quality, not the user’s actual displacement. Do not simply subtract it from the distance and then claim the user must be inside the geofence.

Isolate the distance calculation in a package

First, create a test package in a regular working directory on the cloud Mac:

mkdir GeoPolicy
cd GeoPolicy
swift package init --type library

Replace Sources/GeoPolicy/GeoPolicy.swift with the following implementation. The spherical distance is in meters, with angles converted to radians first. The trigonometric functions correctly handle longitude differences across the International Date Line; do not treat latitude and longitude differences as planar distances.

import Foundation

struct Point {
    let latitude: Double
    let longitude: Double

    var valid: Bool {
        latitude.isFinite && longitude.isFinite &&
        (-90...90).contains(latitude) &&
        (-180...180).contains(longitude)
    }
}

func meters(_ a: Point, _ b: Point) -> Double? {
    guard a.valid, b.valid else { return nil }
    let radians = Double.pi / 180
    let p1 = a.latitude * radians
    let p2 = b.latitude * radians
    let dp = (b.latitude - a.latitude) * radians
    let dl = (b.longitude - a.longitude) * radians
    let h = pow(sin(dp / 2), 2) +
        cos(p1) * cos(p2) * pow(sin(dl / 2), 2)
    return 6_371_000 * 2 * asin(sqrt(min(1, max(0, h))))
}

This spherical approximation is suitable for the policy tests in this article, not for survey-grade calculations. If production code uses a system distance API, the tests should call that same API. Do not validate only this helper while the app runs a different formula.

Prevent boundary oscillation with three states and two thresholds

Next, add the policy function. An unknown state remains unknown when a sample falls between the thresholds. If the state is already inside or outside, a sample in that band simply preserves it. Invalid measurements must not trigger state transitions or business side effects such as rewards for visiting a store.

enum FenceState: Equatable {
    case unknown, outside, inside
}

func nextState(
    previous: FenceState,
    distance: Double,
    accuracy: Double
) -> FenceState {
    guard distance.isFinite, distance >= 0,
          accuracy.isFinite, (0...30).contains(accuracy)
    else { return previous }

    if distance <= 80 { return .inside }
    if distance >= 120 { return .outside }
    return previous
}

When integrating with live location updates, validate sample timestamps first: a stale location must not overwrite a newer one. Put time validation in the adapter layer and inject the current time for testing, rather than reading the system clock directly in the pure policy logic. Under the API contract, zero accuracy is not a negative-value marker for an invalid measurement; whether to accept it needs a separate, explicit business rationale.

Test the boundaries without waiting for location updates

Replace Tests/GeoPolicyTests/GeoPolicyTests.swift with the tests below. State-boundary tests use distance values directly so that coordinate conversion errors cannot obscure whether a threshold is inclusive. Test the distance function separately.

import XCTest
@testable import GeoPolicy

final class GeoPolicyTests: XCTestCase {
    func testDistanceAcrossDateLine() throws {
        let a = Point(latitude: 0, longitude: 179.999)
        let b = Point(latitude: 0, longitude: -179.999)
        let distance = try XCTUnwrap(meters(a, b))
        XCTAssertEqual(distance, 222.39, accuracy: 0.5)
        XCTAssertEqual(
            distance,
            try XCTUnwrap(meters(b, a)),
            accuracy: 0.001
        )
        XCTAssertEqual(try XCTUnwrap(meters(a, a)), 0)
        XCTAssertNil(meters(
            Point(latitude: 91, longitude: 0), b
        ))
    }

    func testHysteresisAndRejectedFixes() {
        var state = FenceState.outside
        let inputs = [79.0, 85, 110, 119.9, 120, 100, 80]
        let expected: [FenceState] = [
            .inside, .inside, .inside, .inside,
            .outside, .outside, .inside
        ]
        for (distance, result) in zip(inputs, expected) {
            state = nextState(
                previous: state, distance: distance, accuracy: 10
            )
            XCTAssertEqual(state, result)
        }
        for accuracy in [-1.0, 30.01, .nan, .infinity] {
            XCTAssertEqual(nextState(
                previous: .outside, distance: 20, accuracy: accuracy
            ), .outside)
        }
        XCTAssertEqual(nextState(
            previous: .unknown, distance: 100, accuracy: 10
        ), .unknown)
        XCTAssertEqual(nextState(
            previous: .outside, distance: 80, accuracy: 30
        ), .inside)
    }
}

Run swift test. The 222.39 meters here comes from the example coordinates and the spherical formula, not from network measurements on a node or measurements taken on a device. Follow up with samples covering negative distances, infinite distances, polar coordinates and antipodal points. Express numerical error with explicit tolerances rather than comparing floating-point strings.

Connect policy tests to the app pipeline

Make the pure-logic tests a pre-merge check first. Then cover the location adapter layer in an iOS test target: verify that stale samples are discarded, how the state is presented after authorization is revoked, and whether invalid samples only produce a record of why they were rejected. Emit business notifications only when a valid state change occurs; repeated reports of the same internal state must not be counted again.

Keep only the sample sequence number, distance, accuracy, previous state, new state and rejection reason. Routine failure reports do not need complete real-world coordinates. Commit the fixed samples alongside the policy version, and review the expected results whenever the radii change.

Finally, run a separate acceptance check on a physical device. System geofence callbacks, background wake-ups and actual location errors are outside the scope of these offline tests. Record “policy passed” and “device behavior passed” separately; a single simulator run is not a substitute for both.

Frequently asked questions

Why use different entry and exit thresholds?

Location noise can move consecutive fixes across a single boundary repeatedly. A smaller entry radius and larger exit radius preserve the previous state between them.

Do these tests replace testing on a physical device?

No. They validate distance and business-state rules. System geofence events, background wakeups, authorization changes and real location quality still need device testing.

Give your build pipeline a dedicated cloud Mac

Get an entire physical machine, not a virtual machine. Choose a model and rental term to match your build volume, and keep archives, signing, and caches on your own node.

Choose a cloud Mac plan

Your next archive, built on a dedicated cloud Mac

Choose a machine for your build volume and a location for your team. Review the plan, then start handing over your toolchain.