Тестирование на Swift долгие годы держалось на трех китах: XCTest, сторонние библиотеки и собственная смекалка. Но на WWDC 24 Apple представила новый, современный фреймворк — Swift Testing, который предлагает концептуально новый подход к тестированию.

Меня зовут Кирилл Гусев. Я мобильный разработчик в ОК. В этой статье я расскажу о том, какие возможности предоставляет Swift Testing.

Начнем со знакомства: немного о Swift Testing

Swift Testing — новый фреймворк для юнит-тестирования от Apple, представленный на WWDC 24 и призванный заменить классический XCTest. Swift Testing имеет открытый исходный код и разработан с учетом современных возможностей Swift.

Фреймворк поддерживает макросы и Swift Concurrency, а также является кроссплатформенным решением — работает на всех платформах, где есть Swift (macOS, iOS, Linux, Windows), что критично для серверной разработки и создания универсальных инструментов.

Вместе с тем, у фреймворка пока остаются некоторые ограничения. Например, он не поддерживает UI-тесты, работу с Performance и Objective-C код. Но даже при этом Swift Testing стал достойной альтернативой XCTest — этому способствует набор новых фич и возможностей. Остановимся на них подробнее.

Новые фичи

Для начала познакомимся с новыми функциями, доступными во фреймворке.

@Test

В Swift Testing реализована поддержка аннотации @Test. Благодаря этому, для определения теста теперь не обязательно создавать класс, который будет наследоваться от XCTestCase — достаточно создать функцию с аннотацией @Test.

import Testing

@Test
func foodTruckExists() {
    // Test logic goes here.
}

При этом в аннотации можно задать имя теста, чтобы легче было идентифицировать его назначение.

@Test("Food truck exists")
func foodTruckExists() {
    // Test logic goes here.
}

Также можно добавлять атрибуты-модификаторы MainActor и async throws.

@Test
@MainActor
func foodTruckExists() async throws {
    // Test logic goes here.
}

Изменения жизненного цикла теста

В XCTest тесты привязаны исключительно к классам. Но в Swift Testing есть возможность использовать как классы, так и структуры. 

Так, если раньше для setUp и tearDown были отдельные функции, то сейчас достаточно определить init и deinit для тестов.

@Suite

Вместе с поддержкой @Test, в Swift Testing поддерживается аннотация @Suite, которая  используется в тестировании для объединения нескольких тестовых классов или групп тестов в один набор (suite). 

@Suite("Arithmetic")
struct ArithmeticTests {
    let calc = Calculator()
    
    @Test
    func addition() {
        let result = calc.addition(num1: 2, num2: 3)
        #expect(result == 5)
    }

    @Test
    func subtraction() {
        let result = calc.subtraction(num1: 5, num2: 3)
        #expect(result == 2)
    }

С ее помощью можно:

  • группировать тесты в один набор;

  • вкладывать Suite внутри друг друга для создания иерархии тестов;

  • применять дополнительные настройки и модификаторы (Traits или трейты) ко всем тестам в Suite одновременно;

  • настраивать отображение набора в Xcode и в результатах тестов.

При этом @Suite необязателен для работы, поскольку Swift Testing распознаёт тесты, находящиеся в любом типе (структуре, классе, акторе). Вместе с тем, @Suite позволяет явно задать имя и дополнительные параметры.

Expectation

В Swift Testing можно использовать expectation — механизм для проверки условий (утверждений) в тестах, который, в отличие от традиционных XCTAssert-функций из XCTest, использует более современный и гибкий подход через макрос #expect().

Примечательно, что функция expect сообщает о неудаче, но позволяет тесту продолжать выполнение. Это удобно, поскольку дает возможность посмотреть все ожидания, которые падают во время теста. 

Например, у нас есть структура calculator и функция sum. Мы хотим проверить, что сумма будет равна разным значениям. 

В примере видим, что первые два ожидания не оправдались, но тест завершился и проверил все ожидания.

#require

Функция require — аналог #expect, но с немедленным прекращением выполнения теста (с ошибкой ExpectationFailedError) при неудачной проверке. 

Также она может использоваться для разворачивания опционалов. Это аналог XCTUnwrap, который был в XCTest. 

@Test
func stringParsesToInt() throws {
    let data = "2"
    let parsed = try #require(Int(data))
    #expect(parsed == 2)
}

Запись ошибок

В Swift Testing появилась возможность логировать ошибки при помощи Issue.record(), в которой мы можем залогировать причины падения теста.

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

enum Engine {
    case electric
    case diesel
}

struct FoodTruck {
    static let shared = FoodTruck()
    
