DEV Community

Michał Kowalski
Michał Kowalski

Posted on

Parametrizing unit tests with XCTestParametrizedMacro

Unit testing is a software testing technique where a software program is dissected into smaller components (units), and each unit undergoes independent testing.
Programmers create and execute unit tests during development, considering them as a crucial factor for ensuring a high-quality software product.
Integrating unit tests into our product provides numerous benefits, including early bug detection in the development process, reinforcement of good design patterns, effective documentation, and acceleration of the overall development process.

Well-written unit tests should check the correctness of our code not only for one variant but it should check all different combinations of input data so the code is fully covered by unit tests.
Such requirements always result in running our test method multiple times with different arguments.
This causes the process of writing unit tests to become boring and developers often resign from testing their code for more than one case.

In this article, I'd like to show how with a simple swift macro you can reduce the amount of boilerplate code needed to write unit test

Real life scenario

Now let's consider real real-life example. Assume we have an account view in our app that presents the name, current balance, and loading indicator when fetching data.
We follow well known MVVM pattern so to separate our logic from the view we created the viewModel class AccountViewModel and now we want to test its correctness for different kinds of accounts.

So our first test method would look like this

    func testAccountViewModel_SavingAccount() throws {
        let viewModel = AccountViewModel(accountService: accountServiceMock,
                                                 accountId: "001")
        XCTAssertTrue(viewModel.isLoading)
        let expectation = expectation(description: "refresh call")
        viewModel.refresh {
                    expectation.fulfill()
                }
        wait(for: [expectation])
        XCTAssertFalse(viewModel.isLoading)
        XCTAssertEqual(viewModel.accountName, "SavingAccount")
        XCTAssertEqual(viewModel.balance, NSDecimalNumber(string: "3000"))
    }
Enter fullscreen mode Exit fullscreen mode

But if we want to test for the other two types of account we have two options: put everything into single method by copy and paste which result in very long test method or create separate method for every test case like this.

    func testAccountViewModel_SavingAccount() throws {
        let viewModel = AccountViewModel(accountService: accountServiceMock,
                                                 accountId: "001")
        XCTAssertTrue(viewModel.isLoading)
        let expectation = expectation(description: "refresh call")
        viewModel.refresh {
                    expectation.fulfill()
                }
        wait(for: [expectation])
        XCTAssertFalse(viewModel.isLoading)
        XCTAssertEqual(viewModel.accountName, "SavingAccount")
        XCTAssertEqual(viewModel.balance, NSDecimalNumber(string: "3000"))
    }

    func testAccountViewModel_KreditAccount() throws {
        let viewModel = AccountViewModel(accountService: accountServiceMock,
                                                 accountId: "002")
        XCTAssertTrue(viewModel.isLoading)
        let expectation = expectation(description: "refresh call")
        viewModel.refresh {
                    expectation.fulfill()
                }
        wait(for: [expectation])
        XCTAssertFalse(viewModel.isLoading)
        XCTAssertEqual(viewModel.accountName, "KreditAccount")
        XCTAssertEqual(viewModel.balance, NSDecimalNumber(string: "-15000"))
    }

    func testAccountViewModel_BasicAccount() throws {
        let viewModel = AccountViewModel(accountService: accountServiceMock,
                                                 accountId: "003")
        XCTAssertTrue(viewModel.isLoading)
        let expectation = expectation(description: "refresh call")
        viewModel.refresh {
                    expectation.fulfill()
                }
        wait(for: [expectation])
        XCTAssertFalse(viewModel.isLoading)
        XCTAssertEqual(viewModel.accountName, "BasicAccount")
        XCTAssertEqual(viewModel.balance, NSDecimalNumber(string: "2345"))
    }
Enter fullscreen mode Exit fullscreen mode

No matter what we choose the result will be a huge amount of duplicated code. So in case of even small refactoring in the future we would end up with changing the same code in few places.

But we can be little smarter and extract test logic to generic method and pass input and output data as parameters.

    func testAccountViewModel_AllAccounts() throws {
        genericTestMethod(id: "001", balance: NSDecimalNumber(string: "3000"), accountName: "SavingAccount")
        genericTestMethod(id: "002", balance: NSDecimalNumber(string: "-15000"), accountName: "KreditAccount")
        genericTestMethod(id: "003", balance: NSDecimalNumber(string: "2345"), accountName: "BasicAccount")
    }

    private func genericTestMethod(id: String, balance: NSDecimalNumber, accountName: String) {
        let viewModel = AccountViewModel(accountService: accountServiceMock,
                                                 accountId: id)
        XCTAssertTrue(viewModel.isLoading)
        let expectation = expectation(description: "refresh call")
        viewModel.refresh {
                    expectation.fulfill()
                }
        wait(for: [expectation])
        XCTAssertFalse(viewModel.isLoading)
        XCTAssertEqual(viewModel.accountName, accountName)
        XCTAssertEqual(viewModel.balance, balance)
    }
