Introduction to Swift Testing

Swift Testing Overview

Code testing is an important and integral part of the software development process. It allows developers to test the functionality of their programs, identify errors, and ensure that their code remains stable when changes are made. In the Swift ecosystem, developers can take advantage of several test writing tools such as XCTest, as well as new libraries such as Testing.

XCTest is the primary testing tool in Swift and is widely used by developers. However, the new Testing library offers additional features and syntactic sugar that makes the testing process even more convenient and powerful. In this article, we will look at the basic aspects of testing in Swift, including using the Testing library.

Why are tests needed and what types of tests exist?

Tests are necessary for several reasons:

  • Ensuring the code works correctly: Tests help ensure that your code works as expected. This is especially important in large projects where one wrong step can lead to many problems.

  • Regression protection: When you make changes to your code, tests help ensure that the new changes don't break existing functionality.

  • Documentation of system behavior: Tests can serve as living documentation for your code, showing exactly how it should work.

  • Increased developer confidence: The presence of tests allows developers to be confident that their changes do not lead to unexpected consequences.

There are several main types of tests:

  • Unit Tests: Test individual components or functions of the code. These tests are isolated from other parts of the system and test the operation of specific functions or methods.

  • Integration Tests: Check the interaction between different parts of the system. These tests help ensure that system modules work together correctly.

  • System Tests (E2E): Check the system as a whole. These tests typically involve checking all aspects of the system, including the user interface.

  • Acceptance Tests (QA): Validate the system in terms of user requirements. These tests help ensure that the system meets the end user's requirements.

Basic concepts: test target, test function, test suite

To successfully write tests in Swift, you need to understand a few basic concepts:

  • Test Target: In Xcode, a special target is created for testing, which contains all your tests. This allows you to separate tests from the main code base and manage them separately. To add tests to a project, you need to create a new test target in your Xcode project.

  • Test Function: This is a separate function that contains a verification code. In XCTest, such functions are marked with a special test prefix, for example, func testExample(). The Testing library uses the @Test attribute for this. These functions are executed by the test framework and report the results of execution.

import Testing

@Test
func testExample() {
    let result = add(2, 3)
    #expect(result == 5)
}
  • Test Suite: A group of tests combined to run together. In XCTest you can create test cases by inheriting from XCTestCase. In the Testing library, test suites can be organized into types with the @Suite annotation.

import Testing

@Suite
struct ArithmeticTests {
    @Test
    func testAddition() {
        let result = add(2, 3)
        #expect(result == 5)
    }

    @Test
    func testSubtraction() {
        let result = subtract(5, 3)
        #expect(result == 2)
    }
}

Working with the testing library

Connecting the Testing library

To start working with the Testing library, you need to import it into a file with tests. (Use XCode 16+)

import Testing

Important: Import the testing library for test purposes only. Importing it into target applications, libraries, or binaries is not supported or recommended.

Test Function Definition

To define a test function, you need to create a Swift function that takes no arguments and prefix its name with the @Test attribute:

@Test func foodTruckExists() {
  // Логика теста
}

You can also set a custom test name:

@Test("Food truck exists") func foodTruckExists() { ... }

Asynchronous and throwing tests

Test functions can be asynchronous (async) and throwing exceptions (throws), which allows asynchronous operations and error handling:

@Test @MainActor func foodTruckExists() async throws { ... }

Testing using parameters

Test functions can take arguments to test a function on different data without duplicating code:

@Suite
struct MultiplicationTests {
    @Test(arguments: [(2, 3, 6), (4, 5, 20), (7, 8, 56)])
    func testMultiplication(_ a: Int, _ b: Int, _ expected: Int) {
        let result = multiply(a, b)
        #expect(result == expected)
    }
}

Limiting test availability

If the test should only run on certain versions of the operating system or Swift language, use the attribute @available:

@available(macOS 11.0, *)
@available(swift, introduced: 8.0, message: "Requires Swift 8.0 features to run")
@Test func foodTruckExists() { ... }

Testing on the main thread