    var engine: Engine = .diesel
}

И мы можем запустить тест, в условиях которого задаем, что тест может упасть, если двигатель не электрический.

struct FoodTruckTests {
    @Test
    func engineWorks() {
        let engine = FoodTruck.shared.engine
        guard case .electric = engine else {
            Issue.record("Engine is not electric")
            return
        }
        // ...
    }
}

Так как у нас двигатель не электрический, тест объективно упадет. 

withKnownIssue

withKnownIssue — параметр аннотации @Test, который помечает тест как содержащий известную проблему (баг). Его использование дает возможность:

  • запускать тест, но не считать его провал как failure;

  • отслеживать известные проблемы;

  • получать предупреждения при неожиданном успехе.

Например, возьмем тест, в котором grillWorks помечен как имеющий известную проблему — "Grill is out of fuel". В этом случае, если при выполнении теста возникнет ошибка, связанная с тем, что гриль не может быть запущен из-за отсутствия топлива, тест не будет считаться проваленным.

struct FoodTruckTests {
    @Test
    func grillWorks() async {
        withKnownIssue("Grill is out of fuel") {
            try FoodTruck.shared.grill.start()
        }
        // ...
    }
}

withKnownIssue также позволяет указывать, если ошибка интермиттирующая, то есть проявляется не всегда, а только в определенных условиях. Это означает, что тесты должны быть готовы к тому, что ошибка может возникать не всегда, а только в определенных условиях.

struct FoodTruckTests {
    @Test
    func grillWorks() async {
        withKnownIssue("Grill may need fuel", isIntermittent: true) {
            try FoodTruck.shared.grill.start()
        }
        // ...
    }
}

Функция withKnownIssue также позволяет задавать условия, при которых известная проблема считается актуальной, и сопоставлять ошибки с определенными критериями.

Например, можно указать, что проблема с отсутствием топлива в гриле актуальна только при условии, что гриль установлен (FoodTruck.shared.hasGrill). Также проверять, что в объекте issue есть ошибка (issue.error != nil), чтобы считать проблему актуальной.

struct FoodTruckTests {
    @Test
    func grillWorks() async {
        withKnownIssue("Grill is out of fuel") {
            try FoodTruck.shared.grill.start()
        } when: {
            FoodTruck.shared.hasGrill
        } matching: { issue in
            issue.error != nil
        }
        // ...
    }
}

Кастомизация тестов

Swift Testing предлагает возможность использования различных трейтов, с помощью которых можно:

  • задать дополнительное поведение или метаданные к тестам и test suite;

  • настраивать выполнение тестов;

  • добавлять описания, теги, ограничения по времени и многое другое.

Разберем несколько наглядных примеров.

Выключение тестов

Например, можно выключить тест, который не нужен.

@Test("Food truck sells burritos", .disabled())
func sellsBurritos() async throws {
// ...
}

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

@Test("Food truck sells burritos", .disabled("We only sell Thai cuisine"))
func sellsBurritos() async throws {
// ...
}

Помимо этого, можно выключать Suite и все определенные в нем тесты.

@Suite("Arithmetic", .disabled())
struct ArithmeticTests

Запуск теста при определенном условии

Также с помощью трейтов можно настраивать запуск теста при определенном условии. Например, чтобы тест запускался только летом.

enum Season {
  case winter
  case spring
  case summer
  case autumn

