Etiqueta: tests

  • Dependency Injection implementations in Swift

    Dependency Injection implementations in Swift

    How to use dependency injection both manually and with a library like Swinject is valuable because it helps developers understand the core principles behind DI, such as decoupling and testability, before introducing them to more scalable, flexible solutions. By comparing both approaches, you empower readers to make informed architectural decisions based on project complexity and team needs. It appeals to a broad audience—from beginners learning the basics to experienced developers seeking to streamline their code using frameworks—and highlights the real-world tradeoffs between control and convenience, making it a practical and educational resource.

    DIP-Dependency injection principle

    Dependency injection is a software design principle in which an object or function receives the resources or dependencies it needs from external sources rather than creating them itself, promoting loose coupling and greater flexibility in code. By separating the responsibility of constructing dependencies from their usage, dependency injection makes programs easier to test, maintain, and modify, since dependencies can be swapped or mocked without changing the dependent code. This approach is closely related to the inversion of control principle, as it shifts the creation and management of dependencies to an external entity, often called an injector or container, allowing for more modular and configurable systems.

    For our example the interface will be ‘UserService’:

    protocol UserService {
        func fetchUsers() -> [User]
    }
    
    class DefaultUserService: UserService {
        func fetchUsers() -> [User] {
            return [
                User(id: 1, name: "Alice"),
                User(id: 2, name: "Bob")
            ]
        }
    }
    

    This is how View model uses the UserService interface:

    class UserListViewModel: ObservableObject {
        @Published var users: [User] = []
    
        private let userService: UserService
    
        init(userService: UserService) {
            self.userService = userService
            loadUsers()
        }
    
        func loadUsers() {
            self.users = userService.fetchUsers()
        }
    }

    The view that presents user list:

    struct UserListView: View {
        @ObservedObject var viewModel: UserListViewModel
    
        var body: some View {
            List(viewModel.users) { user in
                Text(user.name)
            }
        }
    }

    …but where is dependency injection implemented? You’re probably thinking that right now. Hold on a sec…

    @main
    struct ManualDIApp: App {
        var body: some Scene {
            WindowGroup {
                let userService = DefaultUserService()
                let viewModel = UserListViewModel(userService: userService)
                UserListView(viewModel: viewModel)
            }
        }
    }
    

    At that point in the code, an instance of DefaultUserService—which implements the UserService protocol—is being injected into the viewModel.

    Dependency injection by using Swinject

    Using a dependency injection library like Swinject becomes especially beneficial in larger or more complex iOS applications where managing dependencies manually can become tedious, error-prone, and hard to scale. Libraries automate the resolution and lifecycle of dependencies, support advanced features like scopes, circular dependencies, and conditional bindings, and reduce boilerplate code—making them ideal when your project has many services, view models, or interconnected modules. They also promote consistency and cleaner architecture across teams, especially in projects following patterns like MVVM or Clean Architecture, where dependency graphs can quickly grow intricate.

    First thing to do is add ‘https://github.com/Swinject/Swinject’ as SPM package:

    Look out! Add Swinject to SwinjectDI target, but Swinject-Dynamic to none. I faced compilations issues due to that.

    Using a dependency injection library like Swinject becomes especially beneficial in larger or more complex iOS applications where managing dependencies manually can become tedious, error-prone, and hard to scale. Libraries automate the resolution and lifecycle of dependencies, support advanced features like scopes, circular dependencies, and conditional bindings, and reduce boilerplate code—making them ideal when your project has many services, view models, or interconnected modules. They also promote consistency and cleaner architecture across teams, especially in projects following patterns like MVVM or Clean Architecture, where dependency graphs can quickly grow intricate.

    We have to declare a new component that will be responsible for resolving dependencies:

    import Swinject
    
    class DIContainer {
        static let shared = DIContainer()
        let container: Container
    
        private init() {
            container = Container()
    
            container.register(UserService.self) { _ in DefaultUserService() }
            container.register(UserListViewModel.self) { r in
                UserListViewModel(userService: r.resolve(UserService.self)!)
            }
        }
    }
    

    The code defines a singleton dependency injection container using the Swinject library to manage and resolve dependencies within an iOS application. The DIContainer class initializes a shared Container instance and registers two types: UserService, which is mapped to its concrete implementation DefaultUserService, and UserListViewModel, which depends on UserService and retrieves it from the container using resolution. By centralizing the creation of these objects, the code promotes loose coupling, testability, and maintainability, while Swinject handles the instantiation and dependency resolution automatically.

    import Swinject
    
    class DIContainer {
        static let shared = DIContainer()
        let container: Container
    
        private init() {
            container = Container()
    
            container.register(UserService.self) { _ in DefaultUserService() }
            container.register(UserListViewModel.self) { r in
                UserListViewModel(userService: r.resolve(UserService.self)!)
            }
        }
    }
    

    The code defines a singleton dependency injection container using the Swinject library to manage and resolve dependencies within an iOS application. The DIContainer class initializes a shared Container instance and registers two types: UserService, which is mapped to its concrete implementation DefaultUserService, and UserListViewModel, which depends on UserService and retrieves it from the container using resolution. By centralizing the creation of these objects, the code promotes loose coupling, testability, and maintainability, while Swinject handles the instantiation and dependency resolution automatically.

    @main
    struct SwinjectDIApp: App {
        var body: some Scene {
            WindowGroup {
                let viewModel = DIContainer.shared.container.resolve(UserListViewModel.self)!
                UserListView(viewModel: viewModel)
            }
        }
    }

    To implement dependency injection, we simply call the resolver to fetch the appropriate instance to be injected into UserListView. Notice that UserListViewModel also depends on UserService, but this dependency is also resolved by the DIResolver. In conclusion, we can observe that the lines of code required to construct the dependency stack have been reduced to a single line.

    Handling different Protocol implementations

    What we explained in the previous section covers most cases where dependency injection needs to be implemented. However, what happens when we have different protocol implementations? For example, consider a scenario where the same view is used in different application flows, but the data sources differ—one fetches data from a database, while the other uses a REST API.

    class DefaultUserService: UserService {
        func fetchUsers() -> [User] {
            return [User(id: 1, name: "Alice")]
        }
    }
    
    class DefaultUserServiceV2: UserService {
        func fetchUsers() -> [User] {
            return [User(id: 2, name: "Charlie")]
        }
    }
    

    We now have two classes that implement the UserService protocol. The following changes are required to build the dependency injection stack:

                // Screen 1
                let service1 = DefaultUserService()
                let viewModel1 = UserListViewModel(userService: service1)
    
                // Screen 2
                let service2 = DefaultUserServiceV2()
                let viewModel2 = UserListViewModel(userService: service2)

    View model is the same what it differs is the injected userService.

    Dependency injection by using Swinject

    The dependency injection stack usually consists of the same set of dependencies. This consistency is why third-party libraries like Swinject are beneficial—they take advantage of this common pattern.

    However, occasionally, you may encounter a rare case in your app where the dependency stack for a particular screen needs to be set up differently—for instance, when the data source differs.

    Here’s how the DIContainer resolves dependencies:

    class DIContainer {
        static let shared = DIContainer()
        let container: Container
    
        private init() {
            container = Container()
    
            container.register(UserService.self, name: "v1") { _ in DefaultUserService() }
            container.register(UserService.self, name: "v2") { _ in DefaultUserServiceV2() }
    
            container.register(UserListViewModel.self, name: "v1") { r in
                let service = r.resolve(UserService.self, name: "v1")!
                return UserListViewModel(userService: service)
            }
    
            container.register(UserListViewModel.self, name: "v2") { r in
                let service = r.resolve(UserService.self, name: "v2")!
                return UserListViewModel(userService: service)
            }
        }
    }

    The solution was implemented by registering the instance type with a tag name. When implementing dependency injection, we need to provide an additional name tag parameter.

    @main
    struct SwinjectDIApp: App {
        var body: some Scene {
            WindowGroup {
                let viewModelV1 = DIContainer.shared.container.resolve(UserListViewModel.self, name: "v1")!
                UserListView(viewModel: viewModelV1)
                
    //            let viewModelV2 = DIContainer.shared.container.resolve(UserListViewModel.self, name: "v2")!
    //            UserListView(viewModel: viewModelV2)
            }
        }
    }

    In the previous code chunk, "v1" is hardcoded, but it should be dynamic. Ideally, it should instantiate either viewModelV1 or viewModelV2 depending on the situation.

    Unit tests

    Dependency injection in unit testing typically involves injecting a mock that implements a protocol, allowing for deterministic and controlled responses.

    import Foundation
    @testable import ManualDI
    
    class DefaultUserServiceMock: UserService {
        func fetchUsers() -> [User] {
            return [
                User(id: 99, name: "Mocked User")
            ]
        }
    }

    Unit tests will look something like this:

    import Testing
    @testable import ManualDI
    
    struct ManualDITests {
    
        @Test func example() async throws {
            // Write your test here and use APIs like `#expect(...)` to check expected conditions.
            let mock = DefaultUserServiceMock()
            let viewModel1 = UserListViewModel(userService: mock)
            
            #expect(viewModel1.users.count == 0)
        }
    
    }

    Dependency injection by using Swinject

    Unit test will have to be implemented in following way:

    import Testing
    import Swinject
    @testable import SwinjectDI
    
    struct SwinjectDITests {
    
        @Test func example() async throws {
            // Write your test here and use APIs like `#expect(...)` to check expected conditions.
            let testContainer = Container()
            
            testContainer.register(UserService.self) { _ in DefaultUserServiceMock() }
    
             testContainer.register(UserListViewModel.self) { r in
                 UserListViewModel(userService: r.resolve(UserService.self)!)
             }
    
             let viewModel = testContainer.resolve(UserListViewModel.self)!
            #expect(viewModel.users.first?.name == "Mocked User")
        }
    }

    Never use the app-wide DIContainer.shared in tests — always use a local test container so you can inject mocks safely and independently.

    @Injected properly wrapper

    One more thing… By using following property wrapper:

    import Swinject
    
    @propertyWrapper
    struct Injected<T> {
        private var service: T
    
        init(name: String? = nil) {
            if let name = name {
                self.service = DIContainer.shared.container.resolve(T.self, name: name)!
            } else {
                self.service = DIContainer.shared.container.resolve(T.self)!
            }
        }
    
        var wrappedValue: T {
            service
        }
    }

    DIContiner keeps more simplified:

    class DIContainer {
        static let shared = DIContainer()
        let container: Container
    
        private init() {
            container = Container()
    
            container.register(UserService.self, name: "v1") { _ in DefaultUserService() }
            container.register(UserService.self, name: "v2") { _ in DefaultUserServiceV2() }
    
            container.register(UserListViewModel.self, name: "v1") { _ in UserListViewModel() }
            container.register(UserListViewModel.self, name: "v2") { _ in UserListViewModel() }
        }
    }

    And also viewmodel:

     class UserListViewModel: ObservableObject {
         @Published var users: [User] = []
    
         @Injected(name: "v1") private var userService: UserService
    
         func loadUsers() {
             self.users = userService.fetchUsers()
         }
     }

    Conclusions

    I did not try to convince you how useful Dependency Injection is; you can easily find information about it on the internet. Instead, I aim to show how, with a third-party library like Swinject, the process of setting up the Dependency Injection stack can be simplified.

    You can find source code used for writing this post in following repository

    References

  • Switching App Language on the Fly in Swift

    Switching App Language on the Fly in Swift

    Dynamically switching languages in views and using snapshot testing for multilingual validation it addresses a common challenge in global app development: ensuring seamless localization. Developers often struggle with updating UI text instantly when users change languages, and validating UI consistency across multiple locales can be tedious.

    We will cover dynamic language switching for streamlining the user experience, on the other side snapshot testing ensures visual accuracy without manual verification.

    Multilanguage iOS app

    After creating an iOS sample we are going to include the language catalog:

    Next step is adding a new language:

    For this example we have chosen Spanish:

    Next step we start to fill in string catalog for all languages defined:

    Language controller

    For implementing languange controller we will make use of Observer pattern, that means that this class state changes then all views subscribed to it will get notified and will be refreshed automatically:

    class LocalizationManager: ObservableObject {
        @Published var locale: Locale = .current {
            didSet {
                UserDefaults.standard.set([locale.identifier], forKey: "AppleLanguages")
                UserDefaults.standard.synchronize()
            }
        }
        
        init() {
            if let preferredLanguage = UserDefaults.standard.array(forKey: "AppleLanguages")?.first as? String {
                locale = Locale(identifier: preferredLanguage)
            }
        }
        
        func toggleLanguage() {
            if locale.identifier == "en" {
                locale = Locale(identifier: "es")
            } else {
                locale = Locale(identifier: "en")
            }
        }
    }

    This Swift class, LocalizationManager, is an ObservableObject that manages the app’s language settings. It stores the current locale in UserDefaults under the key "AppleLanguages", ensuring that the language preference persists across app restarts. The locale property is @Published, so any UI elements observing it will update when the language changes. The initializer retrieves the stored language preference from UserDefaults or defaults to the system’s current locale. The toggleLanguage() method switches between English ("en") and Spanish ("es") by updating locale, which in turn updates UserDefaults. However, changing this value does not dynamically update the app’s language in real-time without restarting.

    Finally build and run application:

    Multilanguage view

    View implementation is following:

    struct ContentView: View {
        @StateObject private var localizationManager = LocalizationManager()
        
        var body: some View {
            VStack {
                Text("welcome_message".localized(with: localizationManager.locale))
                    .padding()
                
                Button(action: {
                    localizationManager.toggleLanguage()
                }) {
                    Text("change_language".localized(with: localizationManager.locale))
                        .padding()
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(8)
                }
            }
            .environmentObject(localizationManager)
        }
    }

    This SwiftUI ContentView uses a @StateObject called LocalizationManager to manage language localization. It displays a localized welcome message and a button that allows users to toggle the app’s language. The Text elements use a .localized(with:) function to fetch translated strings based on the current locale from LocalizationManager. When the button is tapped, it calls toggleLanguage(), which presumably switches between different languages. The localizationManager is also injected into the SwiftUI environment using .environmentObject(), making it accessible throughout the app.

    Finally, cleaner code on view, and it is a practice that I have seen in many projects is to create an extension for finally  retrieve string translated, but this time with the add on that is parametrized with location language code:

    extension String {
        func localized(with locale: Locale) -> String {
            let language = locale.identifier
            guard let path = Bundle.main.path(forResource: language, ofType: "lproj"),
                  let bundle = Bundle(path: path) else {
                return NSLocalizedString(self, comment: "")
            }
            return bundle.localizedString(forKey: self, value: nil, table: nil)
        }
    }

    The extension adds a localized(with:) function to the String type, allowing it to return a localized version of the string based on the specified Locale. It first retrieves the locale’s language identifier and attempts to find the corresponding .lproj resource bundle in the app’s main bundle. If the bundle exists, it fetches the localized string from it; otherwise, it falls back to NSLocalizedString, which returns the string from the app’s default localization. This enables dynamic localization based on different languages.

    Snapshot testing

    Last but not least, apart from implementing a user interface that allows user to switch language when something is not propery understood on app regular usage. It opens up a new tesging scenario, we are possible to validate the same view presented in different languages, this will you make easier to detect texts that were not presented properly. 

    In Visual Regression Testing: Implementing Snapshots test on iOS post I present how to setup and implement snapshoptesting, is very easy setup. So basically we will bypass snapshot setup library and we will focus just only on sntapshots tests:

    @Suite("Snapshot tests")
    struct DynamicLanguageTests {
    
        let record = true
    
        @Test func testContentViewInEnglish() {
                let localizationManager = LocalizationManager()
                localizationManager.locale = Locale(identifier: "en")
                
                let contentView = ContentView()
                    .environmentObject(localizationManager)
                    .frame(width: 300, height: 200)
                
                let viewController = UIHostingController(rootView: contentView)
                
                assertSnapshot(
                    of: viewController,
                    as: .image(on: .iPhoneSe),
                    named: "ContentView-English",
                    record: record
                )
            }
    
        @Test  func testContentViewInSpanish() {
                let localizationManager = LocalizationManager()
                localizationManager.locale = Locale(identifier: "es")
                
                let contentView = ContentView()
                    .environmentObject(localizationManager)
                    .frame(width: 300, height: 200)
                
                let viewController = UIHostingController(rootView: contentView)
                
                assertSnapshot(
                    of: viewController,
                    as: .image(on: .iPhoneSe),
                    named: "ContentView-Spanish",
                    record: record
                )
            }
    }

    The DynamicLanguageTests struct, annotated with @Suite("Snapshot tests"), contains two test functions: testContentViewInEnglish() and testContentViewInSpanish(). Each function sets up a LocalizationManager with a specific locale (en for English, es for Spanish), creates a ContentView instance, embeds it in a UIHostingController, and captures a snapshot of the rendered UI on an iPhone SE device. The snapshots are named accordingly («ContentView-English» and «ContentView-Spanish») and compared against previously recorded images to detect unintended visual changes.

    Conclusions

    Dynamic Language Switch it could be an interesting implementation for allowing user swap view language for beter understaning., but also is more useful when we have to validate that a view is properly presente in all app languages, you can find the source code used for this post in the following repository

  • Bitrise Magic: One-Click iOS Builds for Your QA Team

    Bitrise Magic: One-Click iOS Builds for Your QA Team

    Automating iOS app distribution for QA and reviewers using Bitrise addresses a common pain point—manually sharing builds—by streamlining the process through CI/CD. Bitrise simplifies generating signed IPAs (for ad-hoc testing), uploading to TestFlight, and deploying to third-party services like Firebase App Distribution, all while handling code signing, versioning, and notifications automatically.

    This post is a step-by-step guide to help developers set up Bitrise to generate an iOS build artifact for distribution to QA or any reviewer.

    Getting in context

    To develop this post, we need the app for internal distribution, an iOS Developer Program license from Apple, and a free Bitrise account.

    The app we will distribute internally is an iOS project called SMOC. SMOC is a head-up display speedometer and dash camera. The app builds without issues, and all unit tests pass. While this may seem obvious, it’s important to ensure that build crashes do not occur during workflow executions.

    Being a member of the Apple Developer Program is mandatory because, at some point during the Bitrise setup, you will need to connect to the Apple Developer portal with your credentials. Additionally, you will need to provide an API key (.p8 file) to Bitrise, generated in the Apple Developer portal and also the Development Certificate (.p12 file).

    Bitrise offers a 30-day free trial account that allows you to experiment with and initiate iOS app internal distribution.

    Generating Apple Developer API Keys

    Login in into your Apple developer account:

    Select User and Access. On the incoming screen, choose Integrations > App Store Connect API from the left-side menu. Under Equipment Keys, click the Add (+) button to create a new key.

    Important point: Access must be set to ‘Administrative’; I was not successful with the ‘Developer’ role. Download the API key (.p8 file) to a secure location, as we will need it to configure Bitrise. The ID Key and Issuer ID have been masked for security reasons, but make sure to take note of them because you will need them later.

    In addition to the API key (.p8 file), we also need the Apple Development Certificate (.p12 file). To obtain it, open Keychain and export the certificate. Remember the password you set during the export process, as it will be required when importing the certificate into Bitrise.

    If the certificate is not in your keychain, you can create it via Xcode. Go to Settings > Accounts > Manage Certificates, and select Apple Development. The certificate will automatically be added to your local machine’s keychain and uploaded to the Apple Developer Portal.

    General iOS Setup

    Once logged into your Bitrise account, the first step—before configuring any project—is to set up some global Bitrise configurations that will be needed later. Since our codebase is hosted on GitHub, we will connect our GitHub account.

    Now it’s time to add your API key. Go back to the previous screen, select ‘App Store Connect,’ and press the ‘Add API Key’ button.

    The name is an identifier of your choice, but the Issuer ID and Key ID are obtained when you create an API key on the Apple Developer portal. Finally:

    Following the ‘least privilege principle’, I initially created an API key with a Developer role. However, the workflow execution failed due to insufficient access rights, so I had to generate a new API key, this time with an Administrator role.

    Lastly, we need to connect the Bitrise account with our Apple Developer account, just as we did with GitHub. However, this time, we need to go to the top-right user icon menu.

    And connect with your Apple Developer (or Enterprise) account:

    iOS Project setup

    Now is the time to create a new project. Select the dashboard and click the ‘(+) New Project’ button.

    Select ‘Add your App’:

    Select ‘New project’:

    And press Next.

    Fill in the app title, mobile platform, and app bundle ID. Then, select ‘Integrations’ from the left menu.

    And just indicate which API key you are going to use.

    Go to the Dashboard and select ‘Configure’ in the project you’re working on.

    Set up the project and access. In our case, we aim to provide CI builds to team members who either do not have access to Bitrise or are not permitted to use it.

    In our case, the code repository is GitHub, so we set the URL to point to our repository.

    Bitrise authorization is not strictly necessary because the repository is public. However, if it were private, we would need to authorize access. For the «Choose Branch» step, we select «No, skip auto-detection and configure settings manually» because, although the default branch is develop, developers often distribute builds to their QA colleagues based on their working branches rather than the default branch.

    In the ‘Default configuration’, we set the project type, project (or workspace) filename, scheme, and distribution method. In our case, we are interested in distributing builds based on development to our QA team.

    Next, we select the XCode version to be used as the toolchain and build machine. Due to our account subscription, we are limited to working with only one type of machine.

    Set the app icon to add an avatar to the project, then select ‘Register a Webhook’ for me!

    Configuration finished just press ‘View CI configuration’.

    Configuration is almost complete, but not quite. Next, we need to upload the Development Certificate (.p12 file). To do this, select ‘Code Signing’ and then click ‘Add .p12 File.

    Once added:

    Even though we set the API key previously, I faced some compilation crashes that failed due to the API key, so I had to specify again which API key I was going to use.

    Project configuration is ready. Now, select workflows to create our workflow, which is also known as pipelines in other contexts.

    Workflow creation

    On workflows select ‘(+) Create Workflow’:

    Fill in the name of the workflow, as well as the workflow on which it is based. In our case, it is ‘archive_and_export_app’:

    This would be ok in case our project used Cocoapods as a dependency manager, but is our case is not.

    So we have to remove Cocoapods step:

    Save workflow:

    Workflow execution

    Setup work finished, now is time to run the workflow, press ‘Start build’ button:

    Press ‘Start build’:

    After a few minutes….

    If everything is set up correctly, the ‘Success’ message should appear. Now, press the ‘Right arrow’ icon to continue with the build distribution.

    Install and run

    We need to provide the app download URL, either by distributing a QR code or sharing the direct link. Our responsibilities as developers are complete.

    As a QA reviewer, once we scan the QR code or enter the link in the device browser, we will see the following. The setup process will only occur the first time the app is installed. Subsequent re-installations (or removal and reinstallation) will not trigger the setup process again.

    Select ‘Check compatibility’ and following alert will appear:

    Select Allow for installing configutation profile. And Open Device Settings, General:

    Select Allow for installing configutation profile. And Open Device Settings, General, VPN & Device Management:

    And select ‘Install’. Remember this setup has to be done once per device and app distributed.

    And yes, finally press install to begin installing the app on your device. The following animation will show you how the app is installed and execute:

    Conclusions

    Although the configuration process can be tedious, once it’s set up, the developer only needs to focus on executing the workflow on the desired working branch and providing the workflow or artifact distribution link to QA or any other interested reviewer. On the QA side, they simply need to click the link to install the app and start testing.

    References

    • Bitrise

      The CI/CD Platform built for Mobile DevOps

    • SMOC

      Portfolio – On board car clip camera iOS App

    • SMOC

      Apple Store

  • Visual Regresion Testing: Implementing Snapshot test on iOS

    Visual Regresion Testing: Implementing Snapshot test on iOS

    Implementing snapshot tests alongside regular unit tests is valuable because it addresses an often-overlooked aspect of testing: UI consistency. While unit tests verify business logic correctness, snapshot tests capture and compare UI renderings, preventing unintended visual regressions. This is especially useful in dynamic UIs, where small code changes might break layouts without being detected by standard tests.

    By demonstrating how to integrate snapshot testing effectively, we aim to help developers enhance app stability, streamline UI testing, and adopt a more comprehensive test-driven approach.

    Setup iOS Project

    We willl creeate a regular iOS app project, with Swift  test target (not XCTest):

    To include the swift-snapshot-testing package in your project, add it via Swift Package Manager (SPM) using the following URL: https://github.com/pointfreeco/swift-snapshot-testing.

    Important: When adding the package, ensure you assign it to the test target of your project. This is necessary because swift-snapshot-testing is a testing framework and should only be linked to your test bundle, not the main app target.

    We will continue by implementing the views that will be validated:

    struct RootView: View {
        @State var navigationPath = NavigationPath()
    
        var body: some View {
            NavigationStack(path: $navigationPath) {
                ContentView(navigationPath: $navigationPath)
                    .navigationDestination(for: String.self) { destination in
                        switch destination {
                        case "SecondView":
                            SecondView(navigationPath: $navigationPath)
                        case "ThirdView":
                            ThirdView(navigationPath: $navigationPath)
                        default:
                            EmptyView()
                        }
                    }
            }
        }
    }
    
    struct ContentView: View {
        @Binding var navigationPath: NavigationPath
    
        var body: some View {
            VStack {
                Text("First View")
                    .font(.largeTitle)
                    .padding()
                
                Button(action: {
                    navigationPath.append("SecondView")
                }) {
                    Text("Go to second viee")
                        .padding()
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }
                .accessibilityIdentifier("incrementButton")
            }
            .navigationTitle("First View")
        }
        
        func testButtonPress() {
            navigationPath.append("SecondView")
        }
    }
    
    struct SecondView: View {
        @Binding var navigationPath: NavigationPath
    
        var body: some View {
            VStack {
                Text("Second View")
                    .font(.largeTitle)
                    .padding()
                
                Button(action: {
                    navigationPath.append("ThirdView")
                }) {
                    Text("Go to third view")
                        .padding()
                        .background(Color.green)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }
            }
            .navigationTitle("Second View")
        }
    }
    
    struct ThirdView: View {
        @Binding var navigationPath: NavigationPath
    
        var body: some View {
            VStack {
                Text("Third View")
                    .font(.largeTitle)
                    .padding()
                
                Button(action: {
                    // Pop to root
                    navigationPath.removeLast(navigationPath.count) // Empty stack
                }) {
                    Text("Get back to first view")
                        .padding()
                        .background(Color.red)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }
            }
            .navigationTitle("Third view")
        }
    }

    The provided SwiftUI code defines a navigation flow where a user can navigate through three views: FirstView, SecondView, and ThirdView. It uses a NavigationStack to manage the navigation path with a NavigationPath that is shared across views. In ContentView, the user can navigate to SecondView by pressing a button. In SecondView, the user can proceed to ThirdView with another button. In ThirdView, there is a button that clears the navigation stack, taking the user back to the FirstView. The navigation path is managed using the navigationPath state, and the specific view to navigate to is determined by the string values in the navigation path. 

    Snapshop testing

    napshot testing in iOS is a method that focuses on verifying the visual elements of an app’s user interface, such as fonts, colors, layouts, and images. It involves capturing a screenshot of the UI and saving it as a reference image, then comparing it pixel by pixel with new screenshots taken during subsequent tests. This technique allows developers to quickly detect unintended visual changes or regressions caused by code modifications, ensuring UI consistency across different versions of the app. By automating visual verification, snapshot testing complements other testing methods, such as unit and integration tests, by specifically addressing the app’s visual aspects and helping maintain a high-quality user experience.

    Swift Testing allows test functions to be parameterized. In our case, we will parameterize test functions based on the device screen we are interested in.

    protocol TestDevice {
        func viewImageConfig() -> ViewImageConfig
    }
    
    struct iPhoneSe: TestDevice  {
         func viewImageConfig() -> ViewImageConfig {
            ViewImageConfig.iPhoneSe
        }
    }
    
    struct iPhone13ProMax: TestDevice  {
         func viewImageConfig() -> ViewImageConfig {
            ViewImageConfig.iPhone13ProMax(.portrait)
        }
    }
    
    struct iPhone12Landscape: TestDevice  {
         func viewImageConfig() -> ViewImageConfig {
             ViewImageConfig.iPhone12(.landscape)
        }
    }

    For our sample, we will use an iPhone SE, iPhone 13 Pro Max, and iPhone 12 (in landscape mode). Finally, the test itself:

    @MainActor
    @Suite("Snapshot tests")
    struct SnapshotTests {
        
        var record = true // RECORDING MODE!
        static let devices: [TestDevice] = [iPhoneSe(), iPhone13ProMax(), iPhone12Landscape()]
        
        @Test(arguments: devices) func testFirstView(device: TestDevice) {
            let rootView = RootView()
            let hostingController = UIHostingController(rootView: rootView)
            var named = String(describing: type(of: device))
            assertSnapshot(of: hostingController,
                           as: .image(on: device.viewImageConfig()),
                           named: named,
                           record: record)
        }
        
        @Test(arguments: devices) func testSecondView(device: TestDevice) {
            let secondView = SecondView(navigationPath: .constant(NavigationPath()))
            let hostingController = UIHostingController(rootView: secondView)
    
            var named = String(describing: type(of: device))
            
            assertSnapshot(of: hostingController,
                           as: .image(on: device.viewImageConfig()),
                           named: named,
                           record: record)
        }
        
        @Test(arguments: devices) func testThirdView(device: TestDevice) {
            let thirdView = ThirdView(navigationPath: .constant(NavigationPath()))
            let hostingController = UIHostingController(rootView: thirdView)
    
            var named = String(describing: type(of: device))
            
            assertSnapshot(of: hostingController,
                           as: .image(on: device.viewImageConfig()),
                           named: named,
                           record: record)
        }
    }

    We have defined a test function for each screen to validate. On the first execution, var record = true, meaning that reference screenshots will be taken. Run the test without worrying about failure results.

    The important point is that a new folder called __Snapshots__ has been created to store the taken snapshots. These snapshots will serve as reference points for comparisons. Don’t forget to commit the screenshots. Now, switch record to false to enable snapshot testing mode

    ...
    var record = false // SNAPSHOT TESTING MODE!
    ...

    Run the test and now all must be green:

    Do test fail!

    Now we are going to introduce some changes to the view:

    Launch test: We now face issues where the tests validating ContentView are failing:

    When we review logs, we can see in which folder the snapshots are stored.

    With your favourite folder content comparator compare both folders:

    In my case, I use Beyond Compare, click on any file pair:

    With the image comparator included in BeyondCompare we can easily see with view components have changed.

    Conclusions

    Snapshot testing is a valuable complement to unit testing, as it enables you to detect regressions in views more effectively. If you’re interested in exploring the implementation further, you can find the source code used for this post in the following repository

    References

  • Testing an iOS Location Manager

    Testing an iOS Location Manager

    This post explains how to validate hardware-dependent components like the LocationManager, which relies on GPS hardware. Testing such managers, including LocationManager and VideoManager, is crucial for addressing challenges developers face, such as hardware constraints, environmental variability, and simulator limitations. By mastering these techniques, you can ensure robust and reliable application behavior in real-world scenarios.

    I will guide you through the process of validating a LocationManager, introduce its test support structures, and provide examples of unit tests. Along the way, we’ll explore key techniques like mocking system services, dependency injection, and efficient testing strategies for simulators and real devices.

    This improved version enhances clarity, reduces redundancy, and improves flow while retaining all the critical details. Let me know if you’d like further refinements!

    Location Manager

    In this case, we have a location manager to handle geographic data efficiently and ensure accurate location tracking.

    import Foundation
    import CoreLocation
    
    @globalActor
    actor GlobalManager {
        static var shared = GlobalManager()
    }
    
    @GlobalManager
    class LocationManager: NSObject, ObservableObject  {
        private var clLocationManager: CLLocationManager? = nil
    
        @MainActor
        @Published var permissionGranted: Bool = false
        private var internalPermissionGranted: Bool = false {
             didSet {
                Task { [internalPermissionGranted] in
                    await MainActor.run {
                        self.permissionGranted = internalPermissionGranted
                    }
                }
            }
        }
        
        @MainActor
        @Published var speed: Double = 0.0
        private var internalSpeed: Double = 0.0 {
             didSet {
                Task { [internalSpeed] in
                    await MainActor.run {
                        self.speed = internalSpeed
                    }
                }
            }
        }
        
        init(clLocationManager: CLLocationManager = CLLocationManager()) {
            super.init()
            self.clLocationManager = clLocationManager
            clLocationManager.delegate = self
        }
        
        func checkPermission() {
            clLocationManager?.requestWhenInUseAuthorization()
        }
    }
    
    extension LocationManager: @preconcurrency CLLocationManagerDelegate {
        
        func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
            let statuses: [CLAuthorizationStatus] = [.authorizedWhenInUse, .authorizedAlways]
            if statuses.contains(status) {
                internalPermissionGranted = true
                Task {
                    internalStartUpdatingLocation()
                }
            } else if status == .notDetermined {
                checkPermission()
            } else {
                internalPermissionGranted = false
            }
        }
        
        func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
            guard let location = locations.last else { return }
            internalSpeed = location.speed
        }
        
        private func internalStartUpdatingLocation() {
            guard CLLocationManager.locationServicesEnabled() else { return }
            clLocationManager?.startUpdatingLocation()
        }
    }
    
    This Swift code defines a LocationManager class that manages location permissions and tracking, integrating with SwiftUI’s reactive model. It uses CLLocationManager to handle location updates and authorization, updating @Published properties like permissionGranted and speed for UI binding. The class leverages Swift’s concurrency features, including @MainActor and @globalActor, to ensure thread-safe updates to the UI on the main thread. Private properties (internalPermissionGranted and internalSpeed) encapsulate internal state, while public @Published properties notify views of changes. By conforming to CLLocationManagerDelegate, it handles permission requests, starts location updates, and updates speed in response to location changes, ensuring a clean, reactive, and thread-safe integration with SwiftUI.

    Location Manager

    The key is to mock CLLocationManager and override its methods to suit the needs of your tests:

    class LocationManagerMock: CLLocationManager {
        var clAuthorizationStatus: CLAuthorizationStatus = .notDetermined
        
        override func requestWhenInUseAuthorization() {
            delegate?.locationManager!(self, didChangeAuthorization: clAuthorizationStatus)
        }
        
        override func startUpdatingLocation() {
            let sampleLocation = CLLocation(
                coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
                altitude: 10.0,
                horizontalAccuracy: 5.0,
                verticalAccuracy: 5.0,
                course: 90.0,
                speed: 10.0,
                timestamp: Date()
            )
            delegate?.locationManager!(self, didUpdateLocations: [sampleLocation])
        }
    }

    For our test purposes, we are validating the location-granted request service and starting the location update process. During permission validation, we use an attribute to provide the desired response when requestWhenInUseAuthorization is executed. Additionally, we include a sample CLLocation to simulate the location data when startUpdatingLocation is called.

    To ensure robust validation of authorization, we have implemented the following unit tests:

        @Test func testAthorizacionRequestDenied() async throws {
            let locationManagerMock = LocationManagerMock()
            locationManagerMock.clAuthorizationStatus = .denied
            let sut = await LocationManager(clLocationManager: locationManagerMock)
            await sut.checkPermission()
            // Wait for the @Published speed property to update
            try await Task.sleep(nanoseconds: 1_000_000)
            await #expect(sut.permissionGranted == false)
        }
    
    
        @Test func testAthorizacionRequestAuthorized() async throws {
            let locationManagerMock = LocationManagerMock()
            locationManagerMock.clAuthorizationStatus =  .authorizedWhenInUse
            let sut = await LocationManager(clLocationManager: locationManagerMock)
            await sut.checkPermission()
            // Wait for the @Published speed property to update
            try await Task.sleep(nanoseconds: 1_000_000)
            await #expect(sut.permissionGranted == true)
        }
    Validates scenarios where the user grants or denies location services authorization. Also validates location updates.
        @Test func testStartUpdatingLocation() async throws {
            let locationManagerMock = LocationManagerMock()
            locationManagerMock.clAuthorizationStatus =  .authorizedWhenInUse
            let sut = await LocationManager(clLocationManager: locationManagerMock)
            await sut.checkPermission()
            // Wait for the @Published speed property to update
            try await Task.sleep(nanoseconds: 50_000_000)
                   
            await #expect(sut.speed == 10.00)
        }

    Basically we check location speed.

    Conclusions

    In this post, I have presented a method for validating hardware-dependent issues, such as GPS information. You can find the source code used for this post in the repository linked below.

  • Dip your toes in middle of TCA ocean

    Dip your toes in middle of TCA ocean

    TCA, or The Composable Architecture, is a framework for iOS development that provides a structured and scalable approach to building robust, maintainable applications. Created by Brandon Williams and Stephen Celis, TCA leverages functional programming principles and Swift’s powerful type system to offer a modern solution for iOS app architecture.

    In this post, we’ll explore how to migrate our Rick and Morty iOS app to TC

    The architecture

    TCA consists of five main components:

    1. State: A single type that represents the entire state of an app or feature.
    2. Actions: An enumeration of all possible events that can occur in the app.
    3. Environment: A type that wraps all dependencies of the app or feature.
    4. Reducer: A function that transforms the current state to the next state based on a given action.
    5. Store: The runtime that powers the feature and manages the state.

    TCA offers several advantages for iOS development:

    • Unidirectional data flow: This makes it easy to understand how changes in state occur, simplifying debugging and preventing unexpected side effects.
    • Improved testability: TCA encourages writing features that are testable by default.
    • Modularity: It allows for composing separate features, enabling developers to plan, build, and test each part of the app independently.
    • Scalability: TCA is particularly useful for complex applications with many states and interactions.

    Configure XCode project

    To get started with this architecture, integrate the ComposableArchitecture library from its GitHub repository.

    Downloading might take some time.

    Character feature

    The component that we have to change implentation is basically the ViewModel component. In this case will be renamed as CharacterFeature.

    import ComposableArchitecture
    
    @Reducer
    struct CharatersFeature {
        @ObservableState
        struct State: Equatable {
            var characters: [Character] = []
            var isLoading: Bool = false
        }
    
        enum Action {
            case fetchCharacters
            case fetchCharactersSuccess([Character])
        }
    
        var body: some ReducerOf<Self> {
            Reduce { state, action in
                switch action {
                case .fetchCharacters:
                    state.isLoading = true
                    state.characters = []
                    return .run { send in
                        let result = await currentApp.dataManager.fetchCharacters(CharacterService())
                        switch result {
                        case .success(let characters):
                            //state.characters = characters
                            await send(.fetchCharactersSuccess(characters))
                        case .failure(let error):
                            print(error)
                        }
                    }
                case .fetchCharactersSuccess(let characters):
                    state.isLoading = false
                    state.characters = characters
                    return .none
                }
            }
        }
    }

    This code defines a feature using the Composable Architecture (TCA) framework in Swift. Let’s break down what this code does:

    1. Import and Structure:
      • It imports the ComposableArchitecture framework.
      • It defines a CharatersFeature struct with the @Reducer attribute, indicating it’s a reducer in the TCA pattern.
    2. State:
      • The State struct is marked with @ObservableState, making it observable for SwiftUI views.
      • It contains two properties:
        • characters: An array of Character objects.
        • isLoading: A boolean to track if data is being loaded.
    3. Actions:
      • The Action enum defines two possible actions:
        • fetchCharacters: Triggers the character fetching process.
        • fetchCharactersSuccess: Handles successful character fetching.
    4. Reducer:
      • The body property defines the reducer logic.
      • It uses a Reduce closure to handle state changes based on actions.
    5. Action Handling:
      • For .fetchCharacters:
        • Sets isLoading to true and clears the characters array.
        • Runs an asynchronous operation to fetch characters.
        • On success, it dispatches a .fetchCharactersSuccess action.
        • On failure, it prints the error.
      • For .fetchCharactersSuccess:
        • Sets isLoading to false.
        • Updates the characters array with the fetched data.
    6. Asynchronous Operations:
      • It uses .run for handling asynchronous operations within the reducer.
      • The character fetching is done using currentApp.dataManager.fetchCharacters(CharacterService()).

    This code essentially sets up a state management system for fetching and storing character data, with loading state handling. It’s designed to work with SwiftUI and the Composable Architecture, providing a structured way to manage application state and side effects.

    View

    The view is almost the same as before:

    struct CharacterView: View {
        let store: StoreOf<CharatersFeature>
        
        var body: some View {
            NavigationView {
                ZStack {
                    if store.isLoading {
                        ProgressView()
                    }
                ScrollView {
                        ForEach(store.characters) { character in
                            NavigationLink {
                                DetailView(character: character)
                            } label: {
                                HStack {
                                    characterImageView(character.imageUrl)
                                    Text("\(character.name)")
                                    Spacer()
                                }
                            }
                        }
                    }
                }
            }
            .padding()
            .onAppear {
                store.send(.fetchCharacters)
            }
        }

    The store holds observable items used by the view to present either the progression view or the character list. When the view appears, it triggers the .fetchCharacters action, prompting the reducer to fetch the character list.

    Unit test

    Unit testing with TCA differs significantly from my expectations:

        @Test func example() async throws {
            // Write your test here and use APIs like `#expect(...)` to check expected conditions.
            let store = await TestStore(initialState: CharatersFeature.State()) {
                CharatersFeature()
            }
            
            await store.send(.fetchCharacters) {
              $0.isLoading = true
            }
            
            await store.receive(\.fetchCharactersSuccess, timeout: .seconds(1)) {
              $0.isLoading = false
                $0.characters = expCharacters
            }
            
            await store.finish()
        }

    In TCA, testing often focuses on asserting the state transitions and effects of the reducer. Instead of traditional XCTest assertions like XCTAssertEqual, TCA provides its own mechanism for testing reducers using TestStore, which is a utility designed to test state changes, actions, and effects in a deterministic way.

    Conclusions

    This is a very minimalistic example just to get in touch with this architecture. With more complex applications, I meain with some flows and many screens reducer would become a huge chunk of code, so god approach would be implement this pattern per app flow.You can find source code used for writing this post in following repository.

  • Safely migrating persisted models in iOS to prevent crashes

    Safely migrating persisted models in iOS to prevent crashes

    Most mobile native apps (iOS/Android) support evolving businesses, and must be ready to accommodate changes. Evolving persisted data in an app is crucial for maintaining a good user experience and preventing obsolescence as business requirements change. In my personal experience with production crashes, this is one of the most common issues encountered after a production release. While removing and reinstalling the app often fixes the issue, this is not an ideal solution.

    The aim of this post is to demonstrate a possible method for handling migration in non-database persisted data. We will explain how to migrate JSON stored in user defaults, though this approach could also apply to data stored in files or keychain. It’s important to note that we’re focusing on non-database storage because database frameworks typically provide their own migration mechanisms that should be followed.

    The starting point

    The base application used as the foundation for this post simply displays a button. When pressed, it saves a structure to UserDefaults. Upon restarting the app after it has been closed, the previously stored content is retrieved from UserDefaults.

    Let me introduce the persisted structure. It has been simplified for better understanding, but is not what you would find in a real production app:

    struct Person: Codable {
        let name: String
        let age: String
    }

    New requirements

    The business has decided that an email address is also required. Therefore, we will proceed to implement this feature.

    struct Person: Codable {
        let name: String
        let age: String
        let email: String
    }
    Build and run, but suddenly something unexpected happens…

    Initially, the app stored the data in the format {name, age}. However, it has since been updated to expect the format {name, age, email}. This discrepancy means that previously persisted data cannot be decoded properly, leading to an exception being thrown.

    A common mistake during development at this stage is to simply remove the app from the simulator, reinstall the new version, and move on, effectively ignoring the issue. This is a poor decision, as it fails to address the root problem. Eventually, this oversight will come back to haunt you when the same issue occurs on hundreds, thousands, or an unacceptably large number of real app installations. This will result in a very poor user experience.

    The first step to properly address this issue is to add a version field to the data structure. This allows for better handling of future changes to the structure.

    struct Person: Codable {
        var version: Int {
            return 1
        }
        let name: String
        let age: String
        let email: String
    }

    We will implement a Migration Manager responsible for handling migrations

    @MainActor
    protocol MigrationManagerProtocol {
        func applyMigration()
    }
    
    @MainActor
    final class MigrationManager: ObservableObject {
        @Published var migrationPenging = true
        @Published var migrationFailed = false
        
        struct PersonV0: Codable {
            let name: String
            let age: String
        }
        
        typealias PersonV1 = Person
    }

    In this class, we will include a copy of the original Person structure, referred to as PersonV0. The current Person structure is named PersonV1

    extension MigrationManager: MigrationManagerProtocol {
        func applyMigration() {
            defer { migrationPenging = false }
            applyPersonMigration()
        }
        
        private func isPersonMigrationPending() -> Bool {
            let userDefaultsManager = appSingletons.userDefaultsManager
            return userDefaultsManager.get(Person.self, forKey: UserDefaultsManager.key.person) == nil
        }
        
        private func applyPersonMigration() {
            let userDefaultsManager = appSingletons.userDefaultsManager
    
            guard isPersonMigrationPending() else {
                return // No migration needed
            }
            let currentStoredPersonVersion = storedPersonVersion()
            if currentStoredPersonVersion == 0,
                let personV0 = userDefaultsManager.get(PersonV0.self, forKey: UserDefaultsManager.key.person) {
                let person = PersonV1(name: personV0.name, age: personV0.age, email: "---")
                saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
            }
        }
        
        private func storedPersonVersion() -> Int {
                return 0
        }
    ...
    }

    The migration process begins by determining the version of the Person structure stored using the storedPersonVersion() function. At this stage in the application’s evolution, the version is 0.

    If the current stored version is 0, the process involves fetching PersonV0 from UserDefaults and performing a migration. This migration entails transforming PersonV0 into PersonV1 by adding an email field with a default value.

    struct JSONPersistedMigrationApp: App {
        var body: some Scene {
            WindowGroup {
                ContentView()
                    .onAppear {
    #if DEBUG
                        setupDebugSwift()
    #endif
                    }
                    .onShake {
    #if DEBUG
                        DebugSwift.show()
    #endif
                    }
                    .task {
                        appSingletons.migrationManager.applyMigration()
                    }
            }
        }

    Finally, we call migrationManager.applyMigration() within the .task modifier to ensure it executes only once during the app’s startup.

    For debugging purposes, I found the DebugSwift tool very useful. I explain in more detail how to integrate this tool and its main features in the following post.

    Now, build and run the app:

    Migration is currently being executed, and the app is displaying data properly.

    When we open the DebugView tool to review user defaults, we observe that the migration has been completed exactly as expected.

    Did we really finish? Well, not yet. It is mandatory to implement a test case that ensures the Person object can be migrated from V0 to V1.

    Updating an attribute

    Once the infrastructure for running migrations is ready, the next changes to the persisted structure should involve either a Tic-Tac-Toe game or an easy recipe.

    Now, the business has requested that we rename the «name» field to «alias.» The first step is to update the structure by increasing the version number and renaming the field.

    struct Person: Codable {
        var version: Int {
            return 2
        }
        
        let alias: String
        let age: String
        let email: String
    }
    

    Second, add PersonV1 to the Migration Manager and set PersonV2 as the current Person structure.

    @MainActor
    final class MigrationManager: ObservableObject {
        @Published var migrationPenging = true
        @Published var migrationFailed = false
        
        struct PersonV0: Codable {
            let name: String
            let age: String
        }
        
        struct PersonV1: Codable {
            var version: Int {
                return 1
            }
            let name: String
            let age: String
            let email: String
        }
        
        typealias PersonV2 = Person
    }

    The third step is to update storedPersonVersion(). This time, the stored Person version could be either V0 or V1.

        private func storedPersonVersion() -> Int {
            let userDefaultsManager = appSingletons.userDefaultsManager
            if let personV1 = userDefaultsManager.get(PersonV1.self, forKey: UserDefaultsManager.key.person) {
                return 1
            } else {
                return 0
            }
        }

    The fourth step is to implement the migration-if-block inside applyPersonMigration.

        private func applyPersonMigration() {
            let userDefaultsManager = appSingletons.userDefaultsManager
    
            guard isPersonMigrationPending() else {
                return // No migration needed
            }
            let currentStoredPersonVersion = storedPersonVersion()
            if currentStoredPersonVersion == 0,
                let personV0 = userDefaultsManager.get(PersonV0.self, forKey: UserDefaultsManager.key.person) {
                let person = PersonV1(name: personV0.name, age: personV0.age, email: "---")
                saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
            }
            if currentStoredPersonVersion <= 1,
                let personV1 = userDefaultsManager.get(PersonV1.self, forKey: UserDefaultsManager.key.person) {
                let person = PersonV2(alias: personV1.name, age: personV1.age, email: personV1.email)
                saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
            }
        }

    It has to be an independent if-block,. In case the app were V0 both if-blocks would be executed, and in case were V1 the tha last block woud be executed.

    Fifth and last step, do unit test, now appears a new test case. One for testing migration from V0 to V2 and another for V1 to V2:

    Remember, five steps one after the other.

    Removing an attribute

    Now, we will remove the age attribute. One, update the person structure.

    struct Person: Codable {
        var version: Int {
            return 3
        }
        
        let alias: String
        let email: String
    }

    Two, add PersonV2 to MigrationManager and set PersonV3 as the current Person structure.

    @MainActor
    final class MigrationManager: ObservableObject {
        @Published var migrationPenging = true
        @Published var migrationFailed = false
        
        struct PersonV0: Codable {
            let name: String
            let age: String
        }
        
        struct PersonV1: Codable {
            var version: Int {
                return 1
            }
            
            let name: String
            let age: String
            let email: String
        }
        
        struct PersonV2: Codable {
            var version: Int {
                return 2
            }
            
            let alias: String
            let age: String
            let email: String
        }
        
        typealias PersonV3 = Person
    }

    Three, update storedPersonVersion(). This time, the stored Person version could be V0, V1, or V2:

        private func storedPersonVersion() -> Int {
            let userDefaultsManager = appSingletons.userDefaultsManager
            if let _ = userDefaultsManager.get(PersonV2.self, forKey: UserDefaultsManager.key.person) {
                return 2
            } else if let _ = userDefaultsManager.get(PersonV1.self, forKey: UserDefaultsManager.key.person) {
                return 1
            } else {
                return 0
            }
        }

    Four, the migration-if-block inside applyPersonMigration:

    private func applyPersonMigration() {
            let userDefaultsManager = appSingletons.userDefaultsManager
    
            guard isPersonMigrationPending() else {
                return // No migration needed
            }
            let currentStoredPersonVersion = storedPersonVersion()
            if currentStoredPersonVersion == 0,
                let personV0 = userDefaultsManager.get(PersonV0.self, forKey: UserDefaultsManager.key.person) {
                let person = PersonV1(name: personV0.name, age: personV0.age, email: "---")
                saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
            }
            if currentStoredPersonVersion <= 1,
                let personV1 = userDefaultsManager.get(PersonV1.self, forKey: UserDefaultsManager.key.person) {
                let person = PersonV2(alias: personV1.name, age: personV1.age, email: personV1.email)
                saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
            }
            if currentStoredPersonVersion <= 2,
                let personV2 = userDefaultsManager.get(PersonV2.self, forKey: UserDefaultsManager.key.person) {
                let person = PersonV3(alias: personV2.alias, email: personV2.email)
                saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
            }
        }

    Five: Perform unit tests. A new test case has now been added, which tests migration from V0 to V3, V1 to V3, and V2 to V3.

    Conclusions

    In this post, you learned how to migrate non-database persisted data in your app. You can find the base project for developing this post in this repository.

  • Force Update iOS Apps When Backend Require It

    Force Update iOS Apps When Backend Require It

    In the mobile native (iOS/Android) app production ecosystem, multiple frontend versions often coexist and interact with the same backend. Frontend updates are typically adopted gradually; while it’s possible to enforce an update, this approach is generally considered disruptive and is used only in exceptional circumstances.

    This post aims to demonstrate a method for controlling request responses based on the frontend version specified in the request. The backend implementation will use Vapor, and the frontend will be an iOS app. Links to the GitHub repositories hosting the source code are provided at the end of this post.

    Keep request under control

    Including the client’s frontend version in backend requests is crucial for several reasons:

    1. Version-Specific Responses: The backend can tailor its responses to ensure compatibility and optimal functionality for each frontend version.

    2. API Versioning: It helps the backend serve the appropriate API version, supporting backward compatibility while enabling updates and improvements.

    3. Feature Support: Frontend versions may differ in their feature sets. The backend can adjust responses to include or exclude functionality based on the client’s capabilities.

    4. Performance Optimization: Backend processing and payloads can be optimized for the specific requirements of each frontend version, improving system performance.

    5. Error Handling: Knowing the frontend version allows for more relevant error messages and effective resolution of version-specific issues.

    6. Security Enhancements: Version-specific security protocols or restrictions can be implemented, boosting system security.

    By including the frontend version in client requests, developers can build robust, efficient, and maintainable systems that adapt to evolving requirements while maintaining compatibility with legacy clients.

    Vapor backend

    Vapor is an open-source web framework written in Swift, designed for building server-side applications. It offers a powerful and asynchronous platform for developing web applications, APIs, and backend services, all using Swift as the server-side language.

    This post is not a «build your first server-side app» tutorial. However, don’t worry—at the end of the post, I’ll share the tutorials I followed to gain a deeper understanding of this technology.

    To get started, we’ll create a new Vapor project. For this project, we won’t be working with databases, so you can safely answer «No» to all related prompts during the setup process.

    We will create an endpoint specifically for checking the minimum required versions compatible with the backend and determining whether a forced update is necessary. The endpoint will use the GET method, and the path will be /minversion.

    struct MainController: RouteCollection {
        func boot(routes: any Vapor.RoutesBuilder) throws {
            let minversionRoutesGrouped = routes.grouped("minversion")
            minversionRoutesGrouped.get(use: minVersion)

    And the associated function to perform this will be as follows.

        @Sendable
        func minVersion(req: Request) async throws -> VersionResponse {
            
            let currentVersion = "2.0.0"
            let minimumVersion = "1.5.0"
            let forceUpdate = true // o false dependiendo de la lógica de negocio
    
            // Devuelve la respuesta como JSON
            return VersionResponse(
                currentVersion: currentVersion,
                minimumVersion: minimumVersion,
                forceUpdate: forceUpdate
            )
        }

    Structure: We need to include the following information:

    1. Minimal Version: The minimum version of the application that the backend can handle.
    2. Current Version: The current version supported by the backend.
    3. Force Update: Whether a forced update is required.

    Instructions:
    Run the project, and check the log console to confirm that the server is ready.

    Use the curl command to call the specified endpoint.

    The API returns a JSON object containing the minimum and current versions, as well as a force-update flag.

    To simplify the backend’s ability to check frontend versions, we will add an additional attribute to each endpoint. This attribute will provide information about the frontend version. To illustrate this approach, we will create a sample POST endpoint that includes this feature.

    struct MainController: RouteCollection {
        func boot(routes: any Vapor.RoutesBuilder) throws {
            let minversionRoutesGrouped = routes.grouped("minversion")
            minversionRoutesGrouped.get(use: minVersion)
            
            let sampleRoutesGrouped = routes.grouped("sample")
            sampleRoutesGrouped.post(use: sample)
        }
    And its functionality is encapsulated in the following endpoint.
        @Sendable
        func sample(req: Request) async throws -> SampleResponse {
            let payload = try req.content.decode(SampleRequestData.self)
            let isLatestVersion =  await payload.version == VersionResponse.current().currentVersion
            let isForceUpdate = await VersionResponse.current().forceUpdate
            guard  isLatestVersion ||
                   !isForceUpdate else {
                throw Abort(.upgradeRequired) // Force update flag set
            }
    
            guard await isVersion(payload.version, inRange: (VersionResponse.current().minimumVersion, VersionResponse.current().currentVersion)) else {
                throw Abort(.upgradeRequired) // Version out of valid range
            }
            
            return SampleResponse(data: "Some data...")
        }

    The first thing the function does is validate that the version adheres to the X.Y.Z syntax.

        struct SampleRequestData: Content {
            let version: String
            
            mutating func afterDecode() throws {
                guard isValidVersionString(version) else {
                    throw Abort(.badRequest, reason: "Wrong version format")
                }
            }
            
            private func isValidVersionString(_ version: String) -> Bool {
                let versionRegex = #"^\d+\.\d+\.\d+$"#
                let predicate = NSPredicate(format: "SELF MATCHES %@", versionRegex)
                return predicate.evaluate(with: version)
            }
        }

    Later on, the process involves validating the version of a client application against a server-defined versioning policy. If the version check is successful, a simple JSON response with sample data is returned.

    Returning to the command line, we execute the sample using valid version values:

    We received a valid sample endpoint response, along with current, minimum version and wether forced update is being required.

    However, when we set a version lower than the required minimum, we encountered an error requesting an upgrade.

    While the implementation is theoretically complete, handling version updates on the front end is not difficult, but any mistakes in production can have dramatic consequences. For this reason, it is mandatory to implement a comprehensive set of unit tests to cover the implementation and ensure that when versions are updated, consistency is maintained.

    From now on, every new endpoint implemented by the server must perform this frontend version check, along with other checks, before proceeding. Additionally, the code must be data race-safe.

    At the time of writing this post, I encountered several issues while compiling the required libraries for Vapor. As a result, I had to revert these settings to continue writing this post. Apologies for the back-and-forth.

    IOS frontend

    The iOS app frontend we are developing will primarily interact with a sample POST API. This API accepts JSON data, which includes the current frontend version.

    • If the frontend version is within the supported range, the backend responds with the expected output for the sample POST API, along with information about the versions supported by the backend.
    • If the frontend version falls below the minimum supported version and a forced update is required, the backend will return an «update required» error response.

    To ensure compliance with Swift 6, make sure that Strict Concurrency Checking is set to Complete.

    … and Swift language version to Swift 6.

    Before we start coding, let’s set the app version. The version can be defined in many places, which can be quite confusing. Our goal is to set it in a single, consistent location.

    This is the unique place where you need to set the version number. For the rest of the target, we will inherit that value. When we set the version in the target, a default value (1.0) is already set, and it is completely isolated from the project. We are going to override this by setting MARKETING_VERSION to $(MARKETING_VERSION), so the value will be taken from the project’s MARKETING_VERSION.

    Once set, you will see that the value is adopted. One ring to rule them all.

    The application is not very complicated, and if you’re looking for implementation details, you can find the GitHub repository at the end of the post. Essentially, what it does is perform a sample request as soon as the view is shown.

    Make sure the Vapor server is running before launching the app on a simulator (not a real device, as you’re targeting localhost). You should see something like this:

    Simulator Screenshot - iPhone 16 Pro Max - 2024-12-05 at 12.05.23

    The current app version is 1.7.0, while the minimum supported backend version is 1.5.0, and the backend is currently at version 2.0.0. No forced update is required. Therefore, the UI displays a message informing users that they are within the supported version range, but it also indicates that an update to the latest version is available.

    Once we configure the Vapor backend to enforce a forced update:

            let versionResponse = VersionResponse(currentVersion: "2.0.0",
                                                  minimumVersion: "1.5.0",
                                                  forceUpdate: true)
            

    Re-run vapor server:

    Screenshot

    Re-run the app:

    Simulator Screenshot - iPhone 16 Pro Max - 2024-12-05 at 12.16.03

    The front-end needs to be updated, and users are required to update the app. Please provide a link to the Apple Store page for downloading the update.

    Conclusions

    In this post, I have demonstrated a method for versioning API communication between the backend and frontend. I acknowledge that my explanation of the implementation is brief, but you can find the backend and frontend repositories linked here.

    References

  • Safely Gathering Singletons While Avoiding Data Races

    Safely Gathering Singletons While Avoiding Data Races

    The text is clear and conveys the intended message effectively. However, it can be slightly refined for improved readability and flow. Here’s a polished version: In our previous post, we discussed migrating an app that uses a Singleton to Swift 6.0. In this post, we’ll focus on consolidating multiple multipurpose Singletons into a single access point. This approach simplifies unit testing by enabling the injection of mocked Singletons.

    Base project

    We begin where we left off in the iOS Location Manager: A Thread-Safe Approach post. In that post, we explained how to migrate a Location Manager. Now, we’ll create a new blank project, ensuring that the Swift testing target is included.

    The base code is the source code provided in the commented section of the post. At the end of the post, you will find a link to the GitHub repository. By reviewing its history, you can trace back to this point.

    At this stage, we will create a second singleton whose purpose is to manage a long-running background task.

    @globalActor
    actor GlobalManager {
        static var shared = GlobalManager()
    }
    
    protocol LongTaskManagerProtocol {
        @MainActor var isTaskDone: Bool { get }
        func doLongTask() async
    }
    
    @GlobalManager
    class LongTaskManager: ObservableObject, LongTaskManagerProtocol {
    
        @MainActor
        static let shared = LongTaskManager()
    
        @MainActor
        @Published var isTaskDone: Bool = false
        
        private var isTaskDoneInternal: Bool = false {
            didSet {
                Task {
                    await MainActor.run { [isTaskDoneInternal] in
                        isTaskDone = isTaskDoneInternal
                    }
                }
            }
        }
    
        #if DEBUG
        @MainActor
        /*private*/ init() {
        }
        #else
        @MainActor
        private init() {
        }
        #endif
        
        // MARK :- LongTaskManagerProtocol
        func doLongTask() async {
            isTaskDoneInternal = false
            print("Function started...")
            // Task.sleep takes nanoseconds, so 10 seconds = 10_000_000_000 nanoseconds
            try? await Task.sleep(nanoseconds: 10_000_000_000)
            print("Function finished!")
            isTaskDoneInternal = true
        }
    }

    Key Concepts at Work

    1. Actor Isolation:

      • Ensures thread safety and serializes access to shared state (isTaskDoneInternal) through GlobalManager.
      • @MainActor guarantees main-thread access for UI-related properties and tasks.
    2. SwiftUI Integration:

      • @Published with ObservableObject enables reactive UI updates.
    3. Encapsulation:

      • Internal state (isTaskDoneInternal) is decoupled from the externally visible property (isTaskDone).
    4. Concurrency-Safe Singleton:

      • The combination of @MainActor, @GlobalManager, and private init creates a thread-safe singleton usable across the application.
     
    We will now make minimal changes to ContentView to integrate and provide visibility for this new Singleton.
    struct ContentView: View {
        @StateObject private var locationManager = LocationManager.shared
        @StateObject private var longTaskManager = LongTaskManager.shared
        
        var body: some View {
            VStack(spacing: 20) {
                Text("LongTask is \(longTaskManager.isTaskDone ? "done" : "running...")")
               ...
            .onAppear {
                locationManager.checkAuthorization()
                
                
                Task {
                  await longTaskManager.doLongTask()
                }
            }
            .padding()
        }
    }

    Key Concepts at Work

    1. Singleton Reference:
      Use a singleton reference to the LongTaskManager.
      The @StateObject property wrapper ensures that any changes in LongTaskManager.isTaskDone automatically update the ContentView.

    2. LongTaskManager Execution Status:
      The longTaskManager.isTaskDone property determines the message displayed based on the execution status.

    3. Start Long Task:
      The .onAppear modifier is the appropriate place to invoke longTaskManager.doLongTask().

    4. Testing on a Real Device:
      Build and deploy the app on a real device (iPhone or iPad) to observe the long task execution. You’ll notice that it takes a while for the task to complete.

    All the Singletons came together at one location

    During app development, there may come a point where the number of Singletons in your project starts to grow uncontrollably, potentially leading to maintenance challenges and reduced code manageability. While Singletons offer advantages—such as providing centralized access to key functionality (e.g., Database, CoreLocation, AVFoundation)—they also have notable drawbacks:

    1. Global State Dependency: Code relying on a Singleton is often dependent on global state, which can lead to unexpected behaviors when the state is altered elsewhere in the application.
    2. Challenges in Unit Testing: Singletons retain their state across tests, making unit testing difficult and prone to side effects.
    3. Mocking Limitations: Replacing or resetting a Singleton for testing purposes can be cumbersome, requiring additional mechanisms to inject mock instances or reset state.

    To address these challenges, the following Swift code defines a struct named AppSingletons. This struct serves as a container for managing singletons, simplifying dependency injection and promoting better application architecture.

    import Foundation
    
    struct AppSingletons {
        var locationManager: LocationManager
        var longTaskManager: LongTaskManager
        
        init(locationManager: LocationManager = LocationManager.shared,
             longTaskManager: LongTaskManager = LongTaskManager.shared) {
            self.locationManager = locationManager
            self.longTaskManager = longTaskManager
        }
    }
     var appSingletons = AppSingletons()

    Ensure that singleton references are obtained from appSinglegons.

    struct ContentView: View {
        @StateObject private var locationManager = appSingletons.locationManager
        @StateObject private var longTaskManager = appSingletons.longTaskManager
        
        var body: some View {

    After performing a sanity check to ensure everything is working, let’s move on to the test target and add the following unit test:

    import Testing
    @testable import GatherMultipleSingletons
    
    struct GatherMultipleSingletonsTests {
    
        @Test @MainActor func example() async throws {
            let longTaskManagerMock = LongTaskManagerMock()
            appSingletons = AppSingletons(longTaskManager: longTaskManagerMock)
            #expect(appSingletons.longTaskManager.isTaskDone == false)
            await appSingletons.longTaskManager.doLongTask()
            #expect(appSingletons.longTaskManager.isTaskDone == true)
        }
    
    }
    
    final class LongTaskManagerMock: LongTaskManager {
        
        override func doLongTask() async {
            await MainActor.run {
                isTaskDone = true
            }
        }
    }
    The test verifies the behavior of a mock implementation of a singleton when performing a long task. It is likely part of verifying the integration between AppSingleton and LongTaskManager, ensuring that the singleton’s behavior matches expectations under controlled test conditions. By using the mock, the test becomes predictable and faster, avoiding the need for actual long-running logic.

    …Thread safe touch

    Now is time to turn  this code into a thread safe. Set Swift Concurrency Checking to Complete:

    … and Swift language version to Swift 6.

    The first issue we identified is that, from a non-isolated domain, the struct is attempting to access an isolated one (@MainActor). Additionally, appSingletons is not concurrency-safe because, as mentioned, it resides in a non-isolated domain.

    ContentView (@MainActor) is currently accessing this structure directly. The best approach would be to move the structure to an @MainActor-isolated domain.

    import Foundation
    
    @MainActor
    struct AppSingletons {
        var locationManager: LocationManager
        var longTaskManager: LongTaskManager
        
        init(locationManager: LocationManager = LocationManager.shared,
             longTaskManager: LongTaskManager = LongTaskManager.shared) {
            self.locationManager = locationManager
            self.longTaskManager = longTaskManager
        }
    }
    
    @MainActor var appSingletons = AppSingletons()

    This means that the LongTaskManager is executed only within the @MainActor. However, this isn’t entirely true. The part responsible for accessing shared attributes and updating the @Published property is executed under the @MainActor, but the part performing the heavy lifting runs in a @globalActor isolated domain.

    Conclusions

    In this post I have showed a way avoid Singleton discontrol, by gathering them in a global structure. You can find the source code used in this post in the following repository.

    References

  • Streamlining Your Xcode Projects with GitHub Actions

    Streamlining Your Xcode Projects with GitHub Actions

    Having good practices is one of the key points for successfully steering your project to completion, especially when working as part of a team. In this post, I will explain how to implement these tasks in a CI/CD environment such as GitHub.

    First, we will set up essential tasks like unit testing and linting locally, and then apply these tasks as requirements for integration approvals.

    Executing unit test through command line console

    At this point, we assume that the unit test target is properly configured in your project. This section is important because we will need to use the command in the future.»

    xcodebuild is a command-line tool provided by Apple as part of Xcode, it allows developers to build and manage Xcode projects and workspaces from the terminal, providing flexibility for automating tasks, running continuous integration (CI) pipelines, and scripting.

    Simply execute the command to validate that everything is working correctly.

    Linting your code

    Linting your code not only improves quality, prevents errors, and increases efficiency, but it also facilitates team collaboration by reducing time spent on code reviews. Additionally, linting tools can be integrated into CI pipelines to ensure that checks are part of the build and deployment process.

    The tool we will use for linting is SwiftLint. Here, you will find information on how to install it on your system. Once it is properly installed on your system:

    Go to project root folder and create file .swiftlint.yml, this is default configuration, you can check following link to know more about the defined rules.

    disabled_rules:
    - trailing_whitespace
    opt_in_rules:
    - empty_count
    - empty_string
    excluded:
    - Carthage
    - Pods
    - SwiftLint/Common/3rdPartyLib
    line_length:
        warning: 150
        error: 200
        ignores_function_declarations: true
        ignores_comments: true
        ignores_urls: true
    function_body_length:
        warning: 300
        error: 500
    function_parameter_count:
        warning: 6
        error: 8
    type_body_length:
        warning: 300
        error: 500
    file_length:
        warning: 1000
        error: 1500
        ignore_comment_only_lines: true
    cyclomatic_complexity:
        warning: 15
        error: 25
    reporter: "xcode"
    

    Now, let’s integrate this in Xcode. Select your target, go to Build Phases, click the plus (+) button, and choose ‘New Run Script Phase’.

    Rename the script name to ‘swiftlint’ for readability, and make sure to uncheck ‘Based on…’ and ‘Show environment…’.

    Paste the following script.

    echo ">>>>>>>>>>>>>>>>>>>>>>>> SWIFTLINT (BEGIN) >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>"
    if [[ "$(uname -m)" == arm64 ]]; then
        export PATH="/opt/homebrew/bin:$PATH"
    fi
    
    if which swiftlint > /dev/null; then
      swiftlint
    else
      echo "warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint"
    fi
    echo "<<<<<<<<<<<<<<<<<<<<<<<<< SWIFTLINT (END) <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<"

    Select the target and build (Cmd+B). If you review the build log, you will see a new warnings triggered.

    GitHub actions

    GitHub Actions is a powerful CI/CD  platform integrated within GitHub, allowing developers to automate, customize, and streamline their software workflows directly from their repositories. It uses YAML configuration files to define tasks or workflows that run in response to events, such as pushing code, opening pull requests, or setting schedules. With its flexibility, developers can automate building, testing or deploying applications.

    Workflow for executing unit test

    At this point, we assume that we are in the root folder of a repository cloned from GitHub. Navigate to (or create) the following folder: ./github/workflows. In that folder, we will place our first GitHub Action for executing the unit tests. In my case, it was a file called UTest.yml with the following content:

    name: utests-workflow
    
    on:
      pull_request:
        branches: [main, develop]
    jobs:
      utests-job:
        runs-on: macos-latest
    
        steps:
          - name: Check out the repository
            uses: actions/checkout@v4
    
          - name: Set to XCode 16.0
            uses: maxim-lobanov/setup-xcode@v1
            with:
               xcode-version: '16.0'
    
          - name: Execute Unit tessts (iOS target)
            run: xcodebuild test -scheme 'EMOM timers' -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest'
    
          - name: Execute Unit tessts (AW target)
            run: xcodebuild test -scheme 'EMOM timers Watch App' -destination 'platform=watchOS Simulator,name=Apple Watch Series 10 (42mm),OS=latest'
    

    The first tag is name, which contains the given name for the workflow. Next, the on tag defines which event can trigger the workflow to run. In this case, we are interested in executing the workflow when a pull request targets the main or develop branch (for available events, please review the documentation).

    Finally, we find the jobs section, which describes the tasks we want to execute. utest-job is the designated name for this job, and the environment where it will be executed is specified as macos-latest.

    Next, we find the steps, which outline the sequence of actions to be executed. The first step is to check out the repository. In this step, we specify a shell command, but instead, we see an action being referenced. An action is a piece of code created by the community to perform specific tasks. It is highly likely that someone has already written the action you need, so be sure to review GitHub Actions or GitHub Actions Marketplace.  The checkout action was defined in the GitHub Actions   repositoty, and we can see that its popularity is good, so let’s give it a try.

    The second step is to set the Xcode version to one that is compatible with your project. My project is currently working with Xcode 16.0, so we need to set it to that version. I found this action in the GitHub Action marketplace.

    The final two steps involve executing unit tests for each target. Now, we can commit and push our changes.

    Create a pull request on GitHub for your project, and at the end, you should see that the workflow is being executed.

    After few minutes…

    I aim you to force fail some unit test and push changes. Did allow you to integrate branch?

    Workflow for linting

    We are going to create a second workflow to verify that static syntax analysis (linting) is correct. For this purpose, we have created the following .yml GitHub Action workflow script:

    name: lint
    
    # Especifica en qué ramas se ejecutará el workflow
    on:
      pull_request:
        branches: [main, develop]
    jobs:
      lint:
        runs-on: macos-latest
        steps:
          - name: Checkout code
            uses: actions/checkout@v4
    
          - name: Install SwiftLint
            run: brew install swiftlint
    
          - name: Run SwiftLint
            run: swiftlint

    In this workflow, the name and job refer to the linting process, with the only differences being in the last two steps.

    The first step, as in the previous workflow, is to check out the branch. The next step is to install SwiftLint via Homebrew, and the final step is to run SwiftLint. In this case, we will deliberately trigger a linting error.

    Once we commit and push the change, let’s proceed to review the pull request. The lint workflow has been executed, and a linting error has been triggered. However, it is still possible to merge the pull request, which is what we want to avoid. In the next section, we’ll address this issue.

    Setup branch rules

    Now it’s time to block any merges on the develop branch. Go to your repository settings.

    In the Branches section, click Add rule under Branch protection rules and select Add a classic branch protection rule.

    Enter the branch name where the rule applies. In this case, it is develop. Check «Require status checks to pass before merging» and «Require branch to be up to date before merging». In the search box below, type the workflow name. In this case, we want the unit tests and linting to succeed before proceeding with the pull request.

    When we return to the pull request page (and possibly refresh), we will see that we are not allowed to merge. This was our goal: to block any pull request that does not meet the minimum quality requirements.

    This rule has been applied to the development branch. I leave it to the reader to apply the same rule to the main branch.

    Conclusions

    By integrating GitHub Actions into our team development process, we can automate tasks that help us avoid increasing technical debt.

    Related links