Чтобы тесты XCTest проходили как по отдельности, так и в составе набора, предоставьте каждому тесту собственную изменяемую фикстуру и внедрите ее в проверяемый код. Если синглтон должен оставаться общим, сбрасывайте его перед каждым тестом и исключите одновременный доступ на протяжении всего теста.
Это руководство посвящено одной задаче разработки на Mac: исправлению тестов вычисления макета в Xcode, которые проходят отдельно, но падают при совместном запуске. В примере используются только синтетические целочисленные значения ширины и локальные вычисления. Для него не нужны окно приложения, имитация сервиса, файловое хранилище или сетевое подключение.
Почему тест может пройти отдельно и упасть в составе набора?
При изолированном запуске тест может встретить синглтон в исходном состоянии. При запуске набора более ранний тест уже мог его изменить. Новая ссылка на этот синглтон по-прежнему указывает на тот же изменяемый объект.
Swift различает свойства экземпляра и свойства типа: у каждого экземпляра свои свойства экземпляра, тогда как свойство типа существует в единственном общем экземпляре. Хранимые свойства типа инициализируются при первом обращении и после этого сохраняются. Поэтому присваивание SpacingCalculator.shared свойству теста не создает новый экземпляр калькулятора. См. документацию проекта Swift о свойствах.
Рассмотрим синтетическую строку элементов с шириной [10, 20, 30]. Ее общая ширина — сумма ширин элементов и промежутков между ними. При промежутке по умолчанию 2 результат равен 64; при промежутке 8 — 76.
Предположим, один тест проверяет ширину по умолчанию, не настраивая калькулятор, а другой изменяет общий промежуток на 8. В следующей таблице показано последовательное выполнение; каждая строка начинается с нового калькулятора, у которого промежуток равен 2. Это иллюстративные вычисления, а не записанные результаты тестов Xcode.
| Выполнение | Общий калькулятор без сброса | Новый калькулятор для каждого теста |
|---|---|---|
| Тест значения по умолчанию отдельно | 64: проходит |
64: проходит |
| Тест увеличенного промежутка отдельно | 76: проходит |
76: проходит |
| Сначала значение по умолчанию, затем увеличенный промежуток | Оба проходят | Оба проходят |
| Сначала увеличенный промежуток, затем значение по умолчанию | Тест значения по умолчанию получает 76: падает |
Оба проходят |
Это дает полезный способ диагностики: выясните, какой тест изменяет состояние, используемое в падающей проверке. Переименование тестов ради удобного порядка выполнения не устраняет зависимость.
Какой метод жизненного цикла XCTest должен отвечать за фикстуру?
Используйте метод настройки экземпляра для изменяемого состояния, необходимого каждому тесту. Apple указывает, что настройка экземпляра выполняется перед каждым методом теста, тогда как настройка на уровне класса — один раз перед классом тестов. Поэтому изменяемая фикстура, созданная при настройке класса, не получает новое исходное состояние перед каждым методом. См. раздел Настройка и очистка состояния в тестах.
Выбирайте метод настройки в соответствии с выполняемой работой: для синхронного создания фикстуры без ошибок подходит setUp(); для создания с возможным выбросом ошибки — setUpWithError(); для асинхронной подготовки — setUp() async throws. Очистка должна завершать текущий тест. Apple прямо советует не полагаться на очистку при подготовке последующих тестов, поскольку сбой может помешать ее выполнению. Эти границы описаны в той же документации о жизненном цикле.
Выбор фикстуры зависит и от того, кому она принадлежит, и от области выполнения:
| Ситуация | Подходящий вариант | Что проверить |
|---|---|---|
| Тесты могут создавать зависимость | Создавайте экземпляр и внедряйте его в каждый тест | Потребитель должен использовать внедренный экземпляр |
| Тесты должны проверять фактический синглтон | Устанавливайте полное исходное состояние перед каждым тестом | Ни один параллельно выполняемый тест не должен изменять этот синглтон |
| Общая конфигурация неизменяема | Повторно используйте неизменяемое значение | Ссылочные объекты также должны оставаться неизменными |
| Тест запускает асинхронную работу | Дождитесь ее завершения до конца теста | После этого не должно оставаться незавершенной работы, изменяющей фикстуру |
Для этого калькулятора проще всего использовать первый вариант. В рабочем коде можно оставить общий экземпляр по умолчанию, а в тестах явно выбирать независимые экземпляры.
Создайте фикстуру XCTest с независимым владельцем
Используйте Mac с Xcode и существующую цель модульных тестов macOS. Добавьте следующий полный пример как один файл Swift в эту цель тестов. Он содержит собственные калькулятор, потребитель и тесты, поэтому импортировать модуль приложения не нужно. Используйте инструментальную цепочку Swift 6, установив для цели изоляцию акторов по умолчанию nonisolated.
Калькулятор реализован как актор, чтобы явно обозначить границу конкурентного доступа к его изменяемому состоянию. Акторы Swift ограничивают доступ к такому состоянию, а вызовы извне актора используют await. Эти правила описаны в главе о конкурентности проекта Swift.
import XCTest
actor SpacingCalculator {
static let shared = SpacingCalculator()
private var gap = 2
func setGap(_ value: Int) {
precondition(value >= 0)
gap = value
}
func width(of itemWidths: [Int]) -> Int {
precondition(itemWidths.allSatisfy { $0 >= 0 })
let gapCount = max(itemWidths.count - 1, 0)
return itemWidths.reduce(0, +) + gapCount * gap
}
}
struct RowLayout {
let calculator: SpacingCalculator
init(calculator: SpacingCalculator = .shared) {
self.calculator = calculator
}
func width(of itemWidths: [Int]) async -> Int {
await calculator.width(of: itemWidths)
}
}
final class RowLayoutTests: XCTestCase {
private var calculator: SpacingCalculator?
override func setUp() {
super.setUp()
calculator = SpacingCalculator()
}
override func tearDown() {
calculator = nil
super.tearDown()
}
func testDefaultGap() async throws {
let fixture = try XCTUnwrap(calculator)
let layout = RowLayout(calculator: fixture)
let actual = await layout.width(of: [10, 20, 30])
XCTAssertEqual(actual, 64)
}
func testWideGap() async throws {
let fixture = try XCTUnwrap(calculator)
await fixture.setGap(8)
let layout = RowLayout(calculator: fixture)
let actual = await layout.width(of: [10, 20, 30])
XCTAssertEqual(actual, 76)
}
func testEmptyRow() async throws {
let fixture = try XCTUnwrap(calculator)
let layout = RowLayout(calculator: fixture)
let actual = await layout.width(of: [])
XCTAssertEqual(actual, 0)
}
func testSingleItem() async throws {
let fixture = try XCTUnwrap(calculator)
let layout = RowLayout(calculator: fixture)
let actual = await layout.width(of: [10])
XCTAssertEqual(actual, 10)
}
}
Ключевая строка — calculator = SpacingCalculator(). Каждая настройка создает отдельный актор с промежутком 2. Затем каждый тест передает этот актор в RowLayout. Вызов RowLayout() внутри этих тестов выбрал бы .shared и обошел фикстуру.
В примере намеренно разрешено прямое создание экземпляров. Если у существующего синглтона закрытый инициализатор, добавьте внутренний способ создания или выделите изменяемое состояние вычислений в отдельно создаваемую зависимость. Внутренний инициализатор можно предоставить цели тестов через принятую в модуле приложения схему импорта для тестирования; делать ссылку на синглтон глобально заменяемой не требуется.
В вычислениях также предусмотрены два полезных граничных случая: ширина пустой строки равна 0, а у одного элемента нет промежутков. Пример принимает неотрицательные целые числа, достаточно малые, чтобы избежать переполнения. Отрицательные значения нарушают предусловия и завершают выполнение, а не приводят к сбою проверки.
Ожидаемые результаты вычислений и оба варианта порядка двух тестов локально проверены детерминированными вычислениями на Python. Примеры Swift были проверены визуально, но не компилировались и не запускались, поскольку в этой исследовательской среде нет инструментальной цепочки Swift. Запустите их в Xcode, чтобы проверить компиляцию и выполнение для вашей цели.
Когда достаточно сбросить синглтон?
Сброс — приемлемый временный вариант, если тестам действительно нужен общий экземпляр и можно восстановить все его видимое тестам состояние. Устанавливайте исходное состояние перед каждым тестом, включая значения, которые тест считает значениями по умолчанию. Сброс только свойства, измененного в текущем падающем тесте, может скрыть другие неучтенные зависимости.
Для этого калькулятора асинхронная настройка могла бы выбрать SpacingCalculator.shared и перед каждым методом ожидать setGap(2). Это установит исходные арифметические параметры только при условии, что до проверки другой параллельный вызов не изменит промежуток. Если позднее в фикстуре появятся кэшированные результаты или дополнительные настройки, операция восстановления исходного состояния должна охватывать и их.
Актор не дает тесту исключительного владения объектом на протяжении нескольких вызовов с await. Например, тест A может установить общий промежуток 2, тест B — 8, а затем тест A может вычислить ширину с использованием 8. Каждый вызов актора может выполниться корректно, но общее предположение теста окажется неверным. Это следует из описанных в документации Swift правил выполнения акторов и приостановки.
Прежде чем выбрать изоляцию со сбросом, определите все тесты и фоновые задачи, которые могут обратиться к общему объекту. Если вы не можете гарантировать исключительное использование во время подготовки, выполнения и очистки, внедряйте независимое состояние. Восстановление снимка после теста не помешает другим вызовам видеть временные изменения во время самого теста.
Как перенести фикстуру в Swift Testing?
Swift Testing создает отдельный экземпляр набора для каждой функции теста экземпляра. Поэтому свойство экземпляра, создающее калькулятор, обеспечивает ту же модель владения без переопределения методов настройки XCTest. Свойство, инициализируемое из .shared, все равно будет ссылаться на синглтон. Жизненный цикл экземпляров описан Apple в разделе Организация тестовых функций с помощью типов наборов.
Если определения SpacingCalculator и RowLayout, приведенные ранее, один раз добавить в ту же цель тестов, можно использовать следующий полноценный дополнительный набор Swift Testing. Для него нужна инструментальная цепочка с модулем Testing, например Xcode 16 или более поздняя версия. Изоляция акторов по умолчанию также должна быть nonisolated.
import Testing
struct RowLayoutSwiftTests {
let calculator = SpacingCalculator()
@Test func defaultGap() async {
let layout = RowLayout(calculator: calculator)
let actual = await layout.width(of: [10, 20, 30])
#expect(actual == 64)
}
@Test func wideGap() async {
await calculator.setGap(8)
let layout = RowLayout(calculator: calculator)
let actual = await layout.width(of: [10, 20, 30])
#expect(actual == 76)
}
}
Для более сложной подготовки Swift Testing поддерживает инициализаторы набора, в том числе асинхронные и выбрасывающие ошибки. В статье Apple Перенос теста с XCTest описан этот вариант замены методов настройки XCTest. Этой фикстуре не нужна явная очистка, поскольку она управляет только локальным состоянием вычислений и не запускает фоновую работу.
По умолчанию Swift Testing запускает тесты параллельно. Применение .serialized к набору обеспечивает последовательное выполнение его тестов и вложенных наборов, но не препятствует одновременному запуску независимых тестов. Применение этого трейта к обычной функции теста без параметризации ничего не меняет. Эти ограничения прямо описаны в разделе Последовательный или параллельный запуск тестов.
Поэтому применение отдельных аннотаций .serialized к двум наборам, зависящим от синглтона, не обеспечивает взаимное исключение между ними. Общий родительский набор, помеченный как последовательный, может координировать свои дочерние наборы при условии, что все конкурирующие пользователи входят в эту область. Независимые фикстуры избавляют эти тесты вычислений от необходимости такой координации.
Проверьте отдельные тесты и набор в Xcode
На протяжении всей проверки используйте одну и ту же схему, план тестирования и целевое устройство Mac. Запуск отдельных методов, наборов и активного плана тестирования описан Apple в разделе Запуск тестов и интерпретация результатов.
- Запустите каждый из четырех методов XCTest отдельно с помощью кнопки запуска на полях редактора. Ожидайте
64,76,0и10соответственно. - Запустите класс
RowLayoutTestsс помощью кнопки запуска рядом с его объявлением. Все четыре метода должны пройти вместе. - Выберите
Product > Test, чтобы запустить активный план тестирования. Убедитесь, что эти тесты действительно запускались, а не были пропущены или исключены. - Если вы добавили пример Swift Testing, также запустите отдельно и вместе обе его функции.
Если отдельные тесты проходят, но весь план завершается с ошибкой, проследите, какой объект использует падающий потребитель. Проверьте, не пропущено ли внедрение зависимости, не вызывает ли вспомогательная функция .shared и не продолжается ли асинхронная работа после завершения породившего ее теста. Если тест падает отдельно, сначала проверьте его входные данные и ожидаемый результат вычисления, прежде чем связывать сбой с порядком тестов в наборе.
В готовой фикстуре исходное состояние должно быть видно в пути ее создания в текущем тесте. Для этих вычислений макета каждый тест владеет отдельным калькулятором, явно передает его потребителю и ожидает завершения его работы. Поэтому ожидаемый результат не зависит от того, какой соседний тест запускался первым.
Источники
- Настройка и очистка состояния в тестах — Apple
- Свойства — «Язык программирования Swift», проект Swift
- Конкурентность — «Язык программирования Swift», проект Swift
- Организация тестовых функций с помощью типов наборов — Apple
- Перенос теста с XCTest — Apple
- Последовательный или параллельный запуск тестов — Apple
- Запуск тестов и интерпретация результатов — Apple