Slide 1

Slide 1 text

Snapshot Testing in Practice: Predictable and Reliable SwiftUI Views Felipe Espinoza - October 2026

Slide 2

Slide 2 text

🇨🇱🇳🇴 10 years developing for apple platforms Lead developer for the iOS app at SATS Youtuber

Slide 3

Slide 3 text

No content

Slide 4

Slide 4 text

No content

Slide 5

Slide 5 text

No content

Slide 6

Slide 6 text

No content

Slide 7

Slide 7 text

No content

Slide 8

Slide 8 text

No content

Slide 9

Slide 9 text

🇳🇴 🇸🇪 🇩🇰 🇫🇮

Slide 10

Slide 10 text

~620000 Unique users per month iOS app

Slide 11

Slide 11 text

Snapshot Testing to improve the craft of your UI code and extra benefits of working with them

Slide 12

Slide 12 text

Making code easier to test Reduce coupling Better code Easier to reason about More maintainable

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

Unit test for your UI Code

Slide 16

Slide 16 text

􀏅 Snapshot Test Test File Rendered UI Reference Image

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

􀆄 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

Slide 19

Slide 19 text

􀆅 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

Slide 20

Slide 20 text

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 … …

Slide 21

Slide 21 text

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 … …

Slide 22

Slide 22 text

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 … …

Slide 23

Slide 23 text

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 … …

Slide 24

Slide 24 text

Combination Count

Slide 25

Slide 25 text

No content

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

No content

Slide 28

Slide 28 text

No content

Slide 29

Slide 29 text

No content

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

No content

Slide 32

Slide 32 text

DEMO First snapshot testing

Slide 33

Slide 33 text

No content

Slide 34

Slide 34 text

No content

Slide 35

Slide 35 text

No content

Slide 36

Slide 36 text

No content

Slide 37

Slide 37 text

No content

Slide 38

Slide 38 text

No content

Slide 39

Slide 39 text

92 snapshot-test suites 194 test methods inside those suites 202 expectSnapshot call sites 91 snapshot source les/directories fi 858 PNG reference images

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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) } } } }

Slide 42

Slide 42 text

No content

Slide 43

Slide 43 text

No content

Slide 44

Slide 44 text

No content

Slide 45

Slide 45 text

{ "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",

Slide 46

Slide 46 text

"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" } } ] }

Slide 47

Slide 47 text

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()

Slide 48

Slide 48 text

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)

Slide 49

Slide 49 text

ViewData

Slide 50

Slide 50 text

ViewData Just the "right amount" of data for a view The data in the "right" format*

Slide 51

Slide 51 text

{ "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" }

Slide 52

Slide 52 text

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 } }

Slide 53

Slide 53 text

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 }

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

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 ) } }

Slide 56

Slide 56 text

No content

Slide 57

Slide 57 text

Component states? Some data: best case Empty state Long names Too many items

Slide 58

Slide 58 text

No content

Slide 59

Slide 59 text

Empty State Long Names

Slide 60

Slide 60 text

View Context Logic of Data

Slide 61

Slide 61 text

Making code easier to test Reduce coupling Better code Easier to reason about More maintainable

Slide 62

Slide 62 text

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

Slide 63

Slide 63 text

view = f(state)

Slide 64

Slide 64 text

Practical problems to solve Setting up Xcode Cloud Controlling time zones Controlling image loading fl The " ashing" problem

Slide 65

Slide 65 text

No content

Slide 66

Slide 66 text

The test runner doesn’t have access to the Source Code, then it cannot see the reference images

Slide 67

Slide 67 text

No content

Slide 68

Slide 68 text

Practical problems to solve Setting up Xcode Cloud Controlling time zones Controlling image loading fl The " ashing" problem

Slide 69

Slide 69 text

No content

Slide 70

Slide 70 text

􀆄 (GTM+1) Europe/Oslo (GTM+7) Asia/Tonsk

Slide 71

Slide 71 text

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 }

Slide 72

Slide 72 text

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)) //...

Slide 73

Slide 73 text

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 }

Slide 74

Slide 74 text

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 ) ) } }

Slide 75

Slide 75 text

@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) }

Slide 76

Slide 76 text

Practical problems to solve Setting up Xcode Cloud Controlling time zones Controlling image loading fl The " ashing" problem

Slide 77

Slide 77 text

No content

Slide 78

Slide 78 text

No content

Slide 79

Slide 79 text

No content

Slide 80

Slide 80 text

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)

Slide 81

Slide 81 text

No content

Slide 82

Slide 82 text

CustomAsyncImage

Slide 83

Slide 83 text

public enum ImageViewData: Equatable, Sendable { case empty case remote(url: URL) case loading case image(_ image: Image) case error }

Slide 84

Slide 84 text

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() }

Slide 85

Slide 85 text

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)

Slide 86

Slide 86 text

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)

Slide 87

Slide 87 text

No content

Slide 88

Slide 88 text

Practical problems to solve Setting up Xcode Cloud Controlling time zones Controlling image loading fl The " ashing" problem

Slide 89

Slide 89 text

No content

Slide 90

Slide 90 text

No content

Slide 91

Slide 91 text

No content

Slide 92

Slide 92 text

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 } // ... } } }

Slide 93

Slide 93 text

@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) } }

Slide 94

Slide 94 text

No content

Slide 95

Slide 95 text

view = f(state+)

Slide 96

Slide 96 text

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

Slide 97

Slide 97 text

Slide s, Sam every p l thing e code a f can b rom this nd e fou t a nd in lk

Slide 98

Slide 98 text

No content

Slide 99

Slide 99 text

/// 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]

Slide 100

Slide 100 text

No content

Slide 101

Slide 101 text

No content

Slide 102

Slide 102 text

No content

Slide 103

Slide 103 text

No content

Slide 104

Slide 104 text

No content

Slide 105

Slide 105 text

No content

Slide 106

Slide 106 text

Reference Failure

Slide 107

Slide 107 text

No content

Slide 108

Slide 108 text

There is a diff image in here

Slide 109

Slide 109 text

Image with colors and levels manipulated

Slide 110

Slide 110 text

No content

Slide 111

Slide 111 text

No content

Slide 112

Slide 112 text

/// 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() )