To test code that should run on the main thread (such as updating an interface), use the attribute @MainActor:

@Test @MainActor func mainThreadTestExample() async throws {
    // Логика теста в основном потоке
}

Examples and explanations

Example 1: Asynchronous Function Test

struct NetworkManager {
    func fetchData() async throws -> Data {
        // Имитация сетевого запроса
        return Data()
    }
}

@Test
func testFetchData() async throws {
    let networkManager = NetworkManager()
    let data = try await networkManager.fetchData()
    #expect(data.isEmpty == false)
}

This example demonstrates testing an asynchronous function fetchData in class NetworkManager. Function testFetchData marked with attributes async And throwswhich allows it to perform asynchronous operations and handle errors using try. Statement #expect checks that the returned data is not empty.

Example 2: Test on the main thread

class ViewModel {
    func updateState(state: ViewModel.State) {
        self.state = state
        // Обновление пользовательского интерфейса
    }
}

@Test
@MainActor
func testUpdateUI() async throws {
    let viewModel = ViewModel()
    await viewModel.updateState(state: .loading)
    #expect(viewModel.state == .loading)
    // Дополнительные проверки, связанные с обновлением интерфейса
}

This example shows testing a function updateState in class ViewModel, which changes the state of the model and updates the user interface. Attribute @MainActor ensures that the test testUpdateUI will be executed on the main thread, which is important for the proper functioning of the user interface.

Example 3: Testing a function that throws an error

struct FileHandler {
    enum FileError: Error {
        case fileNotFound
    }

    func readFile() throws -> String {
        throw FileError.fileNotFound
    }
}

@Test
func testReadFile() throws {
    let fileHandler = FileHandler()
    do {
        let _ = try fileHandler.readFile()
        Issue.record("Ожидалась ошибка, но не была выброшена")
    } catch FileHandler.FileError.fileNotFound {
        // Ожидаемая ошибка
    } catch {
        Issue.record("Неожиданная ошибка: \(error)")
    }
}

This example tests the function readFile structures FileHandlerwhich throws an error FileError.fileNotFound. Test testReadFile checks that the function actually throws the expected error and logs a problem if another error is thrown.

Organizing Tests Using Suite Types

When managing a significant number of tests, it is important to organize them into test suites, which greatly simplifies their execution and maintenance. There are two ways to do this in Swift:

  1. Adding Tests to a Swift Type

    Just put the test functions into a Swift type. This will automatically create a test case without the need to use the attribute @Suite. Example:

    struct FoodTruckTests {
        @Test func foodTruckExists() { ... }
    }
    
  2. Attribute Usage @Suite

    Attribute @Suite is optional, but allows you to customize the display of the test case in the IDE and command line, as well as use additional features such as tags() to mark tests. Example:

    @Suite("Food truck tests") struct FoodTruckTests {
        @Test func foodTruckExists() { ... }
    }
    

Example of using test cases

@Suite("Food Truck Management") struct FoodTruckManagementTests {
    @Test func foodTruckExists() { ... }
    @Test func addFoodTruck() { ... }
}

@Suite("Customer Orders") struct CustomerOrdersTests {
    @Test func orderFood() { ... }
    @Test func cancelOrder() { ... }
}

In this example FoodTruckManagementTests And CustomerOrdersTests are separate test suites grouped by functional areas. This approach makes it easy to organize and configure test execution in various scenarios.

Limitations on test case types

When using types as test cases, the following restrictions must be taken into account:

  • Type initializer: If a type contains test functions declared as instance methods, it must be initialized using a no-argument initializer. This initializer can be implicit or callable, synchronous or asynchronous, error-throwing or non-error-throwing, and any level of access.

    @Suite struct FoodTruckTests {
        var batteryLevel = 100
        @Test func foodTruckExists() { ... } // ✅ ОК: Тип имеет неявный инициализатор.
    }
    
    @Suite struct CashRegisterTests {
        private init(cashOnHand: Decimal = 0.0) async throws { ... }
        @Test func calculateSalesTax() { ... } // ✅ ОК: Тип имеет вызываемый инициализатор.
    }
    
  • Type availability: Test cases and their contents must always be available, so the attribute should not be used @available to limit their availability.

    @Suite struct FoodTruckTests { ... } // ✅ OK: Тип доступен всегда.
    
    @available(macOS 11.0, *) // ❌ Ошибка: Тип может быть недоступен.
    @Suite struct CashRegisterTests { ... }
    
  • Class inheritance: The testing library does not support inheritance between test case types. Therefore, classes used as test cases must be declared as final.

