Тестируем геозоны iOS: расстояние, точность и гистерезис
Пользователь стоит у границы магазина, а приложение раз за разом регистрирует «вход, выход, вход». Не спешите менять частоту определения местоположения. Сначала разделите расчёт расстояния, фильтрацию по точности и переходы между состояниями, а затем воспроизведите фиксированный набор образцов на облачном Mac. Так можно понять, что именно происходит: колеблется результат бизнес-правил или системные события не соответствуют фактическому положению пользователя.
Здесь мы проверяем собственную логику геозон приложения, а не заменяем системный мониторинг регионов. Все проверки используют заранее подготовленные координаты: собирать маршруты пользователей не нужно, а местоположение удалённой машины ни на что не влияет.
Сначала задайте правила входа и выхода
В нашем примере вход фиксируется, только если расстояние до центра не превышает 80 метров, а выход — когда оно достигает 120 метров. В промежуточной полосе сохраняется прежнее состояние. Горизонтальная точность должна быть в диапазоне от 0 до 30 метров, иначе состояние по этому измерению не обновляется. Эти числа задают тестовую стратегию, а не универсальные параметры продукта.
Важно различать «нет достоверного измерения» и «точно снаружи». При первом запуске состояние должно быть неизвестным: нельзя по умолчанию считать пользователя вышедшим только ради того, чтобы показать какое-то значение в интерфейсе. Отрицательную точность, нечисловые и бесконечные значения, а также координаты за допустимыми пределами нужно отклонять.
Горизонтальная точность характеризует качество измерения, а не фактическое смещение пользователя. Не вычитайте её напрямую из расстояния, чтобы затем утверждать, что пользователь точно находится внутри геозоны.
Зафиксируйте расчёт расстояния в отдельном пакете
Сначала создайте тестовый пакет в обычном рабочем каталоге на облачном Mac:
mkdir GeoPolicy
cd GeoPolicy
swift package init --type library
Замените содержимое Sources/GeoPolicy/GeoPolicy.swift следующей реализацией. Расстояние на сфере выражается в метрах, а углы сначала переводятся в радианы. Тригонометрические функции корректно обрабатывают разность долгот при пересечении линии перемены дат; нельзя просто считать разность широт и долгот расстоянием на плоскости.
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))))
}
Это сферическое приближение, подходящее для проверки логики в этой статье, а не алгоритм геодезической точности. Если рабочий код использует системный API расчёта расстояния, тесты должны вызывать тот же API. Не ограничивайтесь проверкой вспомогательной реализации, если в приложении применяется другая формула.
Устраните дребезг на границе с помощью трёх состояний и двух порогов
Теперь добавьте функцию принятия решения. Если состояние неизвестно и образец попадает в промежуточную полосу, состояние остаётся неизвестным. Если уже известно, что пользователь внутри или снаружи, промежуточная полоса лишь сохраняет прежнее значение. Некорректное измерение не вызывает перехода состояния и не должно запускать побочные бизнес-действия, например начисление бонуса за посещение магазина.
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
}
При интеграции сначала проверяйте время измерения: устаревшая позиция не должна перезаписывать более свежую. Проверку времени разместите в слое адаптации и передавайте текущее время как зависимость для тестирования, а не считывайте системные часы прямо в чистой логике принятия решений. По контракту API нулевая точность не относится к отрицательным значениям, обозначающим недействительное измерение; принимать ли её, должно определяться отдельным явным бизнес-правилом.
Проверьте границы без ожидания геолокации
Замените содержимое Tests/GeoPolicyTests/GeoPolicyTests.swift следующими тестами. Границы переходов проверяются непосредственно значениями расстояния: так погрешность пересчёта координат не скроет смысл условий с равенством. Функция расстояния проверяется отдельно.
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)
}
}
Запустите swift test. Значение 222.39 метра здесь получено из координат примера по сферической формуле, а не из измерений сети узла или устройства. Затем добавьте образцы с отрицательным и бесконечным расстоянием, координатами в полярных областях и антиподальными точками. Численную погрешность задавайте явным допуском, а не сравнением строковых представлений чисел с плавающей точкой.
Включите проверку правил в конвейер приложения
Сначала сделайте тесты чистой логики обязательной проверкой перед слиянием изменений, затем покройте слой адаптации геолокации в тестовом таргете iOS: отбрасываются ли старые измерения, как отображается состояние после отзыва разрешения и ограничивается ли обработка некорректного измерения записью причины отклонения. Бизнес-уведомления отправляйте только при изменении состояния по допустимому измерению; повторное получение того же внутреннего состояния не должно приводить к повторным начислениям.
Достаточно сохранять порядковый номер образца, расстояние, точность, старое и новое состояния, а также причину отклонения. В обычных отчётах о сбоях полные реальные координаты не нужны. Фиксированные образцы храните в репозитории вместе с версией правил, а при изменении радиусов одновременно пересматривайте ожидаемые результаты.
В завершение отдельно проведите приёмочные испытания на реальном устройстве: системные обратные вызовы геозон, пробуждение в фоне и фактическая погрешность геолокации не входят в область этих офлайн-тестов. В результатах отдельно отмечайте «правила проверены» и «поведение устройства проверено». Один запуск в симуляторе не заменяет обе проверки.
Часто задаваемые вопросы
Почему одного порога расстояния недостаточно?
Из-за погрешности координаты могут постоянно пересекать один порог. Меньший порог входа и больший порог выхода сохраняют прежнее состояние между ними.
Заменяют ли эти тесты проверку на устройстве?
Нет. Они проверяют бизнес-логику. Системные события геозон, фоновое пробуждение, разрешения и реальную точность нужно проверять на устройстве.
Выделенный облачный Mac для сборок
Вся физическая машина — только в вашем распоряжении, без виртуализации. Выберите модель и срок аренды под объём сборок, а архивы, подписи и кэш храните на своём узле.
Выбрать облачный Mac