iOS Dev Must-Have: Snapshot Testing

Asilbek Djamaldinov
11 min readOct 22, 2023

--

Here are my other articles of the Testing Flow in iOS :

  1. Testing in iOS: From Zero to Hero!
  2. Unlocking the Power of UI Testing in iOS Development
  3. Why Mocking Matters in iOS Unit Testing
  4. iOS Dev Must-Have: Snapshot Testing (Current)
  5. Why to Unit Test in Async Mode

Why is snapshot testing important? What does it bring to the table that other testing methods may not?

To answer this question, imagine you’re an architect designing a grand skyscraper. Every blueprint, every angle, and every material choice must align perfectly to create a visually stunning and structurally sound building. The same holds true for an iOS app — except you’re not just an architect; you’re the engineer, designer, and quality control all rolled into one.

Snapshot testing in iOS acts like a watchful guardian, ensuring that the design and user interface of your app remain as captivating and error-free as your initial vision.

In this article, we’ll explore why snapshot testing is crucial for iOS app development. We’ll see how it maintains your app’s visual integrity, minimizes the chances of visual issues cropping up, and why it’s essential for delivering a smooth and polished user experience. Let’s uncover the reasons why snapshot testing is a must for your iOS app development journey.

What is Snapshot testing?

Snapshot testing is a type of automated testing used in software development, including iOS app development. It focuses on ensuring that the visual appearance and consistency of the user interface (UI) in your app remain unchanged.

Here’s how snapshot testing works: When you create a snapshot test, it takes an initial “snapshot” of a specific UI component or screen in your app. This snapshot is like a picture or a detailed representation of how the UI looks at that moment.

When you run a snapshot test, it compares the current state of the UI component to the stored snapshot. If they match exactly, the test passes. But if there are any differences, the test fails.

If the test fails because you intentionally made changes to the UI, you can choose to update the stored snapshot to match the new UI appearance. This process is controlled and deliberate, ensuring you review and approve any changes as part of the testing.

Snapshot testing is particularly useful for making sure that UI elements in your app maintain a consistent look. It’s valuable for catching any unintended visual changes that might occur as you work on your app’s code or modify its UI components.

A Step-by-Step Guide to Setting Up Snapshot Testing in iOS

No more chit-chat! It’s time to dig into the code and have an enjoyable time while working with snapshot testing. 😄

App Inspection: Let’s Take a Look

In our app, we’ve got two screens. The first screen is where you find a list of jokes related to programming and testing. You can even hit a button to mix up the jokes and get a fresh set. The second screen is all about the jokes themselves — just a single joke text right in the middle. It’s that simple!

In our app, we’ve got a view model DeepOceanViewModel with just one job. It talks to something called a networking service NetworkService. We'll talk more about this networking service later in the article, when we discuss mocking in snapshot testing. (If you're unfamiliar with what mocking is and how to put it into practice, I suggest reading my earlier article on the topic).

class DeepOceanViewModel: ObservableObject {
@Published var list: [String] = []

private let networkService: AnyNetworkService

init(networkService: AnyNetworkService = NetworkService()) {
self.networkService = networkService

getList()
}

func getList() {
list = networkService.fetchList()
}
}

class NetworkService: AnyNetworkService {
// Imagine here that we are making a real networking call
func fetchList() -> [String] {
let list = [
"When your code refuses to work, just blame it on cosmic rays!",
"Debugging is like being the detective in a crime movie where you're also the murderer.",
"Real programmers count from 0. Or at least until they hit 1.",
"Why do programmers always mix up Christmas and Halloween? Because Oct 31 == Dec 25.",
"Why do programmers prefer iOS development? Because it's the only place where an 'apple' a day keeps the bugs away!",
"The best code is the one you don't have to write. Also, it's the one you don't have to test!",
"Why did the programmer go broke? Because he used up all his cache!",
"I asked the computer for a joke, and it replied, 'Why don't programmers like nature? It has too many bugs.'",
"Programming is 10% writing code and 90% figuring out why that code doesn't work.",
"You know you're a programmer when you spend more time Googling error messages than writing code."
]

return list
}
}