@Suite final class FoodTruckTests { ... } // ✅ ОК: Класс объявлен как final.

actor CashRegisterTests: NSObject { ... } // ✅ ОК: Акторы по умолчанию являются final.

class MenuItemTests { ... } // ❌ Ошибка: Этот класс не объявлен как final.

Using .tags to restrict test execution

In Swift Testing, you can use tags (tags). It is a powerful tool for classifying tests and managing their execution in different scenarios. Tags can be applied to individual test functions or to entire test suites.

Basic principles of using tags

  1. Defining Tags

    Tags are defined using a static extension structure Tag. This allows you to set static properties representing different categories of tests:

    extension Tag {
        @Tag static var regression: Self
        @Tag static var ui: Self
        @Tag static var performance: Self
        // Дополнительные теги можно добавлять по мере необходимости
    }
    

    This example defines tags for regression testing (regression), user interface testing (ui) and performance (performance).

  2. Test Functions Abstract

    Tags can be applied to test functions using the attribute @Test. This makes it possible to specify which testing categories apply to a particular test function:

    @Test(.tags(.regression, .ui))
    func testUserInterface() {
        // Логика теста для проверки пользовательского интерфейса
    }
    

    In this example the test testUserInterface belongs to the categories regression And uiwhich means it can be run both when running regression tests and when testing the UI.

  3. Test Case Abstract

    Tags can also be applied to the entire test case using the attribute @Suite. This makes it easy to classify and limit the execution of all tests within a suite:

    @Suite(.tags(.performance))
    struct PerformanceTests {
        @Test func testAppLaunchSpeed() {
            // Логика теста для проверки скорости запуска приложения
        }
    
        @Test func testMemoryUsage() {
            // Логика теста для проверки использования памяти
        }
    }
    

    In this case, all tests in the suite PerformanceTests automatically inherit the tag performanceallowing you to run them only when you need to run performance tests.

Examples of use and explanations

  • Tag combination

    Tags can be combined to accurately classify tests:

    @Test(.tags(.regression, .critical))
    func testCriticalFunctionality() {
        // Логика теста для критически важной функциональности
    }
    

    In this example the test testCriticalFunctionality marked as critical and to be checked in regression tests.

  • Dynamic tag management

    Tags can also be used to dynamically control testing depending on the environment or stage of development:

    #if DEBUG
    @Test(.tags(.fast))
    #else
    @Test(.tags(.slow, .full))
    #endif
    func testComplexCalculation() {
        // Логика теста для сложных вычислений
    }
    

    In this example the test testComplexCalculation tagged fast in debug mode and tags slow And full in release mode, which allows you to choose which tests to run depending on your current environment.

Using tags in Swift testing provides flexibility and convenience in organizing and executing tests, making it easy to manage their execution in a variety of development environments and scenarios.

Migration of tests from XCTest to the Testing library

Switching from using XCTest to the new Testing library in Swift requires several key steps, including importing the library, changing the test class structure, and using new methods for condition tests. Let's look at each of these steps in more detail.

Importing the Testing library

The first step is to replace the XCTest library import with the Testing library in your Swift test files:

import Testing

This import must be added to the files where you define your tests. It is important to remember that the Testing library must be used in the context of tests. Importing this library into a host application or library is not supported or recommended.

Test migration examples

Converting Test Classes

In XCTest, tests are usually grouped within classes that inherit from XCTestCase. In the Testing library, tests can be declared as free functions, static methods of structures or classes, or as members of structures with the attribute @Test.

