Streamlining iOS Automation with Swift Concurrency

Swift Concurrency Dec 6, 2024

At Halodoc, we're committed to pushing the boundaries of mobile testing. As Swift continues to evolve, we're excited to leverage the latest advancements to build more efficient, reliable, and maintainable automation frameworks.

One such groundbreaking feature is Swift's structured concurrency, introduced in Swift 5.5. This powerful paradigm revolutionises asynchronous programming by offering a more intuitive and expressive syntax. By adopting async/await, we've significantly simplified our XCUI tests, making them more robust and less prone to flakiness.

Async/Await: Enhancing XCUITest Clarity and Efficiency

In the realm of UI testing, asynchronous operations are an inherent part of the process. From waiting for network responses and handling dynamic UI elements to synchronizing with backend systems, our tests often require managing multiple concurrent tasks. Historically, handling these asynchronous flows in a reliable and maintainable way was complex and error-prone, leading to challenges in ensuring the accuracy and stability of tests.

Before Swift Concurrency, we were using PromiseKit, a third-party library to simplify handling asynchronous code. While PromiseKit offered a structured way to deal with promises and callbacks, it still required careful attention to avoid common pitfalls such as race conditions, deadlocks, or unhandled failures.

Swift's introduction of native concurrency, however, provides a much-needed solution. By embracing async/await, we can write asynchronous code that reads like synchronous code. This not only enhances readability but also improves the overall maintainability of our test suites.

Benefits of Swift Concurrency in Automation

  • Simplified Asynchronous Operations: async/await allows us to write clean and concise code for handling asynchronous tasks, such as waiting for elements to appear on the screen or making API calls to simulate and perform end-end testing.
  • Improved Test Reliability and Readability: By effectively synchronising asynchronous operations, we can significantly minimise the risk of race conditions and timing-related failures, leading to more stable and reliable test outcomes and also async/await promotes a more declarative style of programming, making our tests easier to understand and debug.
  • Reduced Reliance on Third-Party Libraries: Swift's native concurrency features eliminate the need for external dependencies like PromiseKit and helps in streamline API calls in automation.
  • Enhanced Debugging and Productivity: Third-party libraries like PromiseKit simplify asynchronous programming but require manual management of promise chains and dependencies, complicating debugging. Swift Concurrency offers a more efficient, native solution with async/await and clear error handling, enhancing debugging, improving readability, and reducing development time, ultimately boosting productivity.

In this blog, we'll delve deeper into the practical applications of Swift Concurrency in XCUI automation. We'll explore specific use cases and provide code examples to illustrate how you can leverage this powerful concurrency Framework to build more robust and efficient test suites.

Understanding Swift Concurrency: Revolutionising Asynchronous Code in Swift

Swift Concurrency is a powerful framework designed to simplify writing concurrent and asynchronous code in Swift. This framework brings a suite of language features and tools that streamline the process of creating efficient, scalable applications that fully leverage multiple processors and cores.

With Swift Concurrency, Apple introduces the async and await keywords, which enable developers to write asynchronous code that reads more sequentially, similar to synchronous code. The async keyword designates a function as asynchronous, allowing it to perform time-consuming operations without blocking the main thread, while await pauses execution until an asynchronous task completes, making code easier to follow and reducing callback complexity. To dive deeper into Swift Concurrency and explore its full potential, be sure to check out our in-depth blog on "Swift Concurrency Adoption at Halodoc".

Migrating from PromiseKit to Swift Concurrency for Streamlined UI Automation

It is essential to ensure that your application works as expected in real-world situations. By simulating API calls during UI tests, you can automate tasks like pre-populating data, mocking external services, and verifying end-to-end workflows. This allows you to test how the UI handles different API responses, including errors and ensures the application performs well under varying conditions. Automating these actions also speeds up repetitive testing and provides more consistent results, especially in CI/CD pipelines, helping catch issues early and improving overall test coverage.

