I am trying to write unit tests for SwiftUI views but finding zero resources on the web for how to go about that.
I have a view like the following
struct Page: View {
#EnvironmentObject var service: Service
var body: some View {
NavigationView {
ScrollView(.vertical) {
VStack {
Text("Some text"))
.font(.body)
.navigationBarTitle(Text("Title")))
Spacer(minLength: 100)
}
}
}
}
}
I started writing a test like this
func testPage() {
let page = Page().environmentObject(Service())
let body = page.body
XCTAssertNotNil(body, "Did not find body")
}
But then how do I get the views inside the body? How do I test their properties? Any help is appreciated.
Update:
As a matter of fact even this doesn't work. I am getting the following runtime exception
Thread 1: Fatal error: body() should not be called on ModifiedContent<Page,_EnvironmentKeyWritingModifier<Optional<Service>>>.
There is a framework created specifically for the purpose of runtime inspection and unit testing of SwiftUI views: ViewInspector
You can extract your custom views to verify the inner state, trigger UI-input side effects, read the formatted text values, assure the right text styling is applied, and much more:
// Side effect from tapping on a button
try sut.inspect().find(button: "Close").tap()
let viewModel = try sut.inspect().view(CustomView.self).actualView().viewModel
XCTAssertFalse(viewModel.isDialogPresented)
// Testing localization + formatting
let sut = Text("Completed by \(72.51, specifier: "%.1f")%")
let string = try sut.inspect().text().string(locale: Locale(identifier: "es"))
XCTAssertEqual(string, "Completado por 72,5%")
The test for your view could look like this:
func testPage() throws {
let page = Page().environmentObject(Service())
let string = try page.inspect().navigationView().scrollView().vStack().text(0).string()
XCTAssertEqual(string, "Some text")
}
Update: Let's all try using the ViewInspector library by nalexn!
Original reply:
Until Apple
a) designs testability into SwiftUI, and
b) exposes this testability to us,
we're screwed, and will have to use UI Testing in place of unit testing… in a complete inversion of the Testing Pyramid.
Related
I have a modal presented Sheet which should display based on a UserDefault bool.
I wrote a UI-Test which dismisses the sheet and I use launch arguments to control the defaults value.
However, when I tried using #AppStorage initially it didn't seem to persist the value, or was secretly not writing it? My test failed as after 'dismissing' the modal pops back up as the value is unchanged.
To work around this I wrote a custom binding. But i'm not sure what the behaviour difference is between the two implementations? The test passes this way.
Does anyone know what i'm not understanding sorry?
Cheers!
Simple Example
1. AppStorage
struct ContentView: View {
#AppStorage("should.show.sheet") private var binding: Bool = true
var body: some View {
Text("Content View")
.sheet(isPresented: $binding) {
Text("Sheet")
}
}
}
2. Custom Binding:
struct ContentView: View {
var body: some View {
let binding = Binding<Bool> {
UserDefaults.standard.bool(forKey: "should.show.sheet")
} set: {
UserDefaults.standard.set($0, forKey: "should.show.sheet")
}
Text("Content View")
.sheet(isPresented: binding) {
Text("Sheet")
}
}
}
Test Case:
final class SheetUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
func testDismiss() {
// Given 'true' userdefault value to show sheet on launch
let app = XCUIApplication()
app.launchArguments += ["-should.show.sheet", "<true/>"]
app.launch()
// When the user dismisses the modal view
app.swipeDown()
// Wait a second for animation (make sure it doesn't pop back)
sleep(1)
// Then the sheet should not be displayed
XCTAssertFalse(app.staticTexts["Sheet"].exists)
}
}
It does not work even when running app, because of that "." in key name (looks like this is AppStorage limitation, so use simple notation, like isSheet.
IMO the test-case is not correct, because it overrides defaults by arguments domain, but it is read-only, so writing is tried into persistent domain, but there might be same value there, so no change (read didSet) event is generated, so there no update in UI.
To test this it should be paired events inside app, ie. one gives AppStorage value true, other one gives false
*Note: boolean value is presented in arguments as 0/1 (not XML), lie -isSheet 1
I want to have a modal view whenever the avatar is pressed and selected. But when using the binding var, there is no way to know whether it is an empty string..
Codes below:
struct SelectAvatarView: View {
var role: String
#State var selectedAvatar: String?
var body: some View {
NavigationView{
ZStack {
BackgroundView()
VStack {
TitleTextView(title: "Choose your avatar:")
if role == "Parent" {
ParentAvatarView(selectedAvatar: $selectedAvatar)
}
else{
ChildAvatarView(selectedAvatar: $selectedAvatar)
}
Spacer()
}.padding()
.sheet(isPresented: !(self.$selectedAvatar.isEmpty) ) { SimpleTestView()}
}
}
}
}
Problem is I don't know how to check the binding var $selectedAvatar. no matter what I wrote, errors are:
Cannot convert value of type 'Binding<Bool>' to expected argument type 'Bool'
Help!!! and Thanks!!
sorry to all.
I was being careless.
But I was hurt by the new "arrogance" of the stackOverflow now. One asks question in Stack as last resort, because last thing he/she wants is to shame himself/herself in front of all the other professional and awesome developers with the stupidity - It ain't fun. But when stuck with project, we had to throw face/dignity outside the window. Only because missing details in the question description, (when it is absolutely irrelevant to the problem), I don't think it deserves a "thumb down", let alone a few.
In the long run, this kind of arrogance will hurt the very purpose of having stackOverflow in the first place.
isPresented only takes Binding<Bool>, that's why, providing a Bool won't work.
So I found a workaround by using
It is working fine now:
//change avatar to struct Avatar (identifiable)
#State var selectedAvatar: Avatar?
//change to "item"
.sheet(item: self.$selectedAvatar ){ avatar in AvatarSummaryView(avatar: avatar)}
I don't know how your view architecture.
But here you can fix your compile error by bellow code.
.sheet(isPresented: .constant(!(self.selectedAvatar?.isEmpty ?? false)) ) { SimpleTestView()}
I'm working on an app that needs to open on the users last used view even if the app is completly killed by the user or ios.
As a result I'm holding last view used in UserDefaults and automatically moving the user through each view in the stack until they reach their destination.
The code on each view is as follows:
#Binding var redirectionID: Int
VStack() {
List {
NavigationLink(destination: testView(data: data, moc: moc), tag: data.id, selection:
$redirectionId) {
DataRow(data: data)
}
}
}.onAppear() {
redirectionID = userData.lastActiveView
}
Is there a better / standard way to achieve this? This works reasonably on iOS 14.* but doesn't work very well on iOS 13.* On iOS 13.* The redirection regularly doesnt reach its destination page and non of the preceeding views in the stack seem to be created. Pressing back etc results in a crash.
Any help / advice would be greatly appreciated.
This sounds like the perfect use of if SceneStorage
"You use SceneStorage when you need automatic state restoration of the value. SceneStorage works very similar to State, except its initial value is restored by the system if it was previously saved, and the value is· shared with other SceneStorage variables in the same scene."
#SceneStorage("ContentView.selectedProduct") private var selectedProduct: String?
#SceneStorage("DetailView.selectedTab") private var selectedTab = Tabs.detail
It is only available in iOS 14+ though so something manual would have to be implemented. Maybe something in CoreData. An object that would have variables for each important state variable. It would work like an ObservedObject ViewModel with persistence.
Also. you can try...
"An NSUserActivity object captures the app’s state at the current moment in time. For example, include information about the data the app is currently displaying. The system saves the provided object and returns it to the app the next time it launches. The sample creates a new NSUserActivity object when the user closes the app or the app enters the background."
Here is some sample code that summarizes how to bring it all together. It isn't a minimum reproducible example because it is a part of the larger project called "Restoring Your App's State with SwiftUI" from Apple. But it gives a pretty good picture on how to implement it.
struct ContentView: View {
// The data model for storing all the products.
#EnvironmentObject var productsModel: ProductsModel
// Used for detecting when this scene is backgrounded and isn't currently visible.
#Environment(\.scenePhase) private var scenePhase
// The currently selected product, if any.
#SceneStorage("ContentView.selectedProduct") private var selectedProduct: String?
let columns = Array(repeating: GridItem(.adaptive(minimum: 94, maximum: 120)), count: 3)
var body: some View {
NavigationView {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(productsModel.products) { product in
NavigationLink(destination: DetailView(product: product, selectedProductID: $selectedProduct),
tag: product.id.uuidString,
selection: $selectedProduct) {
StackItemView(itemName: product.name, imageName: product.imageName)
}
.padding(8)
.buttonStyle(PlainButtonStyle())
.onDrag {
/** Register the product user activity as part of the drag provider which
will create a new scene when dropped to the left or right of the iPad screen.
*/
let userActivity = NSUserActivity(activityType: DetailView.productUserActivityType)
let localizedString = NSLocalizedString("DroppedProductTitle", comment: "Activity title with product name")
userActivity.title = String(format: localizedString, product.name)
userActivity.targetContentIdentifier = product.id.uuidString
try? userActivity.setTypedPayload(product)
return NSItemProvider(object: userActivity)
}
}
}
.padding()
}
.navigationTitle("ProductsTitle")
}
.navigationViewStyle(StackNavigationViewStyle())
.onContinueUserActivity(DetailView.productUserActivityType) { userActivity in
if let product = try? userActivity.typedPayload(Product.self) {
selectedProduct = product.id.uuidString
}
}
.onChange(of: scenePhase) { newScenePhase in
if newScenePhase == .background {
// Make sure to save any unsaved changes to the products model.
productsModel.save()
}
}
}
}
EDIT: I've added a rewording of my question at the bottom.
So I have an app that I've been working on for a long time. I used it to teach myself Xcode / Swift and now that I have a Mac again, I'm trying to learn SwiftUI as well by remaking the app completely as a new project.
I have an Entity in CoreData that only ever contains 1 record, which has all of the attributes of the settings I'm tracking.
Before, I was able to make an extension to my Entity, Ent_Settings that would allow me to always get that specific record back.
class var returnSettings: Ent_Settings {
//this is inside of extension Ent_Settings
let moc = PersistenceController.shared.container.viewContext
var settings: Ent_Settings?
let fetchRequest: NSFetchRequest<Ent_Settings> = Ent_Settings.fetchRequest()
do {
let results = try moc.fetch(fetchRequest)
if results.count == 0 {
Ent_Settings.createSettingsEntity()
do {
let results = try moc.fetch(fetchRequest)
settings = results.first!
} catch {
error.tryError(tryMessage: "Failed performing a fetch to get the Settings object after it was created.", loc: "Ent_Settings Extension")
}
} else {
settings = results.first!
}
} catch {
error.tryError(tryMessage: "Fetching Settings Object", loc: "Ent_Settings Extension")
}
return settings!
}
It took me a while to figure out how to get the moc now that there is no AppDelegate, but you can see I figured out in the code and that seems to work great.
However, when I go to a View that I made in SwiftUI, I'm getting weird results.
In the Simulator, I can run my app and see the changes. Leave the view and come back and see the changes are saved. However, if I rebuild and run the app it loses all data. So it seems it's not remembering things after all. Basically, it's volatile memory instead of persistent memory. But if I put another function in the extension of Ent_Settings to update that attribute and then build and run again, it will persistently save.
I'm wondering if it has something to do with how the context is managed, perhaps it's not seeing the changes since it is a different "listener"?
Here is what I'm doing to make the changes in the View. (I tried removing the extra stuff that just made it harder to see)
import SwiftUI
struct Settings_CheckedView: View {
#Environment(\.managedObjectContext) private var viewContext
#State private var picker1 = 0
var body: some View {
Form {
Section(header: Text("..."),
footer: Text("...")) {
VStack {
HStack { ... }
Picker("", selection: $picker1) {
Text("Off").tag(0)
Text("Immediately").tag(1)
Text("Delay").tag(2)
}
.pickerStyle(SegmentedPickerStyle())
.onChange(of: picker1, perform: { value in
settings.autoDeleteSegment = Int16(value)
viewContext.trySave("Updating auto-delete segment value in Settings",
loc: "Ent_Settings Extension")
})
}
}
...
...
.onAppear(perform: {
settings = Ent_Settings.returnSettings
self.picker1 = Int(settings.autoDeleteSegment)
self.picker2 = Int(settings.deleteDelaySegment)
})
}
}
And here is the code that I'm using for the trySave() function I have on the viewContext
extension NSManagedObjectContext {
func trySave(_ tryMessage: String, loc: String) {
let context = self
if context.hasChanges {
do {
try self.save()
} catch {
print("Attempted: \(tryMessage) -in \(loc)")
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
}
This code doesn't seem to actually save anything to the Database.
I also tried adding this code into Settings_CheckedView to see if that would make a difference, but I got even weird results.
#FetchRequest(sortDescriptors:[])
private var settingsResult: FetchedResults<Ent_Settings>
But that returns zero results even though I know the Ent_Settings has 1 record. So that makes me wonder if I am actually creating two databases with the way I'm doing it.
I'm really new to SwiftUI and CoreData seems to be one of those things that is hard to get information about. Hoping someone can point out where I'm going wrong. I'd love to be able to make CoreData changes in both Swift and SwiftUI.
Thank you for any help you can give and let me know if you need anything else added in.
EDITED: To try to reword question
So ultimately, I'm wanting to do some stuff with CoreData inside of the extension for that entity. My database has a lot of aspects to it, but I also made an entity in there to store settings information and since that one isn't related to anything else, I figured that would be a good starting point in learning SwiftUI. I have everything working in UIKit, but I'm specifically trying to learn SwiftUI as well.
From how I understand SwiftUI, it is meant to replace the storyboard and make the lifecycle stuff much easier. So classes I have that deal with CoreData should still do that external to SwiftUI.
As you can see above in my Settings_CheckedView view above, I'm referencing that single settings record from an extension in Ent_Settings. Basically, everything inside of that extension takes care of returning that single record of Settings, checking if that record exists and creating it if it doesn't (first time running app basically)
Ideally, I'd like to keep the functionality inside the extension of Ent_Settings, but my problem is I can't get the correct instance of the moc to persistently save it.
#main
struct MyApp: App {
let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
MainMenuView().onAppear(perform: {
if Ent_Lists.hasAnyList(persistenceController.container.viewContext) == false {
Ent_Lists.createAnyList(persistenceController.container.viewContext)
}
if Ent_Section.hasBlankSection(persistenceController.container.viewContext) == false {
Ent_Section.createBlankSection(persistenceController.container.viewContext)
}
//self.settings = Ent_Settings.returnSettings(persistenceController.container.viewContext)
})
//ContentView()
//.environment(\.managedObjectContext, persistenceController.container.viewContext)
}
}
}
I'm pretty certain that let persistenceController = PersistenceController.shared is initializing the persistent controller used throughout the app. Either creating it or retrieving it.
So, I tried this first inside of Ent_Settings:
let moc = PersistenceController.shared.container.viewContext
but I think that this might be creating a new PersistenceController outside the one made inside of MyApp
I also tried let moc = EZ_Lists_SwiftUI.PersistenceController.shared.container.viewContext but I'm pretty sure that also makes a new instance given I can only access the upper case PersistenceController and not the lower case one.
I also tried just passing the moc from the view like this: class func createSettingsEntity(_ moc: NSManagedObjectContext) { but I get an error about the moc being nil, so I'm guessing the moc can't be sent by reference from a Struct.
Thread 1: "+entityForName: nil is not a legal NSPersistentStoreCoordinator for searching for entity name 'Ent_Settings'"
And just to be clear, I'm adding this to the view: #Environment(\.managedObjectContext) private var viewContext adding this #State property to the View: #State private var settings: Ent_Settings! and setting it within .onAppear with this: self.settings = Ent_Settings.returnSettings(self.viewContext)
So what I'm really looking for is how I access the moc from within that extension of Ent_Settings. In my app that was purely UIKit and Storyboards I used this: let moc = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext but as there is no longer an AppDelegate, I cannot use that anymore. What replaces this to get moc when you are doing SwiftUI but working outside of the Views, like in a class or something like I am? Thank you. Hopefully this extra information helps explain my issue.
If you are only using coredata to store settings, I would look into UserDefaults and if that matches your use case.
https://developer.apple.com/documentation/foundation/userdefaults
If you for some reason need to use CoreData, I would recommend enabling CloudKit, as CloudKit will allow you to actually view what data is saved via a web console.
Also, SwiftUI does not create an AppDelegate for you, you are still able to just create your own AppDelegate.swift file as you would with UIKit.
Just define the #UIApplicationDelegateAdaptor to your custom AppDelegate.swift file in your top most struct (the one with #main) before the struct declaration.
#main
struct MainAppView: App {
#UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
Okay, a bit of egg on my face. I finally figured out why I was having so much trouble. When I was making my app, I commented out everything related to the apple provided ContextView and commented out too much.
//ContentView()
//.environment(\.managedObjectContext, persistenceController.container.viewContext)
I lost the part where the environment object was getting setup. After putting that back in I'm able to save to the moc now.
In my Entity class extension I'm accessing the moc this way:
let moc = MyApp.PersistenceController.shared.container.viewContext
Thank you for everyone who looked and tried to help. Turns out I was shooting myself in the foot.
I'm new to swfitUI, and I'm building a component a bit like this:
// my solution
struct TodoItem {
var title: String
var action: (() -> Void)?
var body: some View {
HStack {
Text(title)
if let action = action {
Button(action: action, label: Image(image))
}
}
}
}
but my teammates not agree with this, they think I should not pass an action to a component, instead I should use ViewBuilder like this,
// my teammates' solution
struct TodoItem<Content>: View where Content: View {
var title: String
var content: Content
#inlinable public init(title: String, #ViewBuilder content: () -> Content) {
self.title = title
self.content = content()
}
var body: some View {
HStack {
Text(title)
content
}
}
}
they said it is more SwiftUI fashion, but I don't understand, in terms of usage, in my solution, anyone use this component only need to care about the title and the action, in my teammates', the user need to care about the title, the action, and how to build the button, it is clearly that my solution is more easy to use, and I didn't see any disadvantage in my solution.
Is my teammates' solution better than mine, why?
If you find yourself reaching for AnyView, you've left the happy-path of SwiftUI. There's a reason it's listed in the Infrequently Used Views section of the docs.
AnyView breaks a lot of SwiftUI's optimizations. It exists as an escape-hatch when you have no other choice.
Your code looks like all the examples I've seen from Apple so far. #ViewBuilder makes sense when your goal is be a container for a caller-generated View, and you want the implementation details to be decided by the caller. HStack is a good example. If the component should encapsulate the View implementation details, then it should generate the view itself using properties passed (which is what you're doing). So the question in this case is "is TodoItem a general tool that will be used in many different ways by many different callers?" If not, I'm not sure why you would pass a ViewBuilder.
Your update (removing AnyView) changes the question quite a bit. In that case it comes down to my last paragraph above: If TodoItem is intended to be a generic container that callers are expected to provide contents for, then the ViewBuilder is good. This assumes that TodoItem is about layout rather than about display, like HStack.
But if it's view that is about display, like Button or Text, then you should pass it properties and let it manage its internals. (Note that Button allows you to pass in a Label ViewBuilder, but it generally does not require it.)
A name like "TodoItem" sounds very much like the latter; it seems like a custom view that should manage its own appearance. But the main question is: how many callers pass different Views to this ViewBuilder? If all callers pass pretty much the same Views (or if there's only one caller), then it should be properties (like a Button). If there are many callers that pass different kinds of content Views, then it should use a ViewBuilder (like an HStack).
Neither is "more SwiftUI." They solve different problems within SwiftUI.
Your approach is correct, with only change (let will not work), so see below corrected:
struct TodoItem {
var title: String
var image: String // << this might also needed
var action: (() -> Void)?
var body: some View {
HStack {
Text(title)
if action != nil { // << here !!
Button(action: action!, label: { Image(image) })
}
}
}
}
Tested with Xcode 11.4 / iOS 13.4
About teammate's alternate: Storing View in member is not a "SwiftUI fashion"... so, fixing second variant I would store ViewBuilder into member and use it to inject View inside body. But anyway it's worse approach, because breaks integrity of UI component.