An example of migrating a test class from XCTest to a structure with an attribute @Test:

Before (XCTest):

class FoodTruckTests: XCTestCase {
    func testEngineWorks() {
        // Тестовая логика здесь.
    }
}

After (Testing):

struct FoodTruckTests {
    @Test func engineWorks() {
        // Тестовая логика здесь.
    }
}

Converting setup and teardown functions

XCTest uses methods setUp() And tearDown() to execute code before and after each test is executed. In the Testing library, similar behavior can be achieved using struct or class initializers and deinitializers.

Example of converting setup and teardown from XCTest into initializer and deinitializer:

Before (XCTest):

class FoodTruckTests: XCTestCase {
    var batteryLevel: NSNumber!

    override func setUp() {
        batteryLevel = 100
    }

    override func tearDown() {
        batteryLevel = 0
    }
}

After (Testing):

struct FoodTruckTests {
    var batteryLevel: NSNumber

    init() {
        batteryLevel = 100
    }

    deinit {
        batteryLevel = 0
    }
}

Converting Test Methods

In XCTest, the test method must be a class method XCTestCase and start with a prefix test. In the Testing library, test functions are identified using the attribute @Test.

Example of converting a test method from XCTest to a function with an attribute @Test:

Before (XCTest):

class FoodTruckTests: XCTestCase {
    func testEngineWorks() {
        // Тестовая логика здесь.
    }
}

After (Testing):

struct FoodTruckTests {
    @Test func engineWorks() {
        // Тестовая логика здесь.
    }
}

Comparison of XCTAssert and expect/require functions

XCTest provides a set of features XCTAssert() to check conditions in tests. In the Testing library, macros are alternatives expect(::sourceLocation:) And require(::sourceLocation:). These macros provide similar behavior to XCTAssert()however require(::sourceLocation:) throws an error if the condition is not met.

Examples of using Assert in XCTest and equivalents in Testing:

  • XCTest:

    class FoodTruckTests: XCTestCase {
        func testEngineWorks() throws {
            let engine = FoodTruck.shared.engine
            XCTAssertNotNil(engine.parts.first)
            XCTAssertGreaterThan(engine.batteryLevel, 0)
            try engine.start()
            XCTAssertTrue(engine.isRunning)
        }
    }
    
  • Testing:

    struct FoodTruckTests {
        @Test func engineWorks() throws {
            let engine = FoodTruck.shared.engine
            try #require(engine.parts.first != nil)
            #expect(engine.batteryLevel > 0)
            try engine.start()
            #expect(engine.isRunning)
        }
    }
    

Correspondence table of XCTAssert and expect/require functions

XCTest

swift-testing

XCTAssert(x), XCTAssertTrue(x)

#expect(x)

XCTAssertFalse(x)

#expect(!x)

XCTAssertNil(x)

#expect(x == nil)

XCTAssertNotNil(x)

#expect(x != nil)

XCTAssertEqual(x, y)

#expect(x == y)

XCTAssertNotEqual(x, y)

#expect(x != y)

XCTAssertIdentical(x, y)

#expect(x === y)

XCTAssertNotIdentical(x, y)

#expect(x !== y)

XCTAssertGreaterThan(x, y)

#expect(x > y)

XCTAssertGreaterThanOrEqual(x, y)

#expect(x >= y)

XCTAssertLessThanOrEqual(x, y)

#expect(x <= y)

XCTAssertLessThan(x, y)

#expect(x < y)

XCTAssertThrowsError(try f())

#expect(throws: (any Error).self) { try f() }

XCTAssertThrowsError(try f()) { error in … }

#expect { try f() } throws: { error in return … }

XCTAssertNoThrow(try f())

#expect(throws: Never.self) { try f() }

try XCTUnwrap(x)

try #require(x)

XCTFail(“…”)

Issue.record(“…”)

This table will help you transition from using XCTest functions to equivalent methods in the Testing library, while ensuring compatibility and maintaining the functionality of your tests.

Checking Expected Values ​​and Results in Swift Testing

