Parameterized Tests

Writing Smarter, Drier Tests

One of the guiding principles in software development is “Don’t Repeat Yourself” (DRY). This applies to test code just as much as it does to application code. A common anti-pattern in testing is writing multiple, nearly identical tests that check the same piece of logic with slightly different inputs. This leads to a bloated, hard-to-maintain test suite.

Swift Testing provides a powerful and elegant solution to this problem: parameterized tests.

The Problem of Boilerplate

Imagine you have a function that validates email addresses. To test it thoroughly, you need to check multiple cases: a valid email, an email without an "@" symbol, an email without a domain, an email with invalid characters, and so on. Without parameterized tests, you would end up with a lot of repetitive code :  

let validator = EmailValidator()

@Test func testValidEmail() {
  #expect(validator.isValid("test@example.com") == true)
}

@Test func testMissingAtSymbol() {
  #expect(validator.isValid("testexample.com") == false)
}

@Test func testMissingDomain() {
  #expect(validator.isValid("test@") == false)
}

//... and so on for many more cases...

This approach is verbose and inefficient. If the EmailValidator API changes, you have to update every single test function.

Introducing Parameterized Tests

Parameterized tests allow you to write a single test function and run it multiple times with different arguments. You provide the arguments directly in the @Test attribute using the arguments: parameter.  

We can refactor the previous example into a single, clean parameterized test:

@Test(
  "Test email validation with various valid inputs",
  arguments: [
    "test@example.com",
    "user.name+tag@domain.co.uk",
    "firstname.lastname@sub.domain.org"
  ]
)
func testValidEmails(email: String) {
  let validator = EmailValidator()
  #expect(validator.isValid(email) == true)
}

In this example, the testValidEmails function will be executed three times, once for each email string in the arguments array. The value from the array is passed as the email parameter to the function for each run.

Debugging and Results

A key advantage of this feature is how Xcode presents the results. The Test Navigator doesn’t show a single test result; it treats each argument’s run as a distinct sub-test. If one of the arguments causes a failure, it will be clearly marked with a red ‘x’, pinpointing exactly which input is problematic. This makes debugging far more efficient than iterating through a loop inside a single test, where a failure on one iteration might not be clearly reported. You can even re-run the test for a single, specific failing argument directly from the Test Navigator.  

Advanced Argument Techniques

Swift Testing’s parameterization capabilities go beyond a single list of arguments.

  • Paired Data with zip: A very common scenario is testing a set of inputs against a corresponding set of expected outputs. You can achieve this using Swift’s built-in zip function to create pairs of arguments.  
@Test(
  "Test email validation with inputs and expected outcomes",
  arguments: zip(
    ["test@example.com", "invalid-email", "test@", ""],
    [true, false, false, false]
  )
)
func testEmailValidation(input: String, expectedResult: Bool) {
  let validator = EmailValidator()
  #expect(validator.isValid(input) == expectedResult)
}

Here, zip creates tuples ("test@example.com", true), ("invalid-email", false), and so on. The test function is called for each tuple, with the elements passed as the input and expectedResult parameters.

  • Multiple Collections (Cartesian Product): If you provide multiple, separate argument collections, Swift Testing will run the test for every possible combination of the inputs (the Cartesian product). This can be useful but should be used with care, as it can lead to a very large number of test runs.  
@Test(
  arguments: , ["a", "b"]
)
func testCombinations(number: Int, letter: String) {
  // This will run 6 times:
  // (1, "a"), (1, "b")
  // (2, "a"), (2, "b")
  // (3, "a"), (3, "b")
}

Parameterized tests represent a significant evolution in testing methodology. They encourage a more systematic and comprehensive approach by shifting the developer’s focus from testing isolated examples to testing a function’s behavior across its entire input domain. This feature makes it natural and easy to implement testing best practices like boundary value analysis (testing the edges of valid ranges) and equivalence class partitioning (testing one representative from a group of similar inputs), ultimately leading to more robust and higher-quality software.  

See forum comments
Download course materials from Github
Previous: Advanced Test Customization with Traits Next: Conclusion