Previously, PromiseKit was used to handle API calls, but this approach often led to increased complexity and longer code. In the example below, observe the difference in code readability and structure between PromiseKit and Swift Concurrency, demonstrated by simulating an API call to update an order status. This comparison highlights how Swift Concurrency’s async/await can streamline asynchronous operations, reduce callback nesting, and create a cleaner, more maintainable codebase.

Implementing API Calls Using PromiseKit

PromiseKit relies on a callback mechanism to either fulfil or reject the promise, which can sometimes lead to more complex error handling and a less straightforward flow. Here’s an example of how we can utilise PromiseKit to automate an action via remote API call.

Using PromiseKit to Make an API Call
Calling PromiseKit API from XCUI Test Script

As you can observe in PromiseKit-based implementation, additional setup is needed. You define a promise, handle its fulfilment or rejection, and manage the flow with callbacks.

Key Challenges with PromiseKit:

  • Increased Boilerplate: You have to create and manage the promise explicitly, which involves more code and indirect flow.
  • Callback Handling: Handling success or failure is done through .done() and .catch() closures, which can be cumbersome and nested, making the code harder to read and develop, especially when dealing with more complex logic or multiple asynchronous calls.
  • Error Handling: While the promise system provides a robust way to handle errors, it requires careful attention to ensure that errors are properly passed and handled through the promise chain.
  • Debugging: Debugging with PromiseKit can be challenging due to the fragmented nature of promise chains. The asynchronous flow is distributed across multiple .then, .done, and .catch blocks, making it harder to trace the execution path and identify where errors occur. Stack traces are often less intuitive, and the added complexity of nested promises can obscure the root cause of issues, especially in scenarios involving multiple dependent asynchronous operations.

Implementing API Calls Using Swift Concurrency

Swift Concurrency manages asynchronous code much simpler and more intuitively. Here’s how the same API call automation looks using Swift Concurrency.

Using swift concurrency to Make an API Call

In this function, updateOrderStatus(), Swift Concurrency's async/await syntax is used to make a network request. Here’s what makes this approach streamlined and efficient:

  • Asynchronous Function (async): The function is marked with async, indicating that it performs asynchronous operations. This allows it to be called with await to pause execution until the asynchronous task is completed.
  • Error Handling (throws): The function is marked with throws, meaning it can throw an error if something goes wrong, making error handling straightforward and integrated into the function.
  • Network Request (try await): The line try await URLSession.shared.data(for: request) makes an asynchronous network call to retrieve data for the given request. The try await keywords ensures,  await pauses execution until the response is received and try allows the code to catch any error thrown during the request.
Calling Swift Concurrency API from XCUI Test Script

In this test function, testOrderStatusUpdate(), we’re testing the updateOrderStatus() function in an asynchronous context. Here’s a step-by-step explanation of how it works:

  • Expectation Setup: The line let expectation = expectation(description: "Order status should be updated") creates an expectation object. This is used to pause the test until the asynchronous task is completed.
  • Task Context (Task): A Task is created to call the async function updateOrderStatus() within the test. This provides an asynchronous context, allowing the use of await within the test function.
  • Calling the Asynchronous Function (try await): Inside the Task, try await updateOrderStatus() calls the updateOrderStatus function and waits for its completion. If it succeeds, the expectation is fulfilled.
  • Error Handling (catch): If an error occurs in updateOrderStatus, it is caught in the catch block. XCTFail is called with an error message, marking the test as failed.
  • Expectation Fulfillment:expectation.fulfill() is called only if updateOrderStatus() completes successfully, signaling that the test can proceed.
  • Waiting for the Test to Complete:wait(for: [expectation], timeout: 10) pauses the test execution for up to 10 seconds, waiting for the expectation to be fulfilled. If it’s not fulfilled within this timeframe, the test will fail due to a timeout, ensuring it doesn’t hang indefinitely.