When writing tests in Swift, especially when using the Testing library, it is important to be able to effectively check expected values ​​and results of operations. For this purpose the functions are used expect And requireeach of which has its own characteristics and is designed for different testing scenarios.

Using expect

Function expect designed to check conditions in tests. It reports failure if the condition is not met, but allows the test to continue executing.

Usage example expect:

@Test func calculatingOrderTotal() {
    let calculator = OrderCalculator()
    #expect(calculator.total(of: [3, 3]) == 6)
    // Выведет "Expectation failed: (calculator.total(of: [3, 3]) → 6) == 7"
}

In this example, if the calculation sum is not equal to 6, the test will continue to run, but an error will be recorded, which will be displayed in the test output.

Using require

Function require works similarly expectbut with one important difference: if the condition is not met, the test immediately fails with an error ExpectationFailedError.

Usage example require:

@Test func returningCustomerRemembersUsualOrder() throws {
    let customer = try #require(Customer(id: 123))
    // Тест не продолжится, если customer равен nil.
    #expect(customer.usualOrder.countOfItems == 2)
}

Here, if the object customer is nil, the test will be stopped and the error will be visible in the test output.

Checking optional values

To check optional values ​​in XCTest, use the function XCTUnwrap, which throws an error if the value is nil. The Testing library provides similar functionality require.

Example using require:

struct FoodTruckTests {
    @Test func engineWorks() throws {
        let engine = FoodTruck.shared.engine
        let part = try #require(engine.parts.first)
        // Далее идёт код, который зависит от наличия `part`
    }
}

If engine.parts.first is nil, an exception will be thrown and the test will be aborted.

Logging Errors

XCTest uses the function to record errors that should cause the test to fail anyway. XCTFail. In the Testing library, a similar function is Issue.record.

Example using Issue.record:

struct FoodTruckTests {
    @Test func engineWorks() {
        let engine = FoodTruck.shared.engine
        guard case .electric = engine else {
            Issue.record("Engine is not electric")
            return
        }
        // Далее идёт код, зависящий от того, что `engine` является электрическим
    }
}

This function is used to capture errors or problems that should be noted in the test output, but do not cause the test to immediately abort.

Testing Asynchronous Behavior

To test asynchronous behavior in Swift, especially when using Confirmation, it is important to follow a few key steps and understand how this approach works. Let's take a closer look at the main points and use cases.

Using Confirmation to Validate Asynchronous Events

Creation of Confirmation

First you need to create a Confirmation inside the test function using the function confirmation() from the Testing library. A Confirmation is created with the expected number of events and a closure that will be called when the condition is met.

Examples of using

  1. Example: Checking the completion of an asynchronous task

    @Test func asyncTaskCompletion() async {
        await confirmation("Task should complete") { taskCompleted in
            Task {
                await performAsyncTask()
                taskCompleted()
            }
        }
    }
    

    In this example confirmation waits for an asynchronous task to complete performAsyncTask. When the task completes, call taskCompleted()confirming the completion of the task.

  2. Example: Testing an asynchronous event

    @Test func eventHandlingTest() async {
        await confirmation("Event should be handled") { eventHandled in
            EventManager.shared.eventHandler = { event in
                if event.type == .desiredEvent {
                    eventHandled()
                }
            }
            EventManager.shared.triggerEvent(.desiredEvent)
        }
    }
    

    In this case we expect that the event .desiredEvent will be processed in EventManager. When the event is processed, it is called eventHandled()which confirms that the event has been processed.

  3. Example: Checking for the absence of an asynchronous event

    @Test func orderCalculatorEncountersNoErrors() async {
      let calculator = OrderCalculator()
      await confirmation(expectedCount: 0) { confirmation in
        calculator.errorHandler = { _ in confirmation() }
        calculator.subtotal(for: PizzaToppings(bases: []))
      }
    }
    

    To ensure that a certain event does not occur during a test, create an object Confirmationwith the expected quantity 0.

Test execution control

