Categoría: Test

  • 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

  • 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.