Introduction
In this post, I explore the problem of building an architecture for small-scale projects. My main goal is to build a super simple architecture that is easy to follow and adapt. It should work out of the box for unit testing, UI testing, and previews. Furthermore, in situations with unresolvable limitations, it should be easy to refactor.
Architecture Goals
- Preview Support: Be able to preview each view and switch between mock models and/or mock data.
- UI Testing: Be able to modify app behavior during UI Testing.
- Unit Testing: Be able to run unit tests on ViewModels with custom mocked models, services, and data providers.
- Environment Awareness: Change behaviors depending on whether the application is running on a real device or an emulator.
Architecture
Global Shared Data Providers and Services
This is likely the only controversial part of this architecture. I plan to access data providers and services directly from a global shared object. In a standard enterprise environment, this might be considered a “no-no” because it isn’t as abstract or “clean enough” as traditional dependency injection.
However, since this architecture is meant for small applications, I value a simpler lifecycle (without a heavy setup step) and less boilerplate code for my views and models over an extra level of abstraction.
@Observable
@MainActor
class AppEnvironment {
private static var _shared: AppEnvironment?
static var shared: AppEnvironment { _shared! }
private(set) var dataProviders: any IAppDataProviders
private(set) var services: any IAppServices
init(dataProviders: any IAppDataProviders, services: any IAppServices) {
self.dataProviders = dataProviders
self.services = services
}
static func initialize(_ appEnv: AppEnvironment) {
_shared = appEnv
}
static func teardown() {
_shared = nil
}
}
protocol IAppDataProviders {
var accelerometerDataProvider: AnyPublisherAccelerometerDataProvider { get }
}
class AppDataProviders: IAppDataProviders {
init(accelerometerDataProvider: AnyPublisherAccelerometerDataProvider) {
self.accelerometerDataProvider = accelerometerDataProvider
}
var accelerometerDataProvider: AnyPublisherAccelerometerDataProvider
}
protocol IAppServices {}
class AppServices: IAppServices {}
AppEnvironment.shared is available and used globally; it is also added to the app’s environment context for SwiftUI views.
App Initialization
Before initializing Data Providers and Services, I want to know exactly which mode the app is operating in. Here are some useful snippets for detection:
Simulator and Debug Detection
#if targetEnvironment(simulator)
let isSimulator = true
#else
let isSimulator = false
#endif
#if DEBUG
let isDebug = true
#else
let isDebug = false
#endif
UI Testing Detection
// App is started using this helper
@MainActor
func getConfiguredXCUIApplicationInstance() -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments.append("--uitesting")
return app
}
// Detection logic
let isUITesting = CommandLine.arguments.contains("--uitesting")
SwiftUI Preview Detection
extension ProcessInfo {
static var isPreview: Bool {
processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
}
}
let isPreview = ProcessInfo.isPreview
Application Run Mode
enum AppRunMode {
case production
case debug
case uiTesting
case unitTesting
case preview
}
let appRunMode: AppRunMode = isUITesting ? .uiTesting : isPreview ? .preview : isDebug ? .debug : .production
Now, this data can be used to initialize the correct versions of Data Providers and Services.
App Entry Point:
func initializeApp(mode: AppRunMode, isSimulation: Bool, postInit: (() -> Void)? = nil) {
// ... logic to setup AppEnvironment.shared based on mode
}
@main
struct TestableViewsApp: App {
@State var appEnvironment: AppEnvironment
init() {
initializeApp(mode: appRunMode, isSimulation: isSimulator)
appEnvironment = AppEnvironment.shared
}
var body: some Scene {
WindowGroup {
NavigationStack {
RootView()
}
.environment(appEnvironment)
}
}
}
Preview Container:
struct PreviewConatiner<Content: View>: View {
@State var appEnvironment: AppEnvironment
private let content: Content
init(@ViewBuilder content: () -> Content, postInit: (() -> Void)? = nil) {
initializeApp(mode: .preview, isSimulation: isSimulator, postInit: postInit)
self.appEnvironment = AppEnvironment.shared
self.content = content()
}
var body: some View {
NavigationStack {
content
}
.environment(appEnvironment)
}
}
Views and ViewModels
Key Principles:
- ViewModels are used only behind a protocol abstraction layer, never as a concrete type.
- Each View and ViewModel has at least two initializers: a production version with default values, and a fully configurable version for injecting mocks.
Simple View
A “Simple View” depends only on globally available data sources and does not need to pass data further down the hierarchy.
View:
struct TestView: View {
@State var model: any ITestViewModel
init(model: any ITestViewModel) {
self.model = model
}
init () {
self.model = TestViewModel()
}
// ...
}
Model:
protocol ITestViewModel: Observable, AnyObject {
var accelerometerData: AccelerometerData? {get}
var error: Error? {get}
func onButtonTap_doSomeStuff()
func onAppear()
func onDisappear()
}
@Observable
class TestViewModel: ITestViewModel {
init(dataSource: AnyPublisherAccelerometerDataProvider) {
self.dataSource = dataSource
}
init() {
self.dataSource = AppEnvironment.shared.dataProviders.accelerometerDataProvider
}
}
Previews:
#Preview("Env") {
PreviewConatiner {
TestView()
}
}
#Preview("Prod") {
// It will fail, no accelerometer error occurs unless the preview is performed on a real device
PreviewConatiner {
TestView(model: TestViewModel(dataSource: AccelerometerDataProvider.shared.eraseToAnyPublisher()))
}
}
#Preview("Normal Model, Mock Data") {
PreviewConatiner {
TestView(
model: TestViewModel(dataSource: AccelerometerDataProviderMock().eraseToAnyPublisher())
)
}
}
#Preview("Mock Model & Data") {
PreviewConatiner {
TestView(
model: TestViewModelMock(dataSource: AccelerometerDataProviderMock().eraseToAnyPublisher())
)
}
}
#Preview("Normal Model, No accelerometer") {
PreviewConatiner {
TestView(
model: TestViewModel(dataSource: AccelerometerDataProviderMockNoAccelerometer().eraseToAnyPublisher())
)
}
}
Complex View
Complex Views use shared ViewModels that must be accessible from subviews. I decided to accomplish this via property drilling. While not always ideal, I preferred it over creating custom EnvironmentKeys for every piece of contextual data.
Root View:
struct TestComplexView: View {
@State var model: ITestComplexViewModel
init(model: ITestComplexViewModel) {
self.model = model
}
init () {
self.model = TestComplexViewModel()
}
var body: some View {
VStack {
TestComplexValueSubview(model: model)
TestComplexEditSubview(model: model)
}
}
}
Root View Preview:
#Preview("Default") {
PreviewConatiner {
TestComplexView()
}
}
#Preview("Mock") {
PreviewConatiner {
TestComplexView(model: TestComplexViewModelMock())
}
}
Inner View / Subview:
struct TestComplexViewValueSubview: View {
@State var model: ITestComplexViewModel
init(model: ITestComplexViewModel) {
self.model = model
}
var body: some View {
Text(model.someValue)
.font(.title)
}
}
#Preview {
PreviewConatiner {
TestComplexViewValueSubview(model: TestComplexViewModelMock())
}
}
Unit Testing
This ViewModel structure allows us to easily test behaviors and lifecycles using mock data. Note that by default, the Swift Testing framework runs tests in parallel. If tests rely on the global AppEnvironment.shared, you may encounter state pollution. Such tests should be run serially.
Here is an example of a serialized test suite that uses the global static app context (one of the tests):
@MainActor
func initTestEnvironment(postInit: (() -> Void)? = nil) {
initializeApp(mode: .unitTesting, isSimulation: isSimulator, postInit: postInit)
}
@MainActor
func teardownTestEnvironment() {
AppEnvironment.teardown()
}
@MainActor
@Suite(.serialized)
class UnitTests {
init() {
initTestEnvironment()
}
@MainActor deinit {
teardownTestEnvironment()
}
@Test func сomplexViewModel() async throws {
let model = TestComplexViewModel()
model.setSomeValue("Hello World!")
#expect(model.someValue == "Hello World!")
}
@Test func viewModelWithMockData() async throws {
let model = TestViewModel(dataSource: AccelerometerDataProviderMock().eraseToAnyPublisher())
model.onAppear()
defer {
model.onDisappear()
}
try? await Task.sleep(for: .milliseconds(50))
#expect(model.accelerometerData != nil)
}
@Test func viewModelNoAccelerometerError() async throws {
let model = TestViewModel(dataSource: AccelerometerDataProviderMockNoAccelerometer().eraseToAnyPublisher())
model.onAppear()
defer {
model.onDisappear()
}
try? await Task.sleep(for: .milliseconds(50))
#expect(model.error != nil)
if case AccelerometerDataProviderError.accelerometerNotAvailable = model.error! { } else {
#expect(Bool(false), "Wrong error type")
}
}
@Test func accelerometerDataProviderMockFromAppEnv() async throws {
let accelerometer = AppEnvironment.shared.dataProviders.accelerometerDataProvider
var accelerometerData: AccelerometerData?
let subscription = accelerometer.sink(receiveCompletion: {completion in}, receiveValue: {accelerometerData = $0})
defer {
subscription.cancel()
}
try? await Task.sleep(for: .milliseconds(50))
#expect(accelerometerData != nil)
}
}
UI Testing
I am not a huge fan of UI testing for small projects; it can feel like a waste of time unless you are testing critical flows like payments or authorization. However, on large projects with many contributors, UI tests are a welcome layer of integration testing. Here is how you can use the detection logic to modify behavior:
// Somewhere in the Root View
if isUITesting {
Button("Custom UITesting Button") {
print("Hello UITesting")
}
}
// UI Test
final class SomeUITest: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
override func tearDownWithError() throws {}
@MainActor
func testExample() throws {
let app = getConfiguredXCUIApplicationInstance()
app.launch()
app.activate()
let element = app.buttons["Custom UITesting Button"].firstMatch
element.tap()
}
}
Conclusion
Is this architecture perfect? Absolutely not. But it is “good enough” in the best sense of the phrase. Every architectural decision involves trade-offs. I deliberately chose a super-simple approach that is easy to maintain, provides a predictable lifecycle, and offers straightforward options for previews, testing, and debugging.
The full source code is available here: https://github.com/AndreiMaksimovich/SwiftDeveloperBlog/tree/main/03%20-%20Testable%20SwiftUI%20Views%20and%20ViewModels