Enter fullscreen mode Exit fullscreen mode

One disadvantage of such a solution is that in case of failure, we lose information for which data test is failing.

To improve this we could add some messages to assertion but I think a beter solution would be to create a test method with a meaningful name.
So in case of failing tests in Xcode's test navigator, we know exactly which test method is failing.

    func testAccountViewModel_SavingAccount() throws {
        genericTestMethod(id: "001", balance: NSDecimalNumber(string: "3000"), accountName: "Saving Account")
    }

    func testAccountViewModel_KreditAccount() throws {
        genericTestMethod(id: "002", balance: NSDecimalNumber(string: "-15000"), accountName: "Kredit Account")
    }

    func testAccountViewModel_BasicAccount() throws {
        genericTestMethod(id: "003", balance: NSDecimalNumber(string: "2345"), accountName: "Basic Account")
    }
Enter fullscreen mode Exit fullscreen mode

Our test methods look good, but we would like to focus on writing productive code not copying and renaming test methods.

Can we do it better?

Meet Swift Macros

Swift Macros are a key feature of Swift since version 5.9. This framework gives developers the ability to write code snippets that are expanded at compile time. Macros allow for the creation of reusable and customizable pieces of code, streamlining development processes and making code more expressive by removing boilerplate code.

It seems to be perfect for our unit test scenario.

Together with my colleague, @mmysliwiec from Xebia Poland, we created a Swift macro called XCTestParametrizedMacro designed for this case.

The @Parametrize macro takes three parameters:

  • An array of input objects, in our case, it is an array of strings representing a list of account IDs.
  • An array of output objects (expected values), in this test, there are two expected values (the name of the account and account balance), so the output array contains a list of tuples (String, NSDecimalNumber).
  • An array of labels. It is not required, but with labels, we can improve the naming of generated test methods.
    @Parametrize(input: ["001",
                         "002",
                         "003"],
                 output: [("Saving Account", NSDecimalNumber(string: "3000")),
                          ("Kredit Account", NSDecimalNumber(string: "-15000")),
                          ("Basic Account", NSDecimalNumber(string: "2345"))],
                 labels: ["SavingAccount",
                          "KreditAccount",
                          "BasicAccount"])
    func testAccountViewModel(input accountId: String,
                              output expected: (String, NSDecimalNumber)) throws {
        let viewModel = AccountViewModel(accountService: accountServiceMock,
                                         accountId: accountId)
        XCTAssertTrue(viewModel.isLoading)
        let expectation = expectation(description: "refresh call")
        viewModel.refresh {
            expectation.fulfill()
        }
        wait(for: [expectation])
        XCTAssertFalse(viewModel.isLoading)
        XCTAssertEqual(viewModel.accountName, expected.0)
        XCTAssertEqual(viewModel.balance, expected.1)
    }
Enter fullscreen mode Exit fullscreen mode

Such code will be expanded to three test methods like this one.

func testAccountViewModel_SavingAccount() throws {
    let accountId: String = "001"
    let expected: (String, NSDecimalNumber) = ("Saving Account", NSDecimalNumber(string: "3000"))
    let viewModel = AccountViewModel(accountService: accountServiceMock,
                                             accountId: accountId)
    XCTAssertTrue(viewModel.isLoading)
    let expectation = expectation(description: "refresh call")
    viewModel.refresh {
                expectation.fulfill()
            }
    wait(for: [expectation])
    XCTAssertFalse(viewModel.isLoading)
    XCTAssertEqual(viewModel.accountName, expected.0)
    XCTAssertEqual(viewModel.balance, expected.1)
}
Enter fullscreen mode Exit fullscreen mode

With XCTestParametrizedMacro, developers can focus on writing good test methods and forget about boilerplate code. With the macro, our tests are more readable; input and output parameters are separated from the test methods. With this tool, your tests are more in line with Data-driven testing.

XCTestParametrizedMacro is available on GitHub https://github.com/PGSSoft/XCTestParametrizedMacro

We are Looking forward to any feedback or contribution, if you like the idea please leave a star.

Top comments (1)

Collapse
 
mmysliwiec profile image
Michał Myśliwiec

It was a real pleasure to implement this macro with @_mkowalski. It's beneficial in a variety of scenarios. At Xebia, we're already using it for our commercial projects and it simplifies unit data-driven testing A LOT!