The plan is as follows:

  1. Add a snapshot testing library and directory into the project.
  2. Create a snapshot test for the main screen.
  3. Generate a new snapshot with an updated view.
  4. Compose a test for the subview.
  5. Mocking the network to run a test without causing changes.

Step 1: Snapshot Tool Integration.

To begin, we should create a new target for snapshot testing. While you can use the UI test target, I prefer having distinct targets for unit tests, UI tests (XCTests), and UI Snapshot tests. You can do this by going to your project, clicking the ‘+’ button, selecting “unit testing bundle”.

Name it “YourProjectSnapshotTests.” If you’re unsure how to add a new target, you can refer to this for guidance.

Your list of targets should now appear like this:

Next, we’ll include the Swift Snapshot Tests package in our project. I’ll be using Swift Package Manager (SPM) to handle dependencies, but you can also use a POD file if you prefer. The critical step is to integrate this library and specify the target for YourProjectSnapshotTests.

If you skip this part, your project won’t work, and you’ll encounter compile-time errors. You can find detailed instructions on adding dependencies to the specific target in this article.

My project’s navigation panel appears like this, and yours should match it:

Step 2: Main Screen Snapshot

In the “MyOceanSnapshotTests” Swift file, remove all the existing code, and replace it with the following code:

import XCTest
// 1
import SnapshotTesting
@testable import MyOceanTests

final class MyOceanSnapshotTests: XCTestCase {
func test_DeepOceanView_didNotChange() {
// 2
// given
let viewModel = DeepOceanViewModel()
let view = DeepOceanView(viewModel: viewModel)

// 3
// then
assertSnapshot(of: view, as: .image)
}
}

If you’ve read my previous article on unit tests, you’ll notice that snapshot tests are quite similar to unit tests. In fact, the code for snapshot tests closely resembles that of unit tests. It’s a fantastic, isn’t it?

Let’s take a closer look at what’s happening here:

  1. SnapshotTesting is the library we added in the first step by importing it into our package. We import it here so that we can use it in our tests.
  2. In this section, we create instances of the DeepOceanViewModel and DeepOceanView. The view is the actual user interface component that you want to capture in the snapshot test.
  3. This part of the code executes the snapshot test. It uses the assertSnapshot function to capture a snapshot of the view. The format in which you want to capture the snapshot is specified as .image. The assertSnapshot function compares the current state of the view to a previously stored snapshot and determines whether they match. If they match, the test passes; if there are differences, it fails, indicating a visual regression that needs to be addressed.

You might wonder about the “previously stored snapshot.” In reality, there isn’t one, which is why if we run our test now, it will fail. However, the good news is that when we run the test for the first time, it will fail but create a snapshot. Then, when we run it for the second time, we’ll have a snapshot to compare with the current state of the view.

Now it’s time to give it a shot. Run the test, and you’ll see it fail. But run it again, and this time, it will succeed (I hope so 😁).

Once everything runs successfully, you can navigate to your project directory at “…/YourProject/YourProjectSnapshotTests/Snapshots/…” to find your very first snapshot. Hooray! Congratulations on creating your first snapshot test.

And now, if you decide to make changes to your main screen, your test will compare the updated screen with the snapshot you have. It will fail because they won’t be the same. This is a good thing because it prevents unintentional changes. But you might be wondering, “How do we make intentional changes from now on?” Well, the next step is an exciting one, and it will answer that question for you!

Step 3: Snapshot Refresh Process

Now that we’ve learned how to create a snapshot and what it looks like, let’s make a change to our view. We’ll switch the colour of button from blue to red and run the test again. What do you expect the result will be? Naturally, it will fail. So, let’s add the additional line to our test case:

func test_DeepOceanView_didNotChange() {
// given
let viewModel = DeepOceanViewModel()
let view = DeepOceanView(viewModel: viewModel)

isRecording = true

// then
assertSnapshot(of: view, as: .image)
}

