Интеграционное тестирование Swift с Docker: продвинутый уровень

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение

Интеграционное тестирование — это критически важный этап разработки, который проверяет взаимодействие компонентов вашего Swift-приложения с внешними зависимостями: базами данных, API, файловыми системами и сетевыми сервисами. Однако традиционные подходы к интеграционному тестированию на macOS/iOS часто страдают от "проблемы окружения" — тесты, работающие на машине разработчика, могут падать на CI-сервере из-за различий в версиях библиотек, настройках сети или конфигурации БД.

Docker решает эту проблему кардинально: вы упаковываете все зависимости (PostgreSQL, Redis, Mock-серверы) в изолированные контейнеры, которые запускаются идентично на любой платформе. В этой статье мы рассмотрим продвинутые техники интеграционного тестирования Swift с Docker: от настройки тестового окружения до параллельного запуска и интеграции с CI/CD.

1. Подготовка тестового окружения с Docker Compose

Начнем с создания изолированного окружения для тестов. Вместо того чтобы запускать каждый сервис вручную, используем docker-compose.yml для декларативного описания всех зависимостей.

Пример docker-compose.yml для Swift-проекта

version: '3.8'
services:
  postgres_test:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: test_user
      POSTGRES_PASSWORD: test_pass
      POSTGRES_DB: swift_test_db
    ports:
      - "5433:5432"  # Используем нестандартный порт, чтобы не конфликтовать с локальной БД
    tmpfs: /var/lib/postgresql/data  # Данные в памяти — быстрое очищение

  redis_test:
    image: redis:7-alpine
    ports:
      - "6380:6379"

  mock_server:
    build: ./mock-server  # Кастомный mock-сервер на Vapor
    ports:
      - "8081:8080"
    environment:
      LOG_LEVEL: debug

Swift-код для динамической конфигурации тестов

Чтобы тесты подключались к контейнерам Docker, а не к локальным сервисам, используем переменные окружения:

import XCTest

final class IntegrationTests: XCTestCase {
    var app: XCTestApplication?

    override func setUp() async throws {
        // Получаем конфигурацию из переменных окружения (устанавливаются в CI или docker-compose)
        let dbHost = ProcessInfo.processInfo.environment["DB_HOST"] ?? "localhost"
        let dbPort = Int(ProcessInfo.processInfo.environment["DB_PORT"] ?? "5433") ?? 5433
        let redisHost = ProcessInfo.processInfo.environment["REDIS_HOST"] ?? "localhost"
        let mockHost = ProcessInfo.processInfo.environment["MOCK_HOST"] ?? "http://localhost:8081"

        // Конфигурируем тестовое приложение (например, Vapor или Kitura)
        let config = AppConfig(
            database: DatabaseConfig(host: dbHost, port: dbPort, 
                                     username: "test_user", password: "test_pass",
                                     database: "swift_test_db"),
            redis: RedisConfig(host: redisHost, port: 6380),
            mockServiceURL: mockHost
        )

        app = try await XCTestApplication.start(config: config)
    }

    override func tearDown() async throws {
        try await app?.shutdown()
    }
}

2. Продвинутая настройка: параллельный запуск и изоляция тестов

Одна из главных проблем интеграционных тестов — их время выполнения. Docker позволяет запускать несколько копий тестового окружения параллельно, что значительно ускоряет прогон.

Стратегия изоляции: один контейнер на тестовый класс

Используем docker-compose --project-name для создания уникальных инстансов:

#!/bin/bash
# Запуск тестов с уникальным project-name для параллельного выполнения
TEST_SUITE=$1
PROJECT_NAME="swift_test_${TEST_SUITE}_$(date +%s)"

docker-compose -p $PROJECT_NAME up -d --force-recreate
sleep 5  # Ждем инициализации контейнеров

# Запускаем конкретный тестовый класс
xcodebuild test -scheme MyApp -destination 'platform=macOS' \
  -only-testing:MyAppTests/$TEST_SUITE \
  TEST_DB_PORT=5433 TEST_REDIS_PORT=6380

# Очистка
docker-compose -p $PROJECT_NAME down -v

Swift-код для параллельного запуска

import XCTest

class UserServiceTests: IntegrationTestCase {
    // Каждый тестовый класс получает свои порты через переменные окружения
    override class func setUp() {
        // Генерируем уникальные порты для изоляции
        let basePort = Int.random(in: 5400..<5500)
        setenv("DB_PORT", "\(basePort)", 1)
        setenv("REDIS_PORT", "\(basePort + 1)", 1)
        super.setUp()
    }

    func testUserCreation() async throws {
        let user = try await UserService.create(name: "Alice", email: "alice@test.com")
        XCTAssertNotNil(user.id)
    }

    func testUserFetch() async throws {
        let users = try await UserService.fetchAll()
        XCTAssertGreaterThan(users.count, 0)
    }
}

3. Mock-сервисы и контрактное тестирование

В реальном проекте вы часто зависите от внешних API (платежные системы, погода, соцсети). Вместо того чтобы тестировать против продакшн-серверов, создаем mock-сервисы в Docker, которые эмулируют поведение реальных API.

Создание Mock-сервера на Vapor

// MockServer/main.swift
import Vapor

let app = try Application(.detect())
defer { app.shutdown() }

// Эмуляция API погоды
app.get("weather") { req -> WeatherResponse in
    return WeatherResponse(temperature: 22.5, condition: "sunny")
}

// Эмуляция платежного шлюза
app.post("payment") { req -> PaymentResponse in
    let payment = try req.content.decode(PaymentRequest.self)
    guard payment.amount > 0 else {
        throw Abort(.badRequest, reason: "Invalid amount")
    }
    return PaymentResponse(status: "success", transactionId: UUID().uuidString)
}

try app.run()

Dockerfile для mock-сервера

FROM swift:5.9-jammy as build
WORKDIR /app
COPY . .
RUN swift build -c release

FROM ubuntu:jammy
WORKDIR /app
COPY --from=build /app/.build/release/MockServer .
EXPOSE 8080
CMD ["./MockServer"]

Тест с проверкой контракта

func testPaymentIntegration() async throws {
    let mockURL = ProcessInfo.processInfo.environment["MOCK_HOST"] ?? "http://localhost:8081"
    let client = HTTPClient(eventLoopGroup: MultiThreadedEventLoopGroup(numberOfThreads:                

Блоги

Рекомендаций по книгам