This post is Merpay & Mercoin Advent Calendar 2024, brought to you by @cyan from the Mercoin iOS team.
Hi! My name is Cyan, and I’m one of the members of the Mercoin iOS Team. This will be my first time writing a blog for Mercari, so I am hoping that you’ll enjoy reading this post. In this blog post, I’d like to share some learnings about Swift Testing.
Personally, I think that Swift Testing is easier to use and more complete than XCTest.
Swift Testing is a new Unit Testing framework introduced by Apple at this year’s WWDC24. This is meant to be the successor of the much used XCTest framework. Swift Testing can only be used from Xcode 16, so if your team haven’t updated the project yet, maybe it’s time to update now 🙂
Let’s start!
Attributes and Macros
@Test
When we were using XCTest, we would add test
at the beginning of the function name to make the function as a test case.
import XCTest
func test_defaultValue() {
// ...
}
But for Swift Testing, we don’t need to add test
but instead use a @Test
attribute.
import Testing
@Test func defaultValue() {
// ...
}
Same with XCTest’s test functions, we could still add async
, throws
, and @MainActor
on our tests.
#expect
This macro is used for actually doing the checking. It is the same with XCTest’s XCAssert
functions. Although, the one key difference of Swift Testing with XCTest is that we don’t need specific functions for different cases on checking.
For XCTest, we can use all of these functions:
XCTAssert, XCTAssertTrue, XCTAssertFalse
XCTAssertNil, XCTAssertNotNil
XCTAssertEqual, XCTAssertNotEqual
XCTAssertIdentical, XCTAssertNotIdentical
XCTAssertGreaterThan, XCTAssertGreaterThanOrEqual
XCTAssertLessThan, XCTAssertLessThanOrEqual
However, in Swift Testing, you can just do it just like these:
#expect(amount == 5000)
#expect(user.name == "Hoge")
#expect(!array.isEmpty)
#expect(numbers.contains(1))
#expect(paymentAmount > 0)
We only need to pass an expression to #expect, which is way simpler and easier to remember.
#require
This macro is used when you want to have a required expectation. Meaning, when this test case fails, the entire test will stop and fail.
try #require(date.isValid) // ← if it fails here...
#expect(date, Date(timeIntervalSince1970: 0)) // ← then this is not executed
Additionally, this can also be used when you want to unwrap optional values, and stop the test when the said optional value is nil
.
let method = try #require(paymentMethods.first) // ← if .first is nil...
#expect(paymentMethods.isCreditCard) // ← then this is not executed
Traits
These are new in Swift Testing, and these provide much easier ways to customize our unit tests. There are lots of traits introduced, so I tried to categorize them into 3 categories so it’s easier to remember them:
- Detail-related Traits
- Condition-related Traits
- Behavior-related Traits
Detail-related Traits
Display Name
This trait allows us to add a name to our test case. Of course, we could know from the function name what the test case does, but it would be easier to understand it if we use this Display Name trait since we could add spaces on it.
@Test("Check default value when there’s a plan")
func defaultValueWithPlan() {
let dependency = Dependency(plan: 1000)
#expect(selectedAmount == 1000)
}
Trait .bug
This trait allows us to link an issue if the said test case was added after fixing a particular bug.
@Test(.bug("example.com/issues/123", "Check default value when there’s no plan")
func defaultValueWithNoPlan() throws {
…
let firstAmountOption = try #require(amounts.first)
#expect(selectedAmount == firstAmountOption)
}
Trait .tags
This trait allows us to add a tag to the test case, and be able to see it on the left side panel of Xcode for easier organization of test cases. Firstly, we’d have to have an extension for Tag to add our desired tags.
extension Tag {
@Tag static var formatting: Self
@Tag static var location: Self
@Tag static var playback: Self
@Tag static var reviews: Self
@Tag static var users: Self
}
And then, you could use it like this:
struct SwiftTestingDemoTests {
@Test(.tags(.formatting)) func rating() async throws {
// add #expect here
}
…
@Test(.tags(.location)) func getLocation() async throws {
// add #expect here
}
…
@Test(.tags(.reviews)) func addReviews() async throws {
// add #expect here
}
}
You’ll see something like this:
You can group tests into a Suite, and then add a tag on that Suite. That would add the tags to all the tests inside that Suite.
@Suite(.tags(.defaultValue)) // ← add .tags here
struct SelectedAmountDefaultValue {
@Test func defaultValueWithPlan() async throws {
…
}
@Test func defaultValueWithNoPlan() async throws {
…
}
}
I’ll share more about Suites later 🙇
Condition-related Traits
Trait .enabled
This trait allows us to specify a condition if we want to run our test case or not.
@Test(.enabled(if: FeatureFlag.isAccordionEnabled))
func defaultValueAccordionState() {
// ...
}
Trait .disabled
This trait allows us to unconditionally disable a test. This could be useful when you have flaky tests in your project and it is causing delays.
@Test(.disabled("Due to flakiness"))
func flakyTestExample() {
// ...
}
Trait @available
This trait allows us to add a condition if the test should be run or not depending on the OS version.
@Test
@available(macOS 15, *)
func caseForFunctionThatUsesNewAPIs() {
// ...
}
Tip:
It is recommended by Apple to use @available
instead of checking at runtime using #available
.
// ✖︎ Avoid checking availability at runtime using #available
@Test func caseForFunctionThatUsesNewAPIs() {
guard #available(macOS 15, *) else { return }
// ...
}
// ⚪︎ Prefer @available attribute on test function
@Test
@available(macOS 15, *)
func caseForFunctionThatUsesNewAPIs() {
// ...
}
Behaviour-related Traits
Trait .timeLimit
This trait allows us to add a time limit to a test case. It could be useful in a case wherein you don’t want a particular function to run above a certain time threshold.
@Test(.timeLimit(.minutes(5)))
func someMethod() {
// ...
}
Trait .serialized
This trait allows us to have tests in a Suite to be run in order, instead of all at the same time.
@Suite(.serialized)
struct SelectedAmountDefaultValue {
@Test func defaultValueWithPlan() {
...
}
@Test func defaultValueWithNoPlan() {
...
}
}
Now that we’ve discussed Traits, let’s proceed to some tips and tricks that we could use with Swift Testing.
Pairing Traits
You could also use multiple Traits in one test case.
@Test(
.disabled("Due to a crash"),
.bug("example.org/bugs/123", "Crashes at <symbol>")
)
func testExample() {
// ...
}
Suites
You might have noticed that Suites were mentioned a few times in this article. Basically, a Suite is a group of test functions.
- annotated using
@Suite
. - could have stored instance properties.
- could also use
init
anddeinit
for set-up and tear-down logic, respectively. - initialized once per instance of
@Test
function.
@Suite(.tags(.defaultValue))
struct SelectedAmountDefaultValueNilPlanTests {
let dependency = Dependency(plan: nil)
init() throws {
...
}
deinit {
...
}
@Test("Check when there’s initial amount")
func withInitialAmount() {
// #expect…
}
@Test("Check when there’s no initial amount")
func withNoInitialAmount() {
// #expect…
}
}
Parameterized Testing
When you have some repetitive tests, you could use a parameterized @Test
function. An example of repetitive tests would be something like below:
// ✖︎ not recommended
struct CryptoCurrencyTests {
@Test func includesBTC() async throws {
let data = try await GetData()
let currency = try #require(data.first(where: { $0 == "BTC" } ))
#expect(currency == “BTC”)
}
@Test func includesETH() async throws {
let data = try await GetData()
let currency = try #require(data.first(where: { $0 == "ETH" } ))
#expect(currency == “ETH”)
}
// ...and more, similar test functions
}
Sure, you could use a for…in
loop to repeat a test, but that is not recommended.
// ✖︎ also not recommended - using a for…in loop to repeat a test
@Test func includesCryptoNames() async throws {
let cryptoNames = [
"BTC",
"ETH",
"CryptoA",
"CryptoB",
]
let data = try await GetData()
for cryptoName in cryptoNames {
let currency = try #require(data.first(where: { $0 == cryptoName } ))
#expect(currency == cryptoName)
}
}
Let’s try to use the Parameterized test function!
Changing it into a parameterized @Test
function would be something like this:
// ⚪︎ recommended
struct CryptoCurrencyTests {
@Test("Check master contains the correct cryptos", arguments: [
"BTC",
"ETH",
"CryptoA",
"CryptoB",
])
func includes(cryptoName: String) async throws {
let data = try await GetData()
let currency = try #require(data.first(where: { $0 == cryptoName } ))
#expect(currency == cryptoName)
}
}
Running Swift Testing via Command Line
Just like XCTest, we could also use Swift Testing in a command line so it could be usable in projects with CI/CD. Please use this command:
swift test
Migrating from XCTest
Actually, we could use Swift Testing alongside with XCTests. When we have similar XCTests, we could consolidate those into a parameterized @Test
function. And then finally, remove the test
from the names of the test cases.
Conclusion
Personally, I like Swift Testing more than XCTest. Swift Testing has improved a lot of things compared to XCTest, and would make it easier to create unit tests than before. Swift Testing can only be used from Xcode 16, so if you have not updated your project to use Xcode 16 just yet, you might have to wait for a little bit to start using Swift Testing.
That’s all. Thank you so much for staying!
I hope you enjoyed reading this article 🙂
References:
- https://developer.apple.com/videos/play/wwdc2024/10179
- https://developer.apple.com/documentation/testing/addingtags
- https://www.avanderlee.com/swift-testing/require-macro/
The next article will be by @Yani. Please look forward to it!