Writing SnapshotTests on iOS in KMM Project
In our case, we needed to make the UI tests as most of the logic was common, tested outside our iOS world. However, in our opinion, writing them for SwiftUI is not that great. That’s why we decided to use snapshot tests, which greatly improved our test coverage and helped to ensure that our application’s UI doesn’t change when the codebase is updated.
What are snapshot tests?
Snapshot tests are a type of test, thanks to which we are confident that our UI won’t change unexpectedly after modifying the code. It tests whether the visual components match with a snapshot of the user interface.
By “snapshot,” we mean a reference image that has been recorded earlier. When the tested view matches the reference, the test passes – otherwise, the test fails and we know that our user interface has unexpectedly changed.
What did we use?
We used the SnapshotTesting framework, which you can find in this GitHub repository: Delightful Swift snapshot testing
It’s a great framework for snapshot testing, thanks to which we can test our ViewControllers. But what about the SwiftUI views? It’s the most important question because our app is built on SwiftUI.
Well, we also can check them, but we need to wrap them into the ViewController, for example, using the UIHostingController(rootView:)
. In our case, we used a custom ViewController
class, which inherits from UIHostingController
, so when we refer to ViewController
, remember that you can also use the UIHostingController.
We also have a method in the UIViewController
extension, thanks to which we can perform the snapshot tests:
extension UIViewController {
func performSnapshotTests(
named name: String,
precision: Float = 0.995,
file: StaticString = #file,
line: UInt = #line
) {
assertSnapshot(
matching: self,
as: .image(on: .iPhoneX, precision: precision),
named: name,
file: file,
testName: "Pismo",
line: line
)
}
}
This method asserts that a given value matches a reference on the disk, and we will call this method at the end of every test case to check this condition.
1st case – our own ViewModels
In KMM, our ViewModels were collecting the data and contained the methods for handling some states and interactions. They were created natively and referred to the common services and queries.
We could also modify their state by assigning values to some properties. For example, we could mock up a list of articles and assign them to the articles
property, which is of type [Article]
(an array of Article objects).
Foremost, we need to import three things – the SnapshotTesting, XCTest,
and our app marked as @testable
, so it looks like this:
import SnapshotTesting
import XCTest
@testable import iosApp
Awesome, let’s check the implementation of the snapshot tests used to test our Splash screen.
final class SplashViewControllerTests: XCTestCase {
var viewModel: SplashViewModel!
var sut: UIViewController!
@MainActor override func setUp() {
super.setUp()
isRecording = false
let viewModel = SplashViewModel()
self.viewModel = viewModel
let view = SplashView(viewModel: viewModel)
sut = ViewController(view: view)
}
override func tearDown() {
sut = nil
viewModel = nil
super.tearDown()
}
// MARK: - Default Behavior
func testSnapshot_LightMode() {
sut.performSnapshotTests(named: "SplashView-LightMode")
}
func testSnapshot_DarkMode() {
sut.overrideUserInterfaceStyle = .dark
sut.performSnapshotTests(named: "SplashView-DarkMode")
}
// MARK: - Error Handling
@MainActor func testSnapshot_LightMode_Error_Occurred() {
viewModel.error = MockHelper.Defaults.networkError
sut.performSnapshotTests(named: "SplashView-LightMode-Error-Occurred")
}
@MainActor func testSnapshot_DarkMode_Error_Occurred() {
sut.overrideUserInterfaceStyle = .dark
viewModel.error = MockHelper.Defaults.networkError
sut.performSnapshotTests(named: "SplashView-DarkMode-Error-Occurred")
}
}
As you can see, our logic is simple. We have to mark a few things there:
- Everything that uses/modifies the
SplashViewModel
needs to be marked as@MainActor
because the ViewModel is anObservableObject
, so it should be marked as MainActor. - The setUp() method contains a reference to the
isRecording
flag. This flag indicates whether we should record all the new references. Basically, when we want to override all the previous references that don’t match to the current state of the view, we need to mark theisRecording
flag as true and run the tests. The tests will fail with an error that the recording has been turned on, and we must turn it back to false to check the new references. - When we don’t have a reference, the tests will fail with this
error: 🛑 failed - No reference was found on disk. Automatically recorded snapshot: ....
It means that we don’t need to switch the isRecording value when we don’t have the reference snapshots. - We assign the sut (system under tests) and view model to the properties, which are cleared in the
tearDown()
method. - We have test cases for each behavior. This behavior is simple and contains only four cases. We have two cases for default behavior and two for behavior when the error occurs; these two groups have one case for light and dark modes.
- We need to remember to record the tests on proper devices. We set it to iPhone X, but we performed the tests on an iPhone 14 (which is fine) – so just remember to always check the snapshots on the same device that they were recorded.
- When we would like to set the dark mode in our snapshot, we need to call
sut.overrideUserInterfaceStyle = .dark
in our test cases. - We can modify the ViewModel properties to get the different behavior of the snapshot. So, the default behavior will show how the app behaves in a normal environment, we can assign the loading value which will show the loading view and error, so the error view is visible. Only the sky is the limit.
- We can check the snapshots by opening the open in Finder, in our case:
iosApp/iosAppTests/__Snapshots__/SplashViewControllerTests/Pismo.testSnapshot_LightMode.png.
That’s everything – it’s the same as testing the native iOS app. We can make the ViewModels stubs/mocks, mock the data, assign the data, and everything works the same. In KMM, all we need to do is import the common module when it’s needed, e.g., for the type reference (like defining the method’s result type, which is common).
2nd case – common ViewModels
During development of the project, it turned out that we had to rework the architecture, thanks to which we would have common ViewModels. For that reason, our snapshot tests needed to be changed. So, it turned out that we can’t change the ViewModels' properties.
Thus, the biggest question was: can we actually modify anything and trigger a condition that matches the snapshots? After a few days, we came to a conclusion: yes, we can! It turned out that we cannot change a value marked as @State outside the view. It was the biggest issue, as our view was using the ViewModel’s state, which we couldn’t modify because it contained only the get-only properties.
But it turned out that we can make a custom initializer for testing purposes and assign the State object with an initial value assigned – and this was our ViewModel’s modified state.
extension ArticleDetailsView {
/// An initializer created for performing UI Tests. Without it we cannot test the app.
/// - Parameter state: a state containing all information about an article's details.
init(state: ArticleDetailsUiState) {
viewModel = Provider.shared.articleDetailsViewModel(articleId: -1)
_state = State(initialValue: state)
}
}
As you can see, we inject the state, and we assign a ViewModel, which is a placeholder because it won’t be used in the tests (but our view requires it). Our state contains the data, which will be used in the view – I will present to you how it works in the ArticleDetailsScreen.
When our ArticleDetailsView appears, the state is collected and assigned to the property. Then, we check whether any error occurred and if so, we show the error view.
struct ArticleDetailsView: View {
let viewModel: ArticleDetailsViewModel
@State private var state: ArticleDetailsUiState = ArticleDetailsUiState.companion.default_
var body: some View {
Group {
if let error = state.error {
createFullScreenError(error)
} else {
createArticleDetailsScreen()
}
}
.task(priority: .high, collectState)
}
@Sendable private func collectState() async {
do {
let stream = asyncStream(for: viewModel.uiStateNative)
for try await state in stream {
self.state = state
}
} catch {
logPrint("[Article Details] Failed to collect UI state")
}
}
}
But the case above is untestable, as we can’t assign a value to the state from outside. So, we decided to inject the default state like in the test initializer.
struct ArticleDetailsView: View {
let viewModel: ArticleDetailsViewModel
@State private var state: ArticleDetailsUiState
init(viewModel: ArticleDetailsViewModel) {
self.viewModel = viewModel
_state = State(initialValue: ArticleDetailsUiState.companion.default_)
}
var body: some View {
// ...
}
// ...
}
Now we can inject our custom state, which contains the data based on which we will show the content!
Awesome, so we still needed to create our tests. We added the ArticleDetailsViewControllerTests
, in which we test everything related to the ArticleDetailsScreen.
import core
import SnapshotTesting
import XCTest
@testable import iosApp
final class ArticleDetailsViewControllerTests: XCTestCase {
var sut: UIViewController!
override func setUp() {
super.setUp()
isRecording = false
}
override func tearDown() {
sut = nil
super.tearDown()
}
// MARK: - Default Behavior
// ... Some cases ...
@MainActor func testSnapshot_DarkMode_Article_Loading() {
let player = mockChipPlayerUIState()
let sections = mockSections()
let state = mockArticleDetailsUiState(sections: sections, player: player, isLoading: true)
initializeNewViewController(state: state)
sut.overrideUserInterfaceStyle = .dark
sut.performSnapshotTests(named: "ArticleDetails-DarkMode-Article-Loading")
}
// ... Some cases ...
// MARK: - Error Handling
@MainActor func testSnapshot_LightMode_Error_Occurred() {
let player = mockChipPlayerUIState()
let state = mockArticleDetailsUiState(player: player, error: MockHelper.Defaults.networkError)
initializeNewViewController(state: state)
sut.performSnapshotTests(named: "ArticleDetails-LightMode-Error-Occurred")
}
// ... Some cases ...
// MARK: - Helpers
private func initializeNewViewController(state: ArticleDetailsUiState) {
let view = ArticleDetailsView(state: state)
sut = ViewController(view: view)
}
private func mockArticleDetailsUiState(
sections: [ArticleSection] = [ArticleSection.TitleSection(title: "TEST TITLE")],
player: ChipPlayerUiState,
articleLink: String = MockHelper.Defaults.articleLink,
isLoading: Bool = false,
error: KotlinThrowable? = nil
) -> ArticleDetailsUiState {
ArticleDetailsUiState(
sections: sections,
player: player,
articleLink: articleLink,
isLoading: isLoading,
error: error
)
}
// ... Some helper methods ...
}
As you can see, it differs a bit. Foremost, our setUp()
method contains only a reference to the isRecording
property. Secondly, we only have the sut property, without the ViewModel, as we assign a ViewController containing the state in each test case, by calling the initializeNewViewController(state:)
method. Remember to use it before using the methods and properties from the sut, like applying the dark mode to the snapshot. Otherwise, your tests will fail due to crashing.
Summary
As you can see, snapshot testing is easy – both in native and KMM apps. They are similar, but differ a bit. The most significant changes are in the Views' and ViewModels' implementation; however, testing the native app works by changing some properties and calling some methods.
In a KMM app, we mock some states that are injected into the view in every test case. Nevertheless, snapshot testing works great with KMM and thanks to it, we can be sure that our view won’t change after codebase modification.