  static var current: Season = .summer
}

@Test("Ice cream is cold", .enabled(if: Season.current == .summer))
func isCold() async throws {
    // ...
}

Примечательно, что два упомянутых условия для теста можно комбинировать — например, выключить тест на всё время, но включать только летом.

@Test(
"Ice cream is cold",
    .enabled(if: Season.current == .summer),
    .disabled("We ran out of sprinkles")
)
func isCold() async throws {
    // ...
}

Добавление тегов

Помимо этого, в Swift Testing появилась возможность использования тегов. То есть, теперь можно не только объединять в группы похожие тесты, но и присваивать им одинаковые теги для упрощения последующего использования — можно создать новый тег, расширив его и добавив статическую переменную.

extension Tag {
    @Tag static var example: Tag
    
    enum CustomTags {}
}

extension Tag.CustomTags {
    @Tag static var jsonTests: Tag
}

Достаточно просто добавить в аннотацию свойства tags, и в Xcode пометятся все тесты с определенным тегом. 

Например, задаем некоторые функции:

@Test("Decode json", .tags(.CustomTags.jsonTests))
func decodeJson()

@Test(.tags(.example))
func decodeJSONTypeCastingFails()

@Suite(.tags(.CustomTags.jsonTests))
struct JSONDecoderTests

А на выходе получаем две группы тестов по тегам:

  • CustomTags.jsonTests;

