Grouping Tests with @Suite
Organizing Your Test Suite
As your project grows, so will your number of tests. A flat list of hundreds of test functions quickly becomes unmanageable. To maintain clarity and efficiency, you need a robust way to organize them. Swift Testing provides two powerful and complementary organizational tools: @Suite for creating a structural hierarchy, and Tags for flexible, semantic categorization.
Grouping Tests with @Suite
In Swift Testing, a “suite” is simply a collection of related tests. The framework makes creating suites incredibly natural. Any Swift type—be it a struct, class, or actor—that contains one or more @Test functions is automatically considered a test suite, without any special declaration.
This allows you to group tests logically, often mirroring the structure of your application code. For example, all tests related to your UserManager could be placed inside a UserManagerTests struct.
struct UserManagerTests {
@Test("Test successful user login")
func testLoginSuccess() {
//...
}
@Test("Test failed login with wrong password")
func testLoginFailure() {
//...
}
}
Xcode’s Test Navigator will recognize this structure and display these tests grouped under “UserManagerTests.”
For more explicit control, or to apply traits to an entire group of tests, you can use the @Suite attribute. This allows you to provide a custom display name for the suite in the Test Navigator.
@Suite("User Authentication Flow")
struct UserManagerTests {
//... tests...
}
Suites can also be nested, enabling you to create a detailed hierarchy that reflects your app’s architecture. This is particularly useful for breaking down complex features into smaller, more manageable testing groups.
@Suite("Account Management")
struct AccountTests {
@Suite("Authentication")
struct AuthTests {
@Test func testLogin() { /*... */ }
@Test func testLogout() { /*... */ }
}
@Suite("Profile Editing")
struct ProfileTests {
@Test func testUpdateUsername() { /*... */ }
@Test func testUpdateAvatar() { /*... */ }
}
}
Setup and Teardown Logic
A common need in testing is to perform some setup work before each test runs (e.g., initializing a database, creating a mock object) and some cleanup work afterward (e.g., clearing the database, resetting state). In XCTest, this was handled by overriding the setUp() and tearDown() methods.
In Swift Testing, this logic is handled by the suite type’s standard initializer (init()) and deinitializer (deinit()). The init() is called before each test function in the suite runs, and deinit() is called after each test function completes. Note that to use deinit, the suite type must be a class.
@Suite("Database Operations")
class DatabaseTests {
var database: MockDatabase
init() {
// This runs BEFORE each test in this suite.
self.database = MockDatabase()
self.database.connect()
}
deinit {
// This runs AFTER each test in this suite.
self.database.disconnect()
}
@Test("Test adding a user")
func testAddUser() {
//... use self.database...
}
@Test("Test removing a user")
func testRemoveUser() {
//... use self.database...
}
}
In-Depth Analysis: The Power of Value-Semantic Suite Structs
One of the most profound design decisions in Swift Testing is its strong recommendation to use value types, specifically structs, for test suites. To understand why this is so important, we must first revisit the fundamental difference between value types and reference types in Swift.
-
Value Types (
struct,enum): When you assign a value type to a new variable or pass it to a function, a complete copy of the data is made. The new variable has its own independent instance. Changes made to the copy do not affect the original. -
Reference Types (
class): When you assign a reference type, you are not copying the data itself, but rather a reference (or pointer) to a single, shared instance in memory. Both the original and the new variable point to the exact same object. A change made through one variable is visible through the other.
Swift Testing leverages the safety of value semantics in a crucial way: for a suite defined as a struct, the framework creates a new, separate instance of that struct for every single test function it contains.
This behavior guarantees test isolation and eliminates an entire class of common testing problems. Consider the following example:
@Suite("Shopping Cart Tests")
struct ShoppingCartTests {
var cart = ShoppingCart() // A struct to hold items
@Test("Adding one item")
mutating func testAddingFirstItem() {
cart.add(item: "Apple")
#expect(cart.items.count == 1)
}
@Test("Adding two items")
mutating func testAddingSecondItem() {
cart.add(item: "Banana")
#expect(cart.items.count == 1) // This test will PASS
}
}
In a traditional, class-based testing framework, the cart property would be a single, shared instance. If testAddingFirstItem ran first, it would add “Apple” to the cart. Then, when testAddingSecondItem ran, it would add “Banana” to the same cart, resulting in a count of 2, and the test would fail. The outcome of one test would depend on the execution order of another, leading to flaky, unreliable tests.
With Swift Testing’s value-semantic approach, this problem vanishes.
- Before running
testAddingFirstItem, the framework creates aShoppingCartTestsinstance. Thecartinside it is empty. The test adds “Apple” and passes. This instance is then discarded. - Before running
testAddingSecondItem, the framework creates a brand newShoppingCartTestsinstance. The cart inside this new instance is also empty. The test adds “Banana”, the count becomes 1, and the test passes.
Each test runs in a pristine, isolated environment, free from the side effects of other tests. This embraces Swift’s core philosophies of safety and predictability, making your test suite more robust and easier to reason about.
Flexible Categorization with Tags
While suites provide a rigid, hierarchical structure, you often need a more flexible way to group tests that share a common characteristic, regardless of where they are in the source code. For example, you might want to run all “critical” tests, or all tests related to “networking,” or all tests that are known to be “slow.”
This is where Tags come in. Tags are a type of trait that provides semantic information, allowing you to categorize tests across different suites and files.
-
Defining Tags
You define custom tags by creating an extension on the
Tagtype and declaring static properties with the@Tagattribute.
import Testing
extension Tag {
@Tag static var networking: Self
@Tag static var critical: Self
@Tag static var slow: Self
}
-
Applying Tags
You apply one or more tags to a test or an entire suite using the
.tags()trait. If applied to a suite, all tests within that suite automatically inherit the tags.
@Suite("API Client",.tags(.networking))
struct APIClientTests {
@Test("Fetch user profile",.tags(.critical))
func testFetchUserProfile() {
//...
}
@Test("Upload large file",.tags(.slow))
func testFileUpload() {
//...
}
}
-
Using Tags
The real power of tags is realized in Xcode and on the command line. In the Xcode Test Navigator, you can switch to a tag-based view, which shows all your tests grouped by their assigned tags. This allows you to run all tests with a specific tag—for example, all
.criticaltests—with a single click.
This dual system of organization is incredibly powerful. @Suite provides a structural organization that mirrors your code’s architecture, while Tags provide a semantic organization based on the characteristics of the tests themselves. This multi-dimensional control is invaluable for managing large test suites and for creating sophisticated continuous integration (CI) workflows. For instance, a CI pipeline could be configured to run all tests except those tagged .slow on every commit for rapid feedback, while running the complete test suite, including the slow ones, nightly or before a release.