Добавить объявление

Как писать стабильные тесты для Swift Concurrency кода

Тестирование многопоточного кода в Swift всегда было не без проблем. С появлением Swift Concurrency ситуация стала лучше, но не идеальной. Самые проблемные - плавающие тесты, которые то проходят, то падают без видимых причин. Корень проблемы в недетерминированном выполнении асинхронных операций, особенно при использовании Task {}. Сейчас разберем, как заставить такие тесты работать предсказуемо.

Почему обычные тесты с Task не работают:


Когда мы создаем Task внутри синхронного метода, тест не ждет его завершения:

// Плавающий тест
func testAsyncOperation() {
let service = DataService()
service.startProcessing() // Внутри создается Task

XCTAssertTrue(service.isCompleted) // Может упасть
}

Тест завершится раньше, чем Task выполнится, потому что Task {} запускает асинхронную операцию, которая живет своей жизнью.

Решение - абстракция над Task:


Ключевая идея - создать протокол TaskProvider, который инкапсулирует создание задач:

protocol TaskProvider {
func task(priority: TaskPriority?, operation: @escaping () async -> T) -> Task
}

struct DefaultTaskProvider: TaskProvider {
func task(priority: TaskPriority?, operation: @escaping () async -> T) -> Task {
Task(priority: priority, operation: operation)
}
}

// Мок для тестов
class MockTaskProvider: TaskProvider {
private var tasks: [Task] = []

func task(priority: TaskPriority?, operation: @escaping () async -> T) -> Task {
let task = Task(priority: priority) {
await operation()
}

tasks.append(task as! Task)
return task
}

// Ждем завершения всех созданных задач
func waitForAllTasks() async {
for task in tasks {
await task.value
}
}
}

Использование в коде:


Меняем зависимость в нашем сервисе:

class DataService {
private let taskProvider: TaskProvider

init(taskProvider: TaskProvider = DefaultTaskProvider()) {
self.taskProvider = taskProvider
}

func startProcessing() {
taskProvider.task(priority: .medium) {
// Долгая асинхронная операция
await self.processData()
}
}
}

Стабильный тест:


Теперь тест может дождаться выполнения всех задач:

func testAsyncOperation() async {
let mockProvider = MockTaskProvider()
let service = DataService(taskProvider: mockProvider)

service.startProcessing()
await mockProvider.waitForAllTasks() // Ждем завершения

XCTAssertTrue(service.isCompleted) // Стабильно проходит
}

Преимущества подхода:


  • Контроль приоритетов: можно проверять, что задачи создаются с правильными приоритетами.

  • Отслеживание создания: можно вести лог созданных задач для assertions.

  • Изоляция тестов: каждый тест работает с изолированным моком.

  • Поддержка detached задач: паттерн легко расширяется для Task.detached.

Важное замечание:


Этот подход не заменяет async/await тесты, а дополняет их. Для кода, который уже использует async функции, лучше тестировать через await. Но для legacy-кода или ситуаций, где Task создается внутри синхронных методов, этот паттерн незаменим.

Вывод:


Тестирование многопоточного кода требует особого подхода и абстракция TaskProvider предоставляет его. Она превращает плавающие тесты в детерминированные, давая полный контроль над выполнением асинхронных операций. Через инверсию зависимостей сохраняется чистота архитектуры, а случайные падения в CI/CD становятся историей.

Особенно ценен этот паттерн при работе с legacy-кодом, где синхронные и асинхронные вызовы соседствуют в процессе миграции на Swift Concurrency.
19.12.2025 13 535