JavaRush /Курсы /Swift SELF /@testable import и границы модулей

@testable import и границы модулей

Swift SELF
52 уровень , 1 лекция
Открыта

1. Зачем тестам знать про модули

Когда вы впервые пишете тесты, мозг ожидает наивную картину мира: «Я же в том же проекте! Значит, тесты должны видеть всё». И вот вы пишете тест, пытаетесь вызвать какой‑то метод — а компилятор отвечает холодным «не вижу». В этот момент кажется, что Swift просто вредничает. На самом деле он делает ровно то, что должен: защищает границы модулей и ваш будущий публичный API.

Представьте, что ваш проект — это квартира, а модули — комнаты. public — это дверь на улицу, internal — это двери между комнатами, а private — это ящик стола. Тесты по умолчанию стоят не «в комнате», а как будто в коридоре общего пользования. И пока вы не выдали им «служебный пропуск», они не могут лазить по вашим internal вещам.

Модули в SwiftPM и тестовые targets

Если говорить без академизма, то в SwiftPM target (цель сборки) обычно становится модулем, который можно импортировать через import ModuleName. У вас в курсе это особенно заметно после разделения проекта на несколько targets: LibraryCLI (исполняемый), Domain, Storage, Networking и так далее.

Тесты — это тоже target (test target). А значит, тесты компилируются как отдельный модуль. И это не прихоть SwiftPM, а фундаментальная идея: тесты проверяют ваш код примерно так же, как его будет использовать внешний клиент. Поэтому тесты обязаны уважать границы видимости. Это тот самый момент, когда «дисциплина сегодня» экономит вам «хаос завтра».

Наглядно удобно представлять структуру как маленький граф зависимостей:

flowchart LR
    Domain[Domain target<br/>модуль Domain]
    Storage[Storage target<br/>модуль Storage]
    CLI[LibraryCLI target<br/>модуль LibraryCLI]
    DomainTests[DomainTests target<br/>модуль DomainTests]
    CLITests[LibraryCLITests target<br/>модуль LibraryCLITests]

    CLI --> Domain
    CLI --> Storage

    DomainTests --> Domain
    CLITests --> CLI
    CLITests --> Domain

И ключевая мысль: граница «Sources vs Tests» — это настоящая граница модулей, а не просто «папки для удобства».

2. Уровни доступа и @testable import

Уровни доступа: что видно через границу модуля

Сейчас будет важный момент, который лучше один раз уложить в голове нормально, чем потом лечить «сделал всё public, потому что тесты не компилировались».

Внутри одного модуля вы можете пользоваться internal (он по умолчанию). Но снаружи модуля internal уже не виден. Именно поэтому тесты, как отдельный модуль, не видят internal по умолчанию.

Сведём это в таблицу (она реально помогает, когда вы отлаживаете «почему не импортится»):

Уровень доступа Видно внутри того же файла Видно внутри модуля (другие файлы) Видно из другого модуля (обычный import) Видно из другого модуля через @testable import
private
да нет нет нет
fileprivate
да нет (за пределами файла) нет нет
internal
да да нет да
public
да да да да

Про то, как именно ведут себя private и fileprivate (что это именно область видимости, а не «настроение компилятора»), Swift подробно формализовал правила ещё в ранних предложениях языка.

@testable import: «служебный пропуск» для internal

@testable import SomeModule — это специальный режим импорта, который используется в тестах. Его смысл простой: тестовый модуль получает право видеть internal объявления импортируемого модуля. В обсуждениях дизайна Swift это обычно формулируют как “tests have extra access permissions of the modules that they import for testing”.

Звучит как чит‑код, но на практике это очень удобно. Многие вещи в реальном проекте должны оставаться internal: помощники парсинга, нормализация строк, маленькие value‑object‑инициализаторы, тестовые фабрики, «склейки» алгоритмов. В проде внешнему пользователю это не нужно, но вам как разработчику (и тестам) — очень даже нужно.

Важно зафиксировать два ограничения, чтобы не было иллюзий.

Первое: @testable не превращает тесты в всемогущего демона. Он не делает private доступным. «Ящик стола» всё ещё закрыт.

Второе: @testable работает, когда модуль собран в режиме «с поддержкой тестирования». В SwiftPM при запуске swift test нужные флаги включаются автоматически, поэтому на уровне курса это обычно «просто работает». Но логика остаётся: тестируемый модуль должен быть собран так, чтобы вообще разрешить friend‑доступ.

