Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Snapshot Testing in Practice: Predictable and R...

Snapshot Testing in Practice: Predictable and Reliable SwiftUI Views

Presented at https://www.nextappcon.com/swiftcon in Berlin, October 2026

UI changes can easily introduce regressions, especially in shared SwiftUI components. Snapshot testing helps catch these issues early by verifying your UI across states and device configurations.

But Snapshot Testing does more than prevent regressions. Making UI testable encourages more predictable code, which leads to better architecture and makes it easier to reason about different UI states, resulting in more maintainable SwiftUI views.

In this talk, I’ll share real-world experience using Snapshot Testing in a large codebase: how it improves reliability, how to introduce it into your workflow, and how to run it in CI with Xcode Cloud for your iOS apps.

Avatar for Felipe Espinoza

Felipe Espinoza

October 08, 2026

More Decks by Felipe Espinoza

Other Decks in Technology

Transcript

  1. Snapshot Testing to improve the craft of your UI code

    and extra benefits of working with them
  2. Agenda What’s Snapshot Testing? How to use it? How to

    control data? Practical problems to solve
  3. Agenda What’s Snapshot Testing? How to use it? How to

    control data? Practical problems to solve
  4. import SwiftUI import SnapshotTesting import Testing @testable import NordicGym @MainActor

    @Suite struct `Home Screen Snapshots` { @Test func `sample content`() async throws { let view = NavigationStack { HomeScreen() } .environment(\.networkingClient, .test()) ? assertSnapshot(of: view, as: .image) } } Rendered UI Reference Image
  5. 􀆄 import SwiftUI import SnapshotTesting import Testing @testable import NordicGym

    @MainActor @Suite struct `Home Screen Snapshots` { @Test func `sample content`() async throws { let view = NavigationStack { HomeScreen() } .environment(\.networkingClient, .test()) assertSnapshot(of: view, as: .image) } } Reference Image
  6. 􀆅 import SwiftUI import SnapshotTesting import Testing @testable import NordicGym

    @MainActor @Suite struct `Home Screen Snapshots` { @Test func `sample content`() async throws { let view = NavigationStack { HomeScreen() } .environment(\.networkingClient, .test()) assertSnapshot(of: view, as: .image) } } Rendered UI Reference Image
  7. Device Type (+ Model) iPhone 18 Pro iPhone Air iPad

    11 Inch 2026 Orientation Portrait Split 1/3 Half Interface Style Light Dark Localization English German Norwegian Region Norway Germany United States Dynamic type Small iPad Air 2025 Landscape 2/3 Large Many other accessibility settings… Full Extra Large Spanish Chile Accessibility1 Accessibility5 … …
  8. Device Type (+ Model) iPhone 18 Pro iPhone Air iPad

    11 Inch 2026 Orientation Portrait Split 1/3 Half Interface Style Light Dark Localization English German Norwegian Region Norway Germany United States Dynamic type Small iPad Air 2025 Landscape 2/3 Large Many other accessibility settings… Full Extra Large Spanish Chile Accessibility1 Accessibility5 … …
  9. Device Type (+ Model) iPhone 18 Pro iPhone Air iPad

    11 Inch 2026 Orientation Portrait Split 1/3 Half Interface Style Light Dark Localization English German Norwegian Region Norway Germany United States Dynamic type Small iPad Air 2025 Landscape 2/3 Large Many other accessibility settings… Full Extra Large Spanish Chile Accessibility1 Accessibility5 … …
  10. Device Type (+ Model) iPhone 18 Pro iPhone Air iPad

    11 Inch 2026 Orientation Portrait Split 1/3 Half Interface Style Light Dark Localization English German Norwegian Region Norway Germany United States Dynamic type Small iPad Air 2025 Landscape 2/3 Large Many other accessibility settings… Full Extra Large Spanish Chile Accessibility1 Accessibility5 … …
  11. What about Xcode Previews? You need to actively run them

    Snapshot ensure your UI keeps working well Making your UI easier to test also improves previews
  12. Agenda What’s Snapshot Testing? How to use it? How to

    control data? Practical problems to solve
  13. 92 snapshot-test suites 194 test methods inside those suites 202

    expectSnapshot call sites 91 snapshot source les/directories fi 858 PNG reference images
  14. Agenda What’s Snapshot Testing? How to use it? How to

    control data? Practical problems to solve
  15. struct HomeScreen: View { @State var content: BasicLoadingState<[HomeModuleViewData]> = .idle

    @Environment(\.networkingClient.fetchHomeContent) var fetchHomeContent var body: some View { BasicStateView(state: $content) { viewData in HomeView(modules: viewData) } fetchData: { let dto = try await fetchHomeContent() return dto.map { HomeModuleViewData(dto: $0) } } } }
  16. { "items": [ { "type": "upcomingClasses", "content": [ { "id":

    "love2dance-20260911-1730", "name": "Love2Dance", "instructorName": "Eva", "location": "Ringnes Park", "dateTime": "2026-09-11T17:30:00+02:00", "durationInMinutes": 60, "bookingState": "booked", "availableSpots": 3, "imageURL": "https://images.pexels.com/photos/5936039/pexels-photo-5936039.jpeg?auto=compress&cs=tinysrgb&w=900" }, { "id": "rowing-20260912-1830", "name": "Rowing", "instructorName": "Erling", "location": "Ringnes Park", "dateTime": "2026-09-12T18:30:00+02:00", "durationInMinutes": 30, "bookingState": "booked", "availableSpots": 8, "imageURL": "https://images.pexels.com/photos/4162486/pexels-photo-4162486.jpeg?auto=compress&cs=tinysrgb&w=900" }, { "id": "yoga-flow-20260913-1000", "name": "Yoga Flow", "instructorName": "Ingrid", "location": "Majorstuen", "dateTime": "2026-09-13T10:00:00+02:00", "durationInMinutes": 60, "bookingState": "bookedOnWaitingList", "availableSpots": 0, "imageURL": "https://images.pexels.com/photos/4587342/pexels-photo-4587342.jpeg?auto=compress&cs=tinysrgb&w=900" } ] }, { "type": "friendActivity",
  17. "instructorName": "Jonas", "location": "Nydalen", "dateTime": "2026-09-12T12:00:00+02:00", "durationInMinutes": 30, "bookingState": "notBooked",

    "availableSpots": 1, "imageURL": "https://images.pexels.com/photos/3757943/pexels-photo-3757943.jpeg?auto=compress&cs=tinysrgb&w=900" } }, { "friend": { "id": "member-mikkel", "firstName": "Mikkel", "lastName": "Solberg", "profilePicture": "https://randomuser.me/api/portraits/men/75.jpg" }, "groupClass": { "id": "dance-energy-20260913-1600", "name": "Dance Energy", "instructorName": "Eva", "location": "Ringnes Park", "dateTime": "2026-09-13T16:00:00+02:00", "durationInMinutes": 50, "bookingState": "notBookedOnWaitingList", "availableSpots": 0, "imageURL": "https://images.pexels.com/photos/5936039/pexels-photo-5936039.jpeg?auto=compress&cs=tinysrgb&w=900" } } ] }, { "type": "featuredContent", "content": { "id": "88B995ED-C598-4622-AD58-0BD07B508471", "title": "What are your objectives?", "message": "Build strength, find your rhythm, or make more time for yourself. Explore classes that fit your goals and plan your next workout with a friend.", "imageURL": "https://images.pexels.com/photos/4587371/pexels-photo-4587371.jpeg?auto=compress&cs=tinysrgb&w=900" } } ] }
  18. struct UpcomingClassesCard: View { let sections: [UpcomingClassesSectionViewData] var body: some

    View { VStack(alignment: .leading, spacing: .spacingL) { ForEach(sections) { section in VStack(alignment: .leading) { // Section Title } } } } VStack(alignment: .leading, spacing: .spacingM) { ForEach(section.classes) { row in GroupClassRow(viewData: row) } } } .cardStyle()
  19. struct GroupClassRow: View { let viewData: GroupClassRowViewData var body: some

    View { NavigationLink(destination: { GroupClassScreen(id: viewData.id) }) { HStack(spacing: .spacingM) { Spacer(minLength: 4) HStack(alignment: .top, spacing: .spacingM) { VStack(alignment: .leading) { Text(viewData.time.formatted(date: .omitted, time: .shortened)) Text(viewData.duration) .foregroundStyle(.secondary) .font(.footnote) } VStack(alignment: .leading) { Text(viewData.className) Text("w/ \(viewData.instructorName)") .foregroundStyle(.secondary) Text(viewData.location) .foregroundStyle(.secondary) Text(viewData.bookingState.message) .foregroundStyle(viewData.bookingState.foregroundColor) } .frame(maxWidth: .infinity, alignment: .leading) } Image(systemName: viewData.bookingState.imageName)
  20. ViewData Just the "right amount" of data for a view

    The data in the "right" format*
  21. { "id": "dance-energy-20260913-1600", "gym": { "id": "ringnes-park", "name": "Ringnes Park"

    }, "room": "Dance Studio", "instructor": { "id": "eva-larsen", "firstName": "Eva", "lastName": "Larsen" }, "startTime": "2026-09-13T16:00:00+02:00", "durationInMinutes": 50, "bookingState": "notBookedOnWaitingList", "availableSpots": 0, "capacity": 30, "friendsAttending": [ { "member": { "id": "member-mikkel", "firstName": "Mikkel", "lastName": "Solberg", "profilePicture": "https://randomuser.me/api/portraits/men/75.jpg" }, "bookingState": "booked" } ], "similarClasses": [ { "id": "love2dance-20260911-1730", "name": "Love2Dance", "instructorName": "Eva", "location": "Ringnes Park", "dateTime": "2026-09-11T17:30:00+02:00", "durationInMinutes": 60, "bookingState": "booked", "availableSpots": 3, "imageURL": "https://images.pexels.com/photos/5936039/pexels-photo-5936039.jpeg?auto=compress&cs=tinysrgb&w=900" } ], "name": "Dance Energy", "description": "Move to upbeat music in an energetic dance workout. Follow Eva through easy-to-learn combinations that build coordination and get your heart pumping.", "imageURL": "https://images.pexels.com/photos/5936039/pexels-photo-5936039.jpeg?auto=compress&cs=tinysrgb&w=900", "intensity": "high" }
  22. struct GroupClass: Decodable { let id: GroupClassID let gym: Gym

    let room: String? let instructor: Instructor let startTime: Date let durationInMinutes: Int let bookingState: BookingState let availableSpots: Int let capacity: Int let friendsAttending: [FriendAttending] let similarClasses: [UpcomingGroupClass] let name: String let description: String let imageURL: URL? let intensity: Intensity enum Intensity: String, Codable { case low case medium case high } }
  23. struct GroupClassRowViewData: Identifiable, Equatable { let id: GroupClassID let className:

    String let instructorName: String let location: String let time: Date let duration: String let bookingState: BookingStateViewData }
  24. extension GroupClassRowViewData { init(dto: GroupClass) { self.init( id: dto.id, className:

    dto.name, instructorName: PersonNameComponents( givenName: dto.instructor.firstName, familyName: dto.instructor.lastName ).formatted(), location: dto.gym.name, time: dto.startTime, duration: dto.durationInMinutes.formatted() + " min", bookingState: .init( dto: dto.bookingState, availableSpots: dto.availableSpots ) ) } } DTO ViewData
  25. extension GroupClassRowViewData { static func previewValue( id: GroupClassID = .previewValue(),

    className: String = "Love2Dance", instructorName: String = "Eva", location: String = "Ringnes Park", time: Date = Date(), duration: String = "60 min", bookingState: BookingStateViewData = .previewValue() ) -> Self { .init( id: id, className: className, instructorName: instructorName, location: location, time: time, duration: duration, bookingState: bookingState ) } }
  26. Agenda What’s Snapshot Testing? How to use it? How to

    control data? Practical problems to solve
  27. Practical problems to solve Setting up Xcode Cloud Controlling time

    zones Controlling image loading fl The " ashing" problem
  28. The test runner doesn’t have access to the Source Code,

    then it cannot see the reference images
  29. Practical problems to solve Setting up Xcode Cloud Controlling time

    zones Controlling image loading fl The " ashing" problem
  30. struct GroupClassRowViewData: Identifiable, Equatable { let id: GroupClassID let className:

    String let instructorName: String let location: String let time: Date let duration: Int let bookingState: BookingStateViewData }
  31. struct GroupClassRow: View { let viewData: GroupClassRowViewData var body: some

    View { NavigationLink(destination: { GroupClassScreen(id: viewData.id) }) { HStack(spacing: .spacingM) { Spacer(minLength: 4) HStack(alignment: .top, spacing: .spacingM) { VStack(alignment: .leading) { Text(viewData.time.formatted(date: .omitted, time: .shortened)) //...
  32. struct GroupClassRowViewData: Identifiable, Equatable { let id: GroupClassID let className:

    String let instructorName: String let location: String let time: String let duration: String let bookingState: BookingStateViewData }
  33. extension GroupClassRowViewData { init(dto: UpcomingGroupClass) { self.init( id: dto.id, className:

    dto.name, instructorName: dto.instructorName, location: dto.location, time: dto.dateTime.formatted(date: .omitted, time: .shortened), duration: dto.durationInMinutes.formatted() + " min", bookingState: .init( dto: dto.bookingState, availableSpots: dto.availableSpots ) ) } }
  34. @Test(arguments: SnapshotVariant.defaultVariants(checkAccessibility: true)) func `home content - controlling the time

    zones`(variant: SnapshotVariant) async throws { let view = NavigationStack { HomeScreen() } .environment( \.dataClient.fetchHomeContent, { [ .upcomingWorkouts([ .previewValue( title: "Tomorrow", classes: [.previewValue(time: "17:30")] ), .previewValue( title: "Friday", classes: [.previewValue(className: "Rowing", time: "18:30")] ), .previewValue( title: "Saturday", classes: [.previewValue(className: "Yoga Flow", time: "10:00")] ), ]) ] } ) expectSnapshot(of: view, on: variant) }
  35. Practical problems to solve Setting up Xcode Cloud Controlling time

    zones Controlling image loading fl The " ashing" problem
  36. struct SocialActivityRow: View { let viewData: SocialActivityRowViewData var body: some

    View { HStack(spacing: .spacingS) { Color.secondary .overlay { AsyncImage(url: viewData.profilePictureURL) { image in image .resizable() .scaledToFill() } placeholder: { Color.gray } } .frame(width: 48, height: 48) .clipShape(Circle()) VStack(alignment: .leading) { Text(viewData.message) Text(viewData.time).foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) Image(systemName: "hand.thumbsup") .bold() .font(.title2) .foregroundStyle(.accent) .padding(.spacingM)
  37. public enum ImageViewData: Equatable, Sendable { case empty case remote(url:

    URL) case loading case image(_ image: Image) case error }
  38. private let allImageCases: [ImageViewData] = [ .loading, .error, .empty, .image(Image(.bookCyclingInterval)),

    .remote(from: URL(string: "https://randomuser.me/api/portraits/women/44.jpg")), ] #Preview(traits: .sizeThatFitsLayout, arguments: allImageCases) { imageViewData in CustomAsyncImage(state: imageViewData) { image in image .resizable() .scaledToFill() } .frame(width: 300, height: 300) .padding() }
  39. struct SocialActivityRow: View { let viewData: SocialActivityRowViewData var body: some

    View { HStack(spacing: .spacingS) { Color.secondary .overlay { AsyncImage(url: viewData.profilePictureURL) { image in image .resizable() .scaledToFill() } placeholder: { Color.gray } } .frame(width: 48, height: 48) .clipShape(Circle()) VStack(alignment: .leading) { Text(viewData.message) Text(viewData.time).foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) Image(systemName: "hand.thumbsup") .bold() .font(.title2) .foregroundStyle(.accent) .padding(.spacingM)
  40. struct SocialActivityRow: View { let viewData: SocialActivityRowViewData var body: some

    View { HStack(spacing: .spacingS) { Color.secondary .overlay { CustomAsyncImage(state: viewData.profilePicture) { image in image .resizable() .scaledToFill() } placeholder: { Color.gray } } .frame(width: 48, height: 48) .clipShape(Circle()) VStack(alignment: .leading) { Text(viewData.message) Text(viewData.time).foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) Image(systemName: "hand.thumbsup") .bold() .font(.title2) .foregroundStyle(.accent) .padding(.spacingM)
  41. Practical problems to solve Setting up Xcode Cloud Controlling time

    zones Controlling image loading fl The " ashing" problem
  42. struct SocialActivityRow: View { let viewData: SocialActivityRowViewData var body: some

    View { HStack(spacing: .spacingS) { AsyncImage(url: viewData.profilePictureURL) { image in image .resizable() .scaledToFill() .frame(width: 48, height: 48) .clipShape(Circle()) } placeholder: { Color.gray } // ... } } }
  43. @testable import NordicGym import Testing import SwiftUI import SnapshotTesting @MainActor

    @Suite struct `Social Row Tests` { @Test(arguments: SnapshotVariant.defaultVariants(checkAccessibility: true)) func `social row in different configurations`(variant: SnapshotVariant) async throws { let view = VStack { row(.previewValue(profilePicture: .loading, isLiked: true)) row(.previewValue(profilePicture: .image(Image(.homeErikPortrait)), isLiked: false)) row(.previewValue(profilePicture: .none, isLiked: false)) row(.previewValue( message: """ **Diego Fernando Morales Castañeda Fernandez** \ did the toughest class known to mankind """ )) } .padding() expectSnapshot(of: view, on: variant) } private func row(_ viewData: SocialActivityRowViewData) -> some View { SocialActivityRow(viewData: viewData) } }
  44. Making UI Testable Better apps for people No need to

    test all combinations Find the variants that cover the most ground No need to use ViewData Find your own abstractions
  45. Slide s, Sam every p l thing e code a

    f can b rom this nd e fou t a nd in lk
  46. /// Basic snapshot variants to test a screen on. ///

    /// by default it will do: /// - iPhone Light /// - iPhone Dark /// - iPad Portrait Light /// - iPad Landscape Dark /// - iPhone Portrait Light Accessibility 3* /// - iPad Portrait Dark Accessibility 3* /// /// - Parameters: /// - navigationTitle: (default `nil`) If included, the view will be wrapped in a NavigationStack /// - checkAccessibility: (default `false`), adds a variant that checks dynamic type with an `.accessibility` size /// - Returns: An array of snapshot variants to test with static func defaultVariants( navigationTitle: String? = nil, checkAccessibility: Bool = false ) -> [SnapshotVariant]
  47. /// A snapshot strategy for comparing SwiftUI Views based on

    pixel equality. /// /// - Parameters: /// - drawHierarchyInKeyWindow: Utilize the simulator's key window in order to render /// `UIAppearance` and `UIVisualEffect`s. This option requires a host application for your /// tests and will _not_ work for framework test targets. /// - precision: The percentage of pixels that must match. /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a /// match. 98-99% mimics /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the /// human eye. /// - layout: A view layout override. /// - traits: A trait collection override. public static func image( drawHierarchyInKeyWindow: Bool = false, precision: Float = 1, perceptualPrecision: Float = 1, layout: SwiftUISnapshotLayout = .sizeThatFits, traits: UITraitCollection = .init() )