ConditionTrait in Swift testing provides a flexible mechanism for controlling the execution of tests based on certain conditions. This is especially useful for situations where you need to skip or run tests depending on various external or internal conditions of the application or environment.

Basic principles of using ConditionTrait

  1. Skip tests based on conditions

    The example shown below demonstrates the use @Suite And @Test with ConditionTrait attributes:

    @Suite(.disabled(if: CashRegister.isEmpty))
    struct CashRegisterTests {
        @Test(.enabled(if: CashRegister.hasMoney))
        func testCashRegisterOperation() {
            // Логика теста
        }
    }
    
    • @Suite(.disabled(if: CashRegister.isEmpty)): This attribute indicates that all tests in the suite CashRegisterTests will be skipped if CashRegister.isEmpty will return true. This can be useful if the tests concern transactions with a cash register, which must contain money for correct testing.

    • @Test(.enabled(if: CashRegister.hasMoney)): This attribute applies to a specific test testCashRegisterOperation and indicates that the test will only be executed if CashRegister.hasMoney will return true. This allows you to avoid running a test if the required conditions are not met.

  2. Testing Various Conditions

    ConditionTrait can be used to test various conditions such as:

    • Availability of certain data in the application.

    • A specific state of a system or database.

    • Operating system version or other system characteristics.

Additional examples

Skip tests based on the presence of menu items

@Suite struct MenuTests {
    @Test(.enabled(if: Menu.hasItem(.pizza)))
    func testPizzaOrder() {
        // Логика теста для заказа пиццы
    }

    @Test(.enabled(if: Menu.hasItem(.sushi)))
    func testSushiOrder() {
        // Логика теста для заказа суши
    }
}
  • @Test(.enabled(if: Menu.hasItem(.pizza))): This test testPizzaOrder will only be executed if there is an item in the menu pizza.

  • @Test(.enabled(if: Menu.hasItem(.sushi))): This test testSushiOrder will only be executed if there is an item in the menu sushi.

This approach allows you to automate application testing, taking into account various conditions that may affect its functionality and test requirements.

Summary of known issues

Annotate known issues in tests using a function withKnownIssue provides a powerful tool for managing and tracking known issues during test execution. Let's take a closer look at examples of use and features of this function.

Example of using withKnownIssue

Simple problem annotation

struct FoodTruckTests {
    @Test func grillWorks() async {
        withKnownIssue("Grill is out of fuel") {
            try FoodTruck.shared.grill.start()
        }
        // Дополнительные проверки и логика теста
    }
}

In this example the test grillWorks marked as having a known problem – “Grill is out of fuel”. If the test fails because the grill cannot be started due to lack of fuel, the test will not fail.

Indicating intermittent errors

Sometimes known problems may not always appear, but only under certain conditions. For such cases the function withKnownIssue can be configured with a flag isIntermittent.

struct FoodTruckTests {
    @Test func grillWorks() async {
        withKnownIssue("Grill may need fuel", isIntermittent: true) {
            try FoodTruck.shared.grill.start()
        }
        // Дополнительные проверки и логика теста
    }
}

In this example, the problem with the grill running out of fuel is flagged as intermittent. This means that tests must be prepared for the fact that an error may not always occur, but only under certain conditions.

Conditions and problem mapping

Function withKnownIssue It also allows you to set the conditions under which a known issue is considered relevant and compare errors with certain criteria.

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
        }
        // Дополнительные проверки и логика теста
    }
}

It is indicated here that the problem with the lack of fuel in the grill is relevant only if the grill is installed (FoodTruck.shared.hasGrill). It also checks that the object contains issue there is an error (issue.error != nil) to consider the problem valid.

Conclusion

Testing in Swift plays a key role in ensuring software quality. From unit tests to system checks, tests help developers make changes with confidence and maintain code stability. XCTest, as a standard framework, provides the basic capabilities for writing and running tests. However, the new Testing library offers convenient syntax and additional functions that make the testing process even more flexible and powerful.

We haven't covered all the features of Swift Testing yet, so let's keep experimenting! And I went on to watch the video from WWDC.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *