iOSSnapshotTestCase

repository·main·Indexed 23 days ago

https://github.com/uber/ios-snapshot-test-case

A framework for performing visual regression testing on iOS applications by comparing snapshots of UIViews or CALayers against reference images. It supports automatic naming, device/OS specificity via fileNameOptions, and visual fidelity for UIVisualEffect and Size Classes. The library can be installed via CocoaPods, Carthage, or Swift Package Manager.

Tokens
2.9K
Snippets
3
Records
9
Agent score
84%

What's inside iOSSnapshotTestCase

  1. How snapshot testing works in iOSSnapshotTestCase

    main
    A snapshot test captures the visual state of a UIView or CALayer using UIKit or Core Animation methods to generate an image. This snapshot is then compared against a "reference image" stored in your source code repository. If the images do not match, the test fails, providing a visual diff that shows exactly what changed (e.g., text overflow, incorrect image scaling, or unexpected highlighted states).
  2. Choosing between Application and Library Test Bundles

    main

    When writing unit tests for iOS, you must decide whether to use an Application Test Bundle or a Library Test Bundle (formerly known as Logic Test Bundles). The choice depends on the components you are testing and your requirements for test execution speed and parallelism.

    Application Test Bundles

    Use these for testing application-level components like UIViewControllers, UIWindows, and UIViews.

    Requirements & Limitations:

    • Requires a Test Host: An application must be running to host the tests.
    • Requires a Simulator: Tests run within a simulator environment.
    • API Access: Provides access to iOS APIs that are unavailable in Library bundles, such as:
      • -[UIControl sendActionsForControlEvents:] (standard implementation).
      • UIAppearance APIs.
      • Keychain operations.
    • Limitations:
      • UIWindow.makeKeyAndVisible will crash at runtime. A workaround is setting hidden = false, but there will still be no true 'key window'.
      • Cannot easily parallelize tests on a single simulator because only one host application can run at a time.

    Library Test Bundles

    Use these for testing frameworks or libraries. These do not strictly require a Test Host.

    Advantages:

    • Speed: Faster execution as there is no need to install or launch a host application.
    • Stability: Avoids the application lifecycle state of a host app, which can cause test instability.
    • Parallelism: Multiple xctest stub processes can run in parallel on a single simulator because they are not full iOS applications.

    Note for Buck users: Removing the test_host_app option for apple_test() rules allows buck and xctool to run your test bundles in parallel.

  3. Create a snapshot test

    main

    To implement a snapshot test, follow these steps:

    1. Subclass FBSnapshotTestCase instead of XCTestCase.
    2. Use FBSnapshotVerifyView (for UIView) or FBSnapshotVerifyLayer (for CALayer) within your test method.
    3. Recording Reference Images: To generate the initial reference images, set self.recordMode = YES; in your test's -setUp method and run the test once.
    4. Running Verification: Remove the recordMode line and run the test again to compare the current view against the stored reference images.
  4. Simulate UIControl actions in Library Test Bundles

    main

    In a Library Test Bundle (without a Test Host), the standard UIControl method sendActionsForControlEvents: does not work. To trigger code paths that normally run when a user interacts with a control, you must use a custom implementation that manually iterates through targets and actions.

    Objective-C Implementation

    You can use the following ub_sendActionsForControlEvents: method. Warning: Ensure this category is only visible within your unit tests to avoid polluting your production codebase.

    Swift Implementation

    If you are using Swift, you can use the testSendActions(for:toTarget:) extension.

    Note: If any of the targets in allTargets do not subclass NSObject, the testSendActions(for:) method will crash. In such cases, call testSendActions(for:toTarget:) directly for each specific target.

    import UIKit
    
    public extension UIControl {
        /// Note: if any of the targets in `allTargets` do not subclass NSObject, this will crash.
        /// You should directly call `testSendActions(for:toTarget:)` for each target instead.
        ///
        /// - Parameter controlEvent: The control events to send actions.
        func testSendActions(
            for controlEvent: UIControl.Event
        ) {
            for target in allTargets {
                testSendActions(for: controlEvent, toTarget: target as AnyObject)
            }
        }
    
        /// Send an action for a given control event to a target.
        /// - Parameters:
        ///   - controlEvent: A control event to send.
        ///   - target: The target to send it to.
        func testSendActions(
            for controlEvent: UIControl.Event,
            toTarget target: AnyObject
        ) {
            guard let actions = actions(forTarget: target, forControlEvent: controlEvent) else {
                return
            }
    
            for action in actions {
                _ = target.perform(Selector(action), with: self)
            }
        }
    }
  5. Install iOSSnapshotTestCase

    main

    You can install iOSSnapshotTestCase using CocoaPods, Carthage, or Swift Package Manager.

    CocoaPods

    Add to your Podfile. For Swift projects, use pod 'iOSSnapshotTestCase'. For Objective-C only projects, use pod 'iOSSnapshotTestCase/Core' to avoid Swift dependencies.

    Carthage

    Add to your Cartfile:

    github "uber/ios-snapshot-test-case" ~> 8.0.0

    Swift Package Manager

    Add to your Package.swift dependencies or use Xcode's "Add Package Dependency" menu with the repository URL. It is recommended to use "Up to Next Major" versioning.

    target "Tests" do
      use_frameworks!
      pod 'iOSSnapshotTestCase'
    end
  6. Configure reference and diff directories in your Test Scheme

    main

    To manage where snapshot images are stored and where failure diffs are saved, define environment variables in your Xcode Test Scheme.

    Recommended settings:

    • FB_REFERENCE_IMAGE_DIR: The directory for reference images. A common pattern is $(SOURCE_ROOT)/$(PROJECT_NAME)Tests/ReferenceImages.
    • IMAGE_DIFF_DIR: The directory for diff images when a test fails. A common pattern is $(SOURCE_ROOT)/$(PROJECT_NAME)Tests/FailureDiffs.
  7. Release process for iOSSnapshotTestCase

    main

    To release a new version of iOSSnapshotTestCase, follow these steps in order:

    1. Prepare Metadata: Update CHANGELOG.md and the version number in iOSSnapshotTestCase.podspec.
    2. Validate CocoaPods: Run pod lib lint from the repository root.
    3. Verify Demo (CocoaPods): Run pod install inside demos/FBSnapshotTestCaseDemo/.
    4. Commit and Tag: Commit all changes to the master branch and create a git tag using the version number (e.g., git tag 0.0.1).
    5. Push Tags: Push the tag to the remote repository using git push --tags.
    6. Push to CocoaPods Trunk: Execute pod trunk push iOSSnapshotTestCase.podspec.
    7. Build Carthage Frameworks:
      • Navigate to demos/iOSSnapshotTestCaseCarthageDemo/.
      • Run carthage update --use-xcframeworks.
      • Run carthage build --no-skip-current --use-xcframeworks.
      • Note: iOSSnapshotTestCaseCarthageDemo/Cartfile.resolved can be committed and pushed even if it changed without a new version.
    8. Archive Framework:
      • Return to the repository root.
      • Run carthage build --archive --configuration Debug --use-xcframeworks.
    9. Finalize Release: Upload the resulting FBSnapshotTestCase.framework.zip to the corresponding tagged release on GitHub.
  8. Reference: Snapshot testing features

    main

    iOSSnapshotTestCase includes several features for advanced testing:

    • Automatic Naming: Reference images are named based on the test class and selector.
    • Identifiers: You can provide an optional identifier to perform multiple snapshots within a single test method.
    • Layer Support: Use FBSnapshotVerifyLayer for CALayer snapshots.
    • Visual Fidelity: Supports usesDrawViewHierarchyInRect to correctly handle UIVisualEffect, UIAppearance, and Size Classes.
    • Device/OS Specificity: Use fileNameOptions to append device models (e.g., iPhone), OS versions, screen sizes, and scales to filenames, allowing the same test to run across different configurations.
  9. ub_sendActionsForControlEvents: (Objective-C)

    main

    A custom implementation for UIControl to be used in Library Test Bundles where the standard sendActionsForControlEvents: is unavailable. It mimics the behavior by finding all targets and actions associated with a specific UIControlEvents bitmask and invoking them manually.

    #import <UIKit/UIKit.h>
    
    @interface UIControl (SendActions)
    
    /**
     In library test bundles with no test host, the default sendActionsForControlEvents: does not work.
    
     This replacement mimics the same idea of that method by finding all the targets associated with the control, finding all the actions on that target for the given control event, and invoking those actions on those targets.
    
     @param controlEvents A bitmask whose set flags specify the control events for which action messages are sent.
     */
    - (void)ub_sendActionsForControlEvents:(UIControlEvents)controlEvents;
    
    @end
    
    static NSUInteger const UIControlEventsMaxOffset = 19;
    
    @implementation UIControl (UberTesting)
    
    - (void)ub_sendActionsForControlEvents:(UIControlEvents)controlEvents
    {
      for (NSUInteger i = 0; i < UIControlEventsMaxOffset; i++) {
        UIControlEvents controlEvent = 1 << i;
        if (controlEvents & controlEvent) {
          for (id target in self.allTargets) {
            NSArray<NSString *> *targetActions = [self actionsForTarget:target forControlEvent:controlEvent];
            for (NSString *action in targetActions) {
              SEL selector = NSSelectorFromString(action);
              IMP imp = [target methodForSelector:selector];
              void (*func)(id, SEL, id) = (void *)imp;
              func(target, selector, self);
            }
          }
        }
      }
    }
    
    @end