И ещё один технический нюанс: из‑за того, что testable‑клиент может видеть internal, компилятор вынужден загружать зависимости модуля более «полно». Это прямо отмечается в правилах загрузки транзитивных зависимостей: если клиент делает @testable‑импорт, зависимости должны быть доступны, потому что internal может на них опираться.

4. Примеры на LibraryCLI

Давайте закрепим на стиле нашего курса: мы развиваем CLI‑приложение LibraryCLI, а внутри вы уже привыкли отделять домен (например, Domain) от CLI‑слоя.

Представим, что в Domain у нас есть парсер команды и внутренняя нормализация строки. Нормализация полезна для тестов, но пользователю доменного модуля (например, LibraryCLI) она как отдельная функция обычно не нужна.

Код модуля Domain

// Sources/Domain/CommandParser.swift
import Foundation

public enum Command: Equatable {
    case add(title: String)
}

public struct CommandParser {
    public init() {}

    public func parse(_ line: String) -> Command? {
        let normalized = normalize(line)
        let parts = normalized.split(separator: " ").map(String.init)
        guard parts.count == 2, parts[0] == "add" else { return nil }
        return .add(title: parts[1])
    }

    internal func normalize(_ line: String) -> String {
        line.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
    }
}

Обратите внимание на тонкость: parse — публичный метод (это часть контракта модуля Domain). А normalizeinternal, потому что это деталь реализации, которую мы не хотим обещать внешним клиентам как «вечную».

Тест без @testable: почему internal «не видно»

Сейчас сделаем типичную ошибку новичка: попробуем протестировать normalize из тестов «обычным» импортом.

import XCTest
import Domain

final class CommandParserTests: XCTestCase {
    func testNormalize_trimsAndLowercases() {
        let sut = CommandParser()
        let result = sut.normalize("  ADD  ") // <- error: 'normalize' is inaccessible
        XCTAssertEqual(result, "add")
    }
}

И это корректное поведение. Тесты — отдельный модуль, и «обычный» клиент модуля Domain не должен иметь доступ к internal.

Тот же тест с @testable import: доступ к internal без public

Теперь включаем «служебный пропуск».

import XCTest
@testable import Domain

final class CommandParserTests: XCTestCase {
    func testNormalize_trimsAndLowercases() {
        // Arrange
        let sut = CommandParser()

        // Act
        let result = sut.normalize("  ADD  ")

        // Assert
        XCTAssertEqual(result, "add")
    }
}

Здесь произошла важная вещь: мы не раздули публичный API модуля Domain ради тестов. Мы сохранили инкапсуляцию, но дали тестам ровно тот уровень доступа, который нужен.

5. Как не злоупотреблять @testable

На этом месте очень легко пойти по опасному пути: раз тесты могут видеть больше, давайте тестировать вообще всё подряд — внутренние шаги, промежуточные переменные, приватные функции, микросостояния.

Проблема в том, что тесты тогда начинают проверять не поведение, а реализацию. И при первом нормальном рефакторинге (когда вы улучшаете код, но сохраняете внешнее поведение) тесты начинают падать, как домино. И вы злитесь не на тесты — вы злитесь на себя из прошлого, который «прибил гвоздями» детали реализации.

Хорошее практическое правило такое: @testable уместен, когда вы хотите проверить важное внутреннее правило, которое сложно, дорого или неочевидно проверять через публичный API. Но если проверка легко делается через публичный метод — лучше тестировать публичный метод. Тогда вы фиксируете именно контракт.

Например, для CommandParser чаще всего достаточно тестировать parse, потому что нормализация — часть поведения parse.

Тестируем публичное поведение parse

import XCTest
import Domain

final class ParseTests: XCTestCase {
    func testParse_acceptsMessyInput() {
        let sut = CommandParser()
        let result = sut.parse("   ADD   milk   ")
        XCTAssertEqual(result, .add(title: "milk"))
    }
}

Этот тест устойчивее. Даже если завтра вы перепишете normalize или вообще удалите её, тест останется валиден, пока parse ведёт себя так же.

Что @testable не открывает: private и fileprivate

Очень хочется сказать: «ну тогда сделаю всё private и буду тестировать через @testable». Но нет: private и fileprivate остаются в силе, потому что они защищают детали реализации даже внутри модуля (или внутри файла). И это логично: иначе смысл private превращается в «ну это такое, пожелание».

Сделаем короткий пример: внутри Domain есть private помощник — его не увидеть даже из тестов.

// Sources/Domain/Helpers.swift
import Foundation

public struct Slugifier {
    public init() {}