  • example.

В дальнейшем такая сортировка не только упрощает поиск похожих тестов, но и позволяет запускать все тесты с одним тегом одновременно. 

Линковка багов

Помимо прочего, в Swift Testing доступен удобный механизм линковки багов. Так, упавший тест с неожиданным поведением можно выключить и слинковать с задачей, с которой он будет фикситься.

@Test(.disabled(), .bug(«https://jira.vk.team/browse/OKAT-6683", "Баг"))
func responseSerializerFailsDataIsNil() throws

При этом, в интерфейсе предусмотрена отдельная кнопка, которая позволяет сразу перейти к нужной задаче.

Создание кастомного трейта

Также в Swift Testing можно создавать кастомные трейты. Это может пригодиться, например, чтобы инкапсулировать часто используемые конфигурации тестов (например, переопределённые зависимости, специальные настройки окружения, общие параметры). То есть, вместо повторения одной и той же логики в каждом тесте или Suite можно создать один кастомный трейт и применять его везде.

Для этого достаточно создать структуру или класс, который будет подписан на протокол TestTrait, и расширить трейт, подписав на нужный трейт с помощью статической переменной. 

@Test(.mockJson)
func example() {
    // ...
}

struct MockJsonTrait: TestTrait { }

extension Trait where Self == MockJsonTrait {
    static var mockJson: Self { }
}

Параметризованные тесты

Одной из важных фишек Swift Testing стала возможность создания параметризированных тестов, то есть тех, которые выполняются многократно с разными наборами входных данных. Это позволяет проверить одну и ту же функциональность с различными значениями, чтобы убедиться в ее правильности в разных случаях.

Параметризация теста

Рассмотрим ситуацию, где нам надо проверить тест, в котором есть определенное количество аргументов. Для этого создадим enum или массив, и пробежимся по нему.

enum Food {
    case burger, iceCream, burrito, noodleBowl, kebab
}

Теоретически можно создать тест, который через for loop проверяет каждый аргумент: в нашем случае — возможность приготовления каждого вида еды.

enum Food {
    case burger, iceCream, burrito, noodleBowl, kebab
}
@Test("All foods available")
func foodsAvailable() async throws {
    for food: Food in [.burger, .iceCream, .burrito, .noodleBowl, .kebab] {
        let foodTruck = FoodTruck(selling: food)
        #expect(await foodTruck.cook(food))
    }
}

Но со Swift Testing тест можно упростить, передав все аргументы в аннотацию, что облегчит его понимание.

enum Food {
    case burger, iceCream, burrito, noodleBowl, kebab
}
@Test("All foods available", arguments: [Food.burger, .iceCream, .burrito, .noodleBowl, .kebab])
func foodAvailable(_ food: Food) async throws {
    let foodTruck = FoodTruck(selling: food)
    #expect(await foodTruck.cook(food))
}

Помимо этого, для enum можно добавить CaseIterable. В таком случае в аргументы можно просто передать значение allCases, что будет подразумевать проверку всех аргументов.

enum Food: CaseIterable {
    case burger, iceCream, burrito, noodleBowl, kebab
}

@Test("All foods available", arguments: Food.allCases)
func foodAvailable(_ food: Food) async throws {
    let foodTruck = FoodTruck(selling: food)
    #expect(await foodTruck.cook(food))
}

Но применение параметризированных тестов возможно и в более сложных кейсах. Например, когда в тесте указывается два массива.

Рассмотрим на функции, которая проверяет нормальную частоту биения сердца в каждом из возрастов. В теории массивы значений можно проверить через for loop.

@Test
func verifyNormalHeartRate() {
    let age = [18, 30, 50, 70]
    let bpm = [77.0, 73, 65, 61]

    for (age, bpm) in zip(age, bpm) {
        let hr = HeartRate(bpm: bpm)
        let context = HeartRateContext(age: age, activity: .regular, heartRate: hr)
        #expect(context.zone == .normal)
    }
}

Но рациональнее передать два массива в аргументы.

@Test(arguments: [18, 30, 50, 70], [77.0, 73, 65, 61])
func verifyNormalHeartRate(age: Int, bpm: Double) {
    let hr = HeartRate(bpm: bpm)
    let context = HeartRateContext(age: age, activity: .regular, heartRate: hr)
    #expect(context.zone == .normal)
}

Но здесь возникает проблема. Так, в исходном варианте применяется zip, что позволяет проверять значения только парами. А при передаче массивов в аргументы можно получить 25 прогонов, поскольку Swift Testing будет пытаться проверить все возможные варианты. Соответственно, чтобы избежать подобных издержек, также нужно использовать zip для тестирования конкретных пар.

@Test(arguments: zip([18, 30, 50, 70], [77.0, 73, 65, 61]))
func verifyNormalHeartRate(age: Int, bpm: Double) {
    let hr = HeartRate(bpm: bpm)
    let context = HeartRateContext(age: age, activity: .regular, heartRate: hr)
    #expect(context.zone == .normal)
}

Это позволит сразу найти пару, которая будет падать, а не перебирать 25 раз. 

Лимиты времени

В рамках параметризации тестов также можно применять некоторые Traits. Например, можно ограничивать время тестов в минутах.

@available(iOS 16.0, *)
@Test(.timeLimit(.minutes(1)))
func prepare(food: Food)

Надо отметить, что функция пока доступна только с iOS 16. 

Выключение параллельности

Также можно выключать параллельность запуска тестов. Например, если нужно последовательно проверить возможность приготовления каждого блюда. Для этого достаточно указать в функции условие serialized.

@Test(.serialized, arguments: Food.allCases)
func prepare(food: Food) {
    // This function will be invoked serially, once per food, because it has the
    // .serialized trait.
}

Также serialized работает и для Suite. Например, пока последовательная проверка каждого аргумента не выполнится, второй тест не начнет свою работу. 

@Suite(.serialized)
struct FoodTruckTests {
    @Test(arguments: Condiment.allCases)
    func refill(condiment: Condiment) {
        // This function will be invoked serially, once per condiment, because the
        // containing suite has the .serialized trait.
    }
   
    @Test
    func startEngine() async throws {
        // This function will not run while refill(condiment:) is running.
        // One test must end before the other will start.
    }
}

Возможности миграции с XCTest к Swift Testing

Теперь остановимся на том, как потенциально можно применить все упомянутые возможности Swift Testing. Для наглядности рассмотрим несколько сценариев.

Декодинг JSON

Для декодинга JSON в XCTest классически применяется довольно типовая структура. 

import XCTest

class JSONDecoderTests: OKTestCase {


func testJSONDecodingTypeCastingFails() {

        let dataDecoder = JSONDecoder()
        let data = Data("{\"uid\": 12, \"firstName\": \"firstName\", \"lastName\": \"lastName\"}".utf8)
        let result = Result { try dataDecoder.decode(String.self, from: data) }

        XCTAssertTrue(result.isFailure)
        XCTAssertNil(result.success)
        XCTAssertNotNil(result.failure)
    }
}

При работе со Swift Testing мы можем значительно упростить ее: сделать структуру (struct JSONDecoderTests), повесить аннотацию @Test и заменить XCTAssert на #expect.

import Testing

struct JSONDecoderTests {

    @Test
    func decodeJSONTypeCastingFails() {

        let dataDecoder = JSONDecoder()
        let data = Data("{\"uid\": 12, \"firstName\": \"firstName\", \"lastName\": \"lastName\"}".utf8)
        let result = Result { try dataDecoder.decode(String.self, from: data) }

        #expect(result.isFailure)
        #expect(result.success == nil)
        #expect(result.failure != nil)
    }
}

Замена setUp и tearDown

Помимо прочего, переход на Swift Testing позволит уйти от применения setUp и tearDown в тестах перформанса. 

import XCTest

final class OKPerformanceModuleTests: XCTestCase {
    
    private var collector: StorageCollector!
    private var scenarioId: String?
    
    override func setUpWithError() throws {
        self.scenarioId = nil
        self.collector = StorageCollector()
        OKPerfomanceModule.attachCollector(self.collector)
    }
    
    override func tearDownWithError() throws {
        if let scenarioId {
            OKPerfomanceModule.end(scenarioId: scenarioId, message: nil)
        }
    }
}

Так, setUp и tearDown можно заменить на init и deinit.

import Testing

final class OKPerformanceModuleTests {
    
    private var collector: StorageCollector
    private var scenarioId: String?
    
    init() {
        self.scenarioId = nil
        self.collector = StorageCollector()
        OKPerfomanceModule.attachCollector(self.collector)
    }
    
    deinit {
        if let scenarioId {
            OKPerfomanceModule.end(scenarioId: scenarioId, message: nil)
        }
    }
}

Объединение логики повторяющихся тестов

Иногда тесты могут отличаться лишь некоторыми параметрами. Но в случае XCTest с этим часто приходится мириться. Например:

final class OKPerfomanceModuleTests: XCTestCase {
  // ...
    func testBeginChildCallNoMessage() throws {
        let parentId = UUID().uuidString
        let scenario = "testBeginChildCallNoMessage-scenario"
        self.scenarioId = OKPerfomanceModule.begin(scenario: scenario,
                                                   parentId: parentId,
                                                   message: nil)
        XCTAssertTrue(collector.scenario == scenario)
        XCTAssertTrue(collector.parentId == parentId)
        XCTAssertTrue(collector.message == nil)
    }
    
    func testBeginChildCallWithMessage() throws {
        let parentId = UUID().uuidString
        let scenario = "testBeginChildCallWithMessage-scenario"
        let message = "testBeginChildCallWithMessage-message"
        self.scenarioId = OKPerfomanceModule.begin(scenario: scenario,
                                                   parentId: parentId,
                                                   message: message)
        XCTAssertTrue(collector.scenario == scenario)
        XCTAssertTrue(collector.parentId == parentId)
        XCTAssertTrue(collector.message == message)
    }
}

При работе со Swift Testing мы можем объединить два теста в один параметризованный. Например, можно создать аргумент и передать в него параметры, которые будут запускаться и проверяться в конкретном тесте.

final class OKPerfomanceModuleTests {
    
    @Test(arguments: [
        Argument(scenario: "testBeginRootCallNoMessage"),
        Argument(scenario: "testBeginRootCallWithMessage-scenario", message: "testBeginRootCallWithMessage-message"),
    ])
    func beginScenario(arg: Argument) {
        self.scenarioId = OKPerfomanceModule.begin(scenario: arg.scenario, parentId: arg.parentId, message: arg.message)
        #expect(collector.scenario == arg.scenario)
        #expect(collector.parentId == arg.parentId)
        #expect(collector.message == arg.message)
    }
}

struct Argument {
    let scenario: String
    let parentId: String?
    let message: String?
    
    init(scenario: String, parentId: String? = UUID().uuidString, message: String? = nil) {
        self.scenario = scenario
        self.parentId = parentId
        self.message = message
    }
}

Переход от XCTUnwrap к require

Тесты в XCTest нередко используют XCTUnwrap.

class OKDecodableBatchResponseSerializerTests: OKTestCase {
    
    func testResponseSerializerFailsDataIsNil() throws {
        let serializer = DecodableTestBatchResponseSerializer()
        serializer.registry(User.self, forKey: .user)
        let result = Result { try serializer.serialize(response: nil, data: nil) }
        
        let networkError = try XCTUnwrap(result.failure?.asNetworkingError)
        XCTAssertEqual(networkError.isInputDataNilOrZeroLength, true)
    }
}

Со Swift Testing мы можем начать использовать require. Для этого достаточно заменить XCTUnwrap на require и Assert на expect.

struct OKDecodableBatchResponseSerializerTests {
    @Test
    func responseSerializerFailsDataIsNil() throws {
        let serializer = DecodableTestBatchResponseSerializer()
        serializer.registry(User.self, forKey: .user)
        let result = Result { try serializer.serialize(response: nil, data: nil) }
        
        let networkError = try #require(result.failure?.asNetworkingError)
        #expect(networkError.isInputDataNilOrZeroLength)
    }
}

Логирование ошибок

Также можно залогировать ошибку, которая есть в тесте. Например, если результат успешный — выполняем определенную часть теста, если нет — выбрасывается ошибка. 

class JSONDecoderTests: OKTestCase {

    func testJSONDecoding() {
        let dataDecoder = JSONDecoder()
        let data = Data("{\"uid\": 12, \"firstName\": \"firstName\", \"lastName\": \"lastName\"}".utf8)
        let result = Result { try dataDecoder.decode(OKAPIUser.self, from: data) }
        
        XCTAssertTrue(result.isSuccess)
        XCTAssertNotNil(result.success)
        XCTAssertNil(result.failure)
        if let user = result.success {
            XCTAssertEqual(user.uid, 12)
            XCTAssertEqual(user.firstName, "firstName")
            XCTAssertEqual(user.lastName, "lastName")
        } else {
            XCTFail("Serialized result type is not dictionary: \(type(of: result.success))")
        }
    }
}

Здесь можно несколько изменить конфигурацию через Guard, предусмотреть обработку ошибки через Issue.record и добавить expect.

struct JSONDecoderTests {
    @Test 
func decodeJson() {
        let dataDecoder = JSONDecoder()
        let data = Data("{\"uid\": 12, \"firstName\": \"firstName\", \"lastName\": \"lastName\"}".utf8)
        let result = Result { try dataDecoder.decode(OKAPIUser.self, from: data) }
        
        #expect(result.isSuccess)
        #expect(result.success != nil)
        #expect(result.failure == nil)
        
        guard let user = result.success else {
            Issue.record("Serialized result type is not dictionary: \(type(of: result.success))")
            return
        }
        
        #expect(user.uid == 12)
        #expect(user.firstName == "firstName")
        #expect((user.lastName == "lastName"))
    }
}

Вместо выводов

Swift Testing представляет собой современный фреймворк с интуитивным API, который предоставляет поддержку Swift Concurrency, параметризации и кастомизации тестов, модификации их поведения и множество других возможностей, с которыми работа разработчиков и тестировщиков становится проще. Именно поэтому мы в ОК уже начали переводить тесты на Swift Testing и получать результаты от внедрения нового фреймворка. 

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

Комментарии (0)