Setting isRecording to true is like saying, “Hey, we’re here to capture a new snapshot, and it’s okay if the current test fails because we’re creating a fresh state.” That’s exactly what this line of code does. Ha-ha! 😄

After you update the snapshot, be sure to remove the isRecording line. And don't worry if you forget; it'll kindly remind you. That's why it's so user-friendly!

You can also set isRecording to true within an assertion, like this:

assertSnapshot(of: view, as: .image, record: true)

But it accomplishes the same task as setting isRecording to true. So, go ahead and pick the one you like best.

Step 4: Subview Snapshot

Likewise, we can generate a snapshot for a subview in a similar manner. Under your initial test, include the following code:

func test_JokeView_didNotChange() {
let view = JokeView(joke: "Hello friend!")

assertSnapshot(of: view, as: .image)
}

Run the test, and it will fail. Run it again, and it will succeed. I wish all my issues were resolved this easily! 😄. Here what we have:

I won’t explain how to write a snapshot test for the second screen because it’s quite similar. However, I want to emphasise that the organisation of your tests depends on your project’s structure. Personally, I like to create separate files for each screen. For instance, if I have three different screens, I’ll have three separate test Swift files. But you’re free to choose the approach that suits you best.

Now, let’s dive into mocking — it’s an exciting topic!

Step 5: Simulating Network Behaviour

Let’s take a moment to discuss our list of jokes. Right now, our test works perfectly because it’s based on the same set of jokes we have in our list. However, imagine if the jokes on the server (backend) change, and we receive a different list. That would create a problem because our snapshot contains different jokes compared to the ones from the network.

The solution is to mock our network interactions. By doing this, we ensure that we always get the same list of jokes returned, making our code rely on something concrete and not subject to unpredictable changes.

If you’re not sure about what mocking is, I suggest you read my previous article on the topic. It will provide a helpful explanation.

We’ll employ a technique called polymorphism to simulate our networking layer using a component named “MockNetworkingService.” This component will be configured to return specific static text as a response. And after, we can recreate the snapshot for the list of jokes on the main view.

Create a “MockNetworkingService” under the “MyOceanSnapshotTests” target within the “MockServices” directory. Then, add the provided code below:

import Foundation
@testable import MyOceanTests

class MockNetworkService: AnyNetworkService {
func fetchList() -> [String] {
return [
"Why did the iOS developer go broke? Because they had too many connections, but none of them were personal!",
"What do you call a group of iOS developers working on a networking project? The Inter-NETworking Team!",
"Why did the Wi-Fi network apply for a job? Because it wanted to make better connections!"
]
}
}

What this code does is simply provide you with three jokes without going through the actual network. It’s a straightforward process.

Replace the viewModel in your first test case with the code provided below:

func test_DeepOceanView_isSame() {
let viewModel = DeepOceanViewModel(networkService: MockNetworkService())

...
}

You’ve essentially created a new instance of your view model, but this time, it’s configured to work with the mock networking service. Create a new snapshot with isRecording.

Whew, we’ve come a long way, and you’ve done an excellent job! But before we conclude, there’s one important point to remember.

When testing snapshots, always stick with one simulator. You can’t use an iPhone 15 Pro today and switch to an iPhone 15 Pro Max tomorrow. Changing simulators will detect differences, and all your tests will fail. It might require a bit of coordination when working with a larger team, but it’s worth discussing which simulator to use for successful snapshot tests.

You could, of course, add your snapshots to gitignore, but I prefer not to do this. When I create a view, I know exactly how it should look, and I want to save my team the time of verifying if their snapshots are correct. So, with that, we can wrap things up! 😄

Conclusion

To sum it up with a smile, snapshot testing is like having a trusty guardian for your iOS app’s looks. It ensures that your app always dazzles with the same eye-catching design, sparing you from unexpected visual hiccups. By following a well-organised process, you can boldly tackle UI updates, simplify your testing routine, and serve up iOS apps that are not just glitch-free but also visually delightful.

Find it a good read?

Recommend this post by clicking the 👏 button so other people can see it and let’s connect on LinkedIn asilbekdjamaldinov

--

--