    public func makeSlug(_ s: String) -> String {
        cleanup(s).replacingOccurrences(of: " ", with: "-")
    }

    private func cleanup(_ s: String) -> String {
        s.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
    }
}

Тесты могут тестировать makeSlug, но не должны иметь возможности дергать cleanup напрямую. И это хорошо: вы оставили себе свободу менять «как именно чистим строку» без риска, что тесты начнут зависеть от микродеталей.

6. Практика SwiftPM: имена модулей и зависимости

Имя модуля в import — это имя target

Когда вы пишете @testable import ..., вы должны импортировать модуль, то есть target. Не папку, не package name, не «как называется репозиторий на GitHub».

Если у вас есть:

  • Package.swift с пакетом LibraryCLI,
  • targets: LibraryCLI (executable), Domain (library), Storage (library),
  • test target: DomainTests,

то в тестах домена вы обычно пишете @testable import Domain, а не @testable import LibraryCLI.

А если вы ошиблись, то компилятор честно скажет "No such module". Это не значит, что модуль «пропал». Это значит, что вы импортируете несуществующее имя или target не подключён как зависимость test target в Package.swift.

Почему тесты иногда требуют больше зависимостей

Это можно воспринимать как любопытный факт, но он хорошо объясняет некоторые «странные» ошибки сборки.

Когда модуль импортируется testable‑способом, тесты получают доступ к internal. А internal код, в свою очередь, может использовать зависимости, которые публичному клиенту не нужны. Поэтому компилятор обязан быть готов загрузить больше вещей «на всякий случай». В правилах про загрузку транзитивных зависимостей прямо отмечено, что @testable‑импорт заставляет загрузить зависимости, потому что internal может на них опираться.

Для вас практический вывод простой: если у вас в Domain внутри internal кода используется какой‑то модуль, и тесты начинают «ругаться», что чего‑то не хватает, это часто сигнал, что зависимость должна быть корректно описана в SwiftPM и действительно доступна при сборке тестов.

7. Типичные ошибки при работе с @testable import и границами модулей

Ошибка №1: делать всё public, потому что «тесты не видят».
Это самая частая ловушка. Вы начинаете расширять публичный API не потому, что так нужно пользователям модуля, а потому что так удобно тестам. В результате контракт раздувается, и вы сами себе запрещаете нормальный рефакторинг. Почти всегда лучше оставить детали internal и, если действительно нужно, использовать @testable import для доступа к ним. Идея @testable как «дополнительных прав доступа для тестов» заложена в дизайн языка именно ради этого.

Ошибка №2: ожидать, что @testable откроет доступ к private/fileprivate.
Так не будет. private и fileprivate — это не «скрыто от внешних модулей», это «скрыто на уровне файла/лексической области видимости». Их смысл — защищать реализацию даже от соседних файлов. Правила приватности в Swift устроены именно как правила области видимости, а не «режимы для галочки».

Ошибка №3: тестировать внутренние шаги вместо поведения и получить хрупкие тесты.
Когда тест привязан к деталям реализации, любой рефакторинг превращается в бой с тестами: поведение не меняли, но тесты упали. В такой ситуации @testable становится «ломиком», и проект начинает жить в стиле «не трогай — сломаешь». Гораздо устойчивее тестировать публичный контракт, а @testable оставлять для точечных случаев, где это реально даёт пользу.

Ошибка №4: перепутать имя пакета и имя модуля в импорте.
@testable import импортирует модуль, то есть цель сборки. Если target называется Domain, импорт должен быть Domain. Если вы напишете имя пакета или репозитория, получите ошибку "No such module". В этот момент полезно открыть Package.swift и сверить именно targets: [ ... name: "..." ... ].

Ошибка №5: забыть, что @testable может влиять на требования к зависимостям при сборке.
Иногда проект собирается как приложение, но «падает» на тестах: testable‑импорт даёт доступ к internal, а там внезапно используются куски кода, которым нужны зависимости. В правилах сборки отмечается, что при @testable‑импорте зависимости должны быть доступны, потому что тесты могут обращаться к internal, который на них опирается.

1
Задача
Swift SELF, 52 уровень, 1 лекция
Недоступна
Приветствие библиотеки
Приветствие библиотеки
1
Задача
Swift SELF, 52 уровень, 1 лекция
Недоступна
Сумма из строки
Сумма из строки
1
Задача
Swift SELF, 52 уровень, 1 лекция
Недоступна
Границы модулей
Границы модулей
1
Задача
Swift SELF, 52 уровень, 1 лекция
Недоступна
Тень приватности
Тень приватности
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