The PromiseKit-based approach involves a bit more boilerplate code, including setting up a promise and managing asynchronous behaviour with callbacks. On the other hand, with Swift Concurrency, the code is much more streamlined. By using async/await, we can perform the same network request in a more declarative way. There’s no need for promises or explicit callbacks, Swift handles the suspension and resumption of the task internally. Error handling is simpler and more intuitive, and the overall structure is cleaner and easier to follow.

UI Testing with Async Operations

Swift Concurrency has transformed UI testing by making it easier to handle asynchronous operations, such as waiting for UI elements to appear or update. By leveraging async/await, tests become more streamlined, maintainable, and readable. This eliminates the need for complex waiting mechanisms and allows tests to adapt naturally to varying conditions.

Here’s how different approaches compare when implementing UI waits in XCUITest:

Sleep Statements in XCUITest

Using sleep introduces arbitrary delays, which can make tests unreliable and waste execution time and slow's down tests unnecessarily if the element appears quickly.

Using Sleep inside XCUI Test Block

Custom Wait Inside XCUITest

A reusable custom waiting function provides better control and avoids unnecessary delays. This approach uses predicates to wait for an element to appear dynamically.

Using custom wait inside XCUI Test Block

Replace sleep with async/await

Swift’s async/await makes the waiting logic even more straightforward by allowing the test to pause naturally until the condition is met.

Using async/await Inside XCUI Test Block.

Although waitForExistence(timeout: 5) is simple to use directly without following any of the approaches explained above, its generic failure messages make debugging more challenging when UI elements are missing. Similarly, the static sleep approach, which pauses for a fixed duration regardless of the application’s state, often leads to unnecessary delays and inefficiency. In contrast, async/await introduces a more dynamic and responsive mechanism, waiting only as long as necessary and improving both test reliability and execution speed.

Compared to a custom waiting function, which provides better control and tailored failure messages, async/await takes it a step further by greatly simplifying the implementation. It removes the need for complex predicates or manual logic while ensuring tests remain concise, maintainable, and highly readable. By leveraging Swift Concurrency, developers can write UI tests that are not only efficient and adaptable but also modern and robust, setting a higher standard for automation testing practices.

Conclusion

At Halodoc, transitioning from PromiseKit to Swift Concurrency has significantly improved our productivity and testing workflows. The adoption of the async/await syntax has simplified asynchronous code, reducing the cognitive overhead of managing complex promise chains. This clarity in code structure has made debugging faster and easier, enabling us to identify and resolve issues more efficiently.

Moreover, Swift Concurrency's Task model and native thread-safety mechanisms have streamlined our approach to managing concurrent operations, eliminating common pitfalls like race conditions and thread-related bugs. These enhancements have not only improved the reliability and maintainability of our test automation but have also accelerated the generation of test cases. As a result, our team can now focus on writing meaningful tests with greater agility, ensuring faster delivery of a high-quality product while minimising the complexities of asynchronous logic.

Join us

Scalability, reliability, and maintainability are the three pillars that govern what we build at Halodoc Tech. We are actively looking for engineers at all levels and if solving hard problems with challenging requirements is your forte, please reach out to us with your resumé at careers.india@halodoc.com.

About Halodoc

Halodoc is the number one all-around healthcare application in Indonesia. Our mission is to simplify and bring quality healthcare across Indonesia, from Sabang to Merauke. We connect 20,000+ doctors with patients in need through our Tele-consultation service. We partner with 3500+ pharmacies in 100+ cities to bring medicine to your doorstep. We've also partnered with Indonesia's largest lab provider to provide lab home services, and to top it off we have recently launched a premium appointment service that partners with 500+ hospitals that allow patients to book a doctor appointment inside our application. We are extremely fortunate to be trusted by our investors, such as the Bill & Melinda Gates Foundation, Singtel, UOB Ventures, Allianz, GoJek, Astra, Temasek, and many more. We recently closed our Series D round and in total have raised around USD$100+ million for our mission. Our team works tirelessly to make sure that we create the best healthcare solution personalised for all of our patient's needs, and are continuously on a path to simplify healthcare for Indonesia.

Syed Mansoor

Software Engineer @ Halodoc