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 throws
which 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 FileHandler
which 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:
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() { ... } }
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 astags()
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
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
).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 categoriesregression
Andui
which means it can be run both when running regression tests and when testing the UI.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 tagperformance
allowing 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
taggedfast
in debug mode and tagsslow
Andfull
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 require
each 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 expect
but 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
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 completeperformAsyncTask
. When the task completes, calltaskCompleted()
confirming the completion of the task.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 inEventManager
. When the event is processed, it is calledeventHandled()
which confirms that the event has been processed.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
Confirmation
with the expected quantity0
.
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
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 suiteCashRegisterTests
will be skipped ifCashRegister.isEmpty
will returntrue
. 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 testtestCashRegisterOperation
and indicates that the test will only be executed ifCashRegister.hasMoney
will returntrue
. This allows you to avoid running a test if the required conditions are not met.
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 testtestPizzaOrder
will only be executed if there is an item in the menupizza
.@Test(.enabled(if: Menu.hasItem(.sushi)))
: This testtestSushiOrder
will only be executed if there is an item in the menusushi
.
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.