Olya, tests and factory — the path to beautiful architecture and clean code
There are many different approaches, solutions and ways to automate testing. The most common and indispensable is the Page Object (Screen Object) pattern. I have dealt with two approaches when working with this pattern: with and without a factory for my page objects. In this article, we will compare both approaches, their pros and cons on the example of our autotests. I will show you what our factory of page objects looks like. I will also tell you about the challenges we have faced with in autotests with the factory and how we have solved them.
All the examples in the article will be in Swift, but for Android autotests everything works the same way.
Before we start.
Hi, my name is Olga. I am a mobile QA at hh.ru. For two and a half years we have converted 90% of manual testing to automated testing. During this time we have managed to run into all sorts of pitfalls many times and overcame hundreds of arguments, and now I want to share my experience with the world.
We write autotests for both Android (Kotlin, Kaspresso) and iOS (Swift, XCUITest). We try to make our UI-tests small, checking only single test cases. Luckily, we have enough server capabilities for that, so the regression testing which consists of ~300–400 UI autotests on each platform takes on average 30–40 minutes.
In our autotests we use the Page Object pattern. Thousands of articles have been written about it, so we won’t dwell on it in detail.
Historically, when creating the first autotests for iOS and Android, we chose different approaches of working with page objects. For Android we just follow a pattern, while for iOS we also created a factory of page objects (for implement the fluent interface). The factory is the place where all page objects are initialized. We can use it to pass other page objects in our screen methods, thereby building a chain of interactions similar to the app behavior.
Is there a need for a page object factory?
After creating hundreds of test cases, we made the conclusion that using a page object factory is a matter of tester’s taste who will write the autotests. There is no difference in performance and stability when working with different approaches. But to avoid struggling with your own code in the future, you need to decide as early as possible whether there will be a place for the page objects factory in your autotests architecture or not.
Here are two examples of the same test, written with and without the factory.
What this test does: the user tries to go to the profile tab from the main application screen. In the profile, he selects “Login”, then signs up by login method and enters his login and password, then clicks the “Login” button.
// test without a factory
class ExampleLoginTestSuit: BaseTestCase {
let mainScreen = MainScreen()
let profileScreen = ProfileScreen()
let authorizationScreen = AuthorizationScreen()
let loginScreen = LoginScreen() func testExampleLogin() {
let user = userFixtureService.createUser() mainScreen.openProfileTab()
profileScreen.goToAuthorization()
authorizationScreen.goToLogin()
loginScreen
.enterLogin(user.login)
.enterPassword(user.password)
.logIn()
}
}// test with a factory
class ExampleLoginTestSuit: BaseTestCase {
func testExampleLogin() {
let user = userFixtureService.createUser() pageObjectsFactory
.makeMainScreenPageObject()
.openProfileTab()
.goToAuthorization()
.goToLogin()
.enterLogin(user.login)
.enterPassword(user.password)
.logIn()
}
}
If after this example you decided on tests without a factory looking good, just go ahead and write without one. This advice is especially relevant if your app is not large with a variety of non-repeating content and elements. Or if you write tests with no more than 10–15 steps. In that case, a factory won’t make a difference.
But if it doesn’t, or if tests with a factory resonated in your heart, then this article is for you.
What our factory looks like
Let’s find out what the page object factory is needed for, what it looks like, and what it does.
The standard test case is a set of steps:
1. Run the app
2. Open the Profile screen
3. Tap on “Login.”
4. Select the sign in method by login and password
5. Enter user’s data (login and password)
6. Tap the “Login” button
Each step of the test case corresponds to a step in the autotest. I can also say that each step is a separate method of page object of the corresponding screen.
If we want to exactly repeat this test case in the autotest, building the same uninterrupted chain of method calls, we need each method to return the page object of the next screen.
When you create a page object, you need to initialize it. So, if we want to create page objects inside other pages, each page object will have many of the same initialisations. To avoid this, we move initialization of all page objects to a factory: pageObjectFactory (or screenFactory, etc.). At the end of each method, we ask the factory to create the screen we want.
final class ProfilePageObject: BasePageObject {
func goToAuthScreen() -> AuthPageObject {
openAuthButton.tap()
return pageObjectsFactory
.makeAuthPageObject()
}
}
All page objects are inherited from BasePageObject, which contains the main parameters. In it, we must write a required constructor so that the factory can create any of its descendants. In our case, the base class looks like this:
class BasePageObject {
let pageObjectsFactory: PageObjectsFactory
let application: XCUIApplication
required init(pageObjectsFactory: PageObjectsFactory,
application: XCUIApplication) {
self.pageObjectsFactory = pageObjectsFactory
self.application = application
}
}
And the screen initialization in the factory eventually looks like this:
final class PageObjectsFactory { private func initializePageObject<PageObject: BasePageObject>(ofType type: PageObject.Type) -> PageObject {
return type.init(pageObjectsFactory: self, application: application)
} func makeAuthPageObject() -> AuthPageObject {
return initializePageObject(ofType: AuthPageObject.self)
}
}
As a result, in any page object method, we can call the factory method of creating the screen, which will initialize it itself.
Pros of factory-free autotester’s life
- It is always clear from the test on which screen the action takes place
Without a factory, each line of test clearly contains the screen and the action to be performed on it. This allows us to avoid reading the whole test code from the very beginning, e.g., when debugging and patching. The absence of a factory especially adds readability when actions or tests are performed on a one screen.
class ExampleLoginTestSuit : BaseTestCase { // create objects of the screens used in the test
let mainScreen = MainScreen()
let profileScreen = ProfileScreen()
let authorizationScreen = AuthorizationScreen()
let loginScreen = LoginScreen() func testExampleLogin() {
let user = userFixtureService.createUser() // create a test user mainScreen.openProfileTab() // from the main screen open Profile Tab
profileScreen.goToAuthorization() // on the profile screen select "Login"
authorizationScreen.goToLogin() // on the login selection screen select login and password
loginScreen // on the login screen, enter your login and password and press the login button
.enterLogin(user.login)
.enterPassword(user.password)
.login()
}
}
Even if you are not too well awared of the app details, it is immediately clear from the above example that the enterLogin / enterPassword actions are performed on the same screen. In the test with the factory you won’t see this clarity; you might think that the enterLogin action took the user to the next screen.
2. It is more convenient to write tests with actions that can lead to different screens
When writing tests without a factory, you don’t have to think about the fact that any user action may behave differently depending on the state of the app, you just describe the logic of what happens:
// a response from an unauthorized user opens the app login screen
vacancyScreen.tapResponseButton()
authScreen.authUser(user)
responseScreen.checkScreenIsOpened()
or
// a response by an authorized user immediately opens the response screen
vacancyScreen.tapResponseButton()
responseScreen.checkScreenIsOpened()
In the case of the page object factory, you have to think about how to return the desired screen in the tapResponseButton() method (or duplicate a method like tapResponseButtonAndLogin() ), depending on the user’s authorization.
final class VacancyPageObject: BasePageObject {
... func tapResponseButton() -> ResponseSendPageObject {
responseButton.tap()
return pageObjectsFactory
.makeResponseSendPageObject()
}
func tapResponseButtonAndLogin(login: String) -> ResponseSendPageObject {
responseButton.tap()
pageObjectsFactory
.makeAuthorizationPageObject()
.goToLogin()
.logIn(login)
return pageObjectsFactory
.makeResponseSendPageObject()
}
}
We have 6 such methods on this screen, and they all tap on the same button.
3. No need to convey each action and each check into a separate method
The page objects without a factory method do not need to return the next screen for the chain continuation, so you don’t need to create separate methods per an action.
For example, a tap on a button in a test without a factory can look like this:
vacancyScreen.responseButton.tap()
4. You don’t have to think about complex solutions, additionally think over the page objects architecture
In my experience, when writing autotests without a factory, almost no problems arise with page objects. Describing a new page object is easy and fast.
The factory, along with its capabilities, adds some complications, which I’ll share later.
Undoubted pros of the factory
1. There is no way to skip a step while writing a test
All page object methods are links in the same chain. Each new link (method) must be connected with the next. This is due to the fact that all methods return some page object (itself or another), so that when writing a test we do not have the possibility to choose the method of any screen, only of the next one.
First, it greatly speeds up a new tester’s writing their first autotest. The IDE itself prompts which actions and on which screen you can perform next.
Second, this autotest architecture allows you to learn the app through writing them and not reverse order. (again — good motivation for junior autotesters).
Third, there is no opportunity to skip a step, because the desired method simply won’t appear until you go through the script correctly. Such omissions are quite common when writing tests without a factory, and you find out them only when running the test.
2. If you change the signature of the page object method, the IDE will change all the tests related to the object
This point follows on from the previous one. All page object methods replicate the app logic and behavior. If the application changes the logic of transitions between screens or adds new ones, we fix the method we need and change its return value. In autotests with the factory you don’t need to run all tests to find all the tests affected by this change. The IDE itself will point to all places where the “chain” is broken. Tests without a factory do not keep track of this.
3. The architecture and cleanliness of the code are not affected by the creation of unnecessary screen objects
If you do not use a factory, and the test passes through dozens of screens in the script, then at the beginning of the class with the test you have to write a whole block of creating objects of each screen. And you have to do this every time for each test class. Even worse, if you write several tests in one class. Then the size of this “block” increases many times alongside with creation of all page objects.
An example from a real test, where a user responds to a vacancy, gets logged out and authorized by using other user account. This block moves from class to class with minor changes for all similar tests.
final class CounterUpdateAfterOtherUserLoginTest: BaseTestCase {
private lazy var mainScreen = MainScreenPageObject()
private lazy var vacanciesScreen = VacanciesScreenPageObject()
private lazy var responseToVacancyScreen = ResponseToVacancyBottomSheet()
private lazy var successResponseBottomSheet = SuccessResponseBottomSheet()
private lazy var settingsScreen = UserSettingsScreenPageObject()
private lazy var chooseAuthScreen = ChooseAuthScreenPageObject()
private lazy var authScreen = NativeAuthScreenPageObject()
private lazy var navigation = NavigationPageObject()
private lazy var moreScreen = MoreScreenPageObject()
...
}
There is an option to bring initialization of all page objects into the base class of tests, using lazy initialization. Then all screens will be available in each test and no unnecessary objects will be created. But the problem with enumerating many screens will return when we want to initialize page objects in the page objects themselves, if we need to write a method that passes through a different screen.
The factory, on the other hand, takes on the task of initializing the required page objects when we need them.
4. A great opportunity to immerse yourself deeper into the application code, study its architecture, modules, their interaction etc.
Having a page object factory will require you to wrap all screen actions in methods that return the next screen. Because of this, page objects get really big, which makes you wonder how to make things look nicer and neater. For this reason, we try to use architecture tricks, look at the implementation of different screens in the app code, trace the interaction of modules to build a similar system in your autotests, which will be nice and comfortable to use.
As a bonus, we get to know how the application works from the inside. At a minimum, this is useful for general development, and often very helpful in testing.
5. The test code looks very neat and clean
No comment here, it’s really a matter of taste. About how “neater and cleaner” for you can be decided by the very first example.
Pitfalls // how to stumble and get around
We can conclude that it’s better to use the page object factory when writing autotests. Whoever you ask why they use the factory, the answer is always the same: “And you try to write a test without it and compare”. Factory really takes care of a large part of tasks, the responsibility for absence of errors in autotest sequence, etc. It also opens up some interesting possibilities, which I have already written about above.
But I want to note that everything is not always rosy with these opportunities. When automating with the factory we encountered some very unpleasant problems, but in the end we solved them.
Protocols and common elements for the entire app
In any mobile app, there are elements that can be accessed from any screen. As an example, we can take the tabbar (menu). The question arises: how can we access the tabbar methods at any moment of the test without interrupting the chain of page object method calls?
The most obvious and our initial solution is to make an extension of the base class of page objects with these methods.
import XCTest /*
An extension to use the tabbar in the application.
*/extension BasePageObject {
private lazy var tabBar = application.tabBars[Accessibility.TabBar.identifier].firstMatch
var searchTab = tabBar.buttons[Accessibility.TabBar.searchTab].firstMatch … func openSearchTab() -> MainScreenPageObject {
searchTab.tap()
return pageObjectsFactory.makeMainScreenPageObject()
} …
}
During the discussion, we realized that the tabbar is not the only element that is needed on all screens, and concluded that by adding more and more extensions, we would quickly clutter up the base class.
Another disadvantage of this solution is that methods become available to absolutely all page objects, which is wrong. This breaks the contract of derived classes, which are supposed to contain only methods specific to them. For example, we don’t need tabbar methods for page object-alerts.
Our final solution — we made TabBarUsable protocol from page object (similar to Kotlin — interface). And wrote its extension (extension, implementation), which allows not to duplicate code and at the same time replace inheritance by composition.
import XCTest protocol TabBarUsable { var searchTab: XCUIElement { get } func openSearchTab() -> MainScreenPageObject
…
} extension TabBarUsable where Self: BasePageObject {
private var tabBar: XCUIElement { application.tabBars[Accessibility.TabBar.identifier].firstMatch }
var searchTab: XCUIElement { tabBar.buttons[Accessibility.TabBar.searchTab].firstMatch } func openSearchTab() -> MainScreenPageObject {
searchTab.tap()
return pageObjectsFactory.makeMainScreenPageObject()
}
…
}
For all screens that have a tabbar, we add adoption to this protocol.
final class VacancyPageObject: BasePageObject, TabBarUsable {
…
}
Accordingly, all methods of working with the tabbar become available on all these screens.
class ExampleTabbarTestSuit: BaseTestCase { func testExampleOpenSearchTab() {
pageObjectsFactory
.makeMainScreenPageObject()
.openVacanciesList()
.openVacancy() // method returns .makeVacancyPageObject()
.openSearchTab() // method from TabBarUsable
}
}
This does not break the architecture, the tabbar methods are available only for the screens we want, and the code of the same methods is not duplicated.
Protocols and Code DRYing
As mentioned above, when you use the page object factory, all actions and tests are wrapped in methods. After a while, when you create another autotest, you start to notice that you write the same methods for every page object, which even don’t differ in elements. Examples of such methods are zeroscreen tests, handling the same list items on different screens and so on.
A logical thought arises: “Why in the world would I do that? How to stop duplicating code?
The first way, which I have already written about, is to move all such methods to a base class. This can be done, but very cautiously. First of all, it is a very engaging process. It seems a single method in a base class won’t break anything, but sooner or later the base class turns into an incomprehensible, unstructured, cluttered monster, in short, it becomes completely unusable. We’ve tried it, we know it. Getting rid of this monster is even harder than making it correctly right away.
Over time, we realized that the DRY (Don’t Repeat Yourself) principle was invented for a reason. We began to look for the same methods that are used in many page objects. When we found them, we had a long discussion about whether they were used everywhere in the same way and had the same logic. Finally, we decided that if 80% of the methods are used in the same way, we should put them in a separate protocol.
One of the first methods that we brought out was the waitView() method. We described a special ViewWaitable protocol and its implementation in protocol extension, and now to make this method available in page object you just need to add the conformity of the protocol. Since all screens have different view IDs, all page objects that use the protocol must declare their own view.
protocol ViewWaitable {
var view: XCUIElement { get }
} extension ViewWaitable where Self: BasePageObject {
@discardableResult
func waitView() -> Self {
testWaiter.waitForElementToAppear(view)
return self
}
}final class VacancyPageObject: BasePageObject, TabBarUsable, ViewWaitable { lazy var view = application.otherElements[Accessibility.view].firstMatch
…
}
Another example from our hh.ru app: we have a job list that appears on many different screens, with the only difference being the view ID again where the list appears. There are a lot of actions and a ton of tests associated with this list. Multiply them by about 10 (by the number of screens on which this list occurs) to estimate the scale of code duplication.
Once again, the solution to the problem was protocols. To make protocols even clearer and more easy-to-use, we separated list elements (VacancyListContainig), cell elements (VacancyCellContainig), methods with tests (asserts, checks) and interaction methods (actions). This separation perfectly solved the problem of a huge page object readability. The final architecture looks like this:
Page objects that have a job list can add conformity to the VacancyListPageObject protocol. This makes all methods from the implementation of this protocol available to the screen without any code duplication.
final class SearchResultPageObject: BasePageObject, ViewWaitable, VacancyListPageObject { lazy var view = application
.otherElements[Accessibility.SearchResults.view].firstMatch
lazy var listView = application
.tables[Accessibility.SearchResults.tableView].firstMatch
…
}
As a result, by following the DRY principle, we have very clean, neat page objects, identical method names, and no unnecessary code.
Same alerts on different screens // Sources
System errors, alerts, and bottom sheets are an integral part of regresses, which we definitely want to cover with autotests. Errors and alerts occur on different screens, in different cases, and in different app states, but the elements themselves basically have the same identifiers and behavior. Obviously, the same type of alerts with two buttons (e.g. Ok/Cancel) = one separate page object.
Back to the desire not to interrupt the chain of method calls. We have dozens of screens from which we can open the same alert. When we close such an alert, we should return to the page object from which it was opened. We don’t want to write these dozens of duplicate methods in the page object of such alert; they will differ from each other only by the return page object.
The simplest solution is to stop care and break the test once. There’s nothing wrong with that either. And life gets much easier.
class ExampleTestSuit: BaseTestCase { func testExample() {
pageObjectsFactory
.makeMainScreenPageObject()
.openVacanciesList()
.openVacancy()
.openAlert()
.closeAlert() // does not return the screen we want, so the call chain is broken
pageObjectFactory
.makeVacancyPageObject() // re-create the screen
…
}
}
Our solution is more complicated, but it’s so beautiful! We added a type parameter (generic) alert to the page object. This will be the type of the page object from which we open the alert. The page object of this screen will be passed to the page object of the alert during initialization and stored as a generic variable source.
More details about how it works: When opening (and initializing) an alert we pass the target screen in the generic variable source. This is done in the page method of the object that opens the alert:
final class VacancyPageObject: BasePageObject { …
func openAlert() -> AlertPageObject<VacancyPageObject> {
button.tap()
return pageObjectsFactory
.makeAlertPageObject(from: self) // pass source VacancyPageObject
}
}
Then this source (in this example it’s VacancyPageObject) goes through all the methods needed in the test and gets to the final dismissAlert(). As a result, the dismissAlert() method returns the desired screen, on which we can immediately continue the test.
final class AlertPageObject<Source: BasePageObject>: BasePageObject { func dismissAlert() -> Source {
cancelButton.tap()
return source // return the screen from which you opened the alert - VacancyPageObject
}
}
As I said, for a page object to be able to receive and return the required screens (sources), it needs to be assigned a generic type limited to the basic page object type, and initialized using the page object to be returned at the very end:
final class AlertPageObject<Source: BasePageObject>: BasePageObject { // a generic variable to store the screen for later return to the general call chain
private let source: Source init(pageObjectsFactory: PageObjectsFactory,
application: XCUIApplication,
source: Source) {
self.source = source
super.init(pageObjectsFactory: pageObjectsFactory, application: application)
} // BasePageObject has a mandatory constructor, but here we forbid its use
required init(pageObjectsFactory: PageObjectsFactory, application: XCUIApplication) {
fatalError("init(pageObjectsFactory:application:) has not been implemented. Use another init")
}
}
The initialization of a generic page object in the factory would look like this:
final class PageObjectsFactory {
…
func makeAlertPageObject<Source: BasePageObject>(
from source: Source
) -> AlertPageObject<Source> {
return AlertPageObject(pageObjectsFactory: self,
application: application,
source: source)
}
}
Ta-da! Done. Now we can continue the test without breaking the call chain.
class ExampleSourcesTestSuit: BaseTestCase { func testExampleSource() {
pageObjectsFactory
.makeMainScreenPageObject()
.openVacanciesList()
.openVacancy()
.openAlert()
.dismissAlert()
.checkVacancyScreenIsOpened() // after closing the alert, we get back to the screen we left and can continue working with it
…
}
}
A huge page object, how to understand it
There are screens (there really are) on which there is a lot of different content, various interface elements, individual logical blocks. At the same time, all of them are on the same screen, and we want to interact with them through a page object that describes this screen.
Let’s take the main screen of our mobile app as an example.
This screen can be divided into three completely independent sections: search bar, search history block and tabs with vacancies listings. You could describe everything in one page object (because it’s a full-fledged screen), but it wouldn’t be very easy to use, because each section has its own logic, its own checks, etc.
Sooner or later, we’ll also face the problem of method naming. There are a lot of different cells, titles, subtitles, etc., so you end up with long names instead of simple ones. Writing tests becomes very inconvenient and complicated. Every time you select a method in a test, you have to open the page object code and look into it to make sure you don’t make a mistake.
final class VacancyListPageObject: BasePageObject {
…
func assertHistoryListFirstCellTitleExists() -> Self { ... }
func assertHistoryListCellByTitleExists(title: String) -> Self { ... }
func assertRecommendationsListFirstCellTitleExists() -> Self { ... }
func assertVacancyNearbyListFirstCellTitleExists() -> Self { ... }
… etc.
}
The easiest solution is not to see it as a problem. No matter how big a page object gets, you can still use it (especially if you get used to it). But you don’t want to get used to it, and you want it to be nice and convenient.
In addition, these “sections” can be different modules in the code, which means that they can be reused on different screens of the app. If we build a similar architecture with page objects, we could also reuse them.
We have decided to write different page object classes for these sections. We add an empty MainScreenSection: BasePageObject { } to each of them.
Let’s take the search history section of the main screen as an example. Its page object will look like this:
final class SearchHistoryPageObject: BasePageObject, MainScreenSection {
// variables
// methods
}
In the main page object MainScreenPageObject, which describes the main screen, we made a method that tells the test in which section we are now going to do something. It looks like this.
final class MainScreenPageObject: BasePageObject, TabBarUsable {
…
func section<Section: MainScreenSection>(_ section: Section.Type) -> Section {
return section.init(pageObjectsFactory: pageObjectsFactory, application: application)
}
}
In this method, we pass the section type and initialize the page object inside using the generic method of the factory.
As a result, we get uninterrupted test, no unnecessary initializations of huge page objects and readability of the code.
// open the main screen and work with the "Search History" section
.openSearchTab() // appears in MainScreenPageObject
.section(SearchHistoryPageObject. self) // go to the Search History section
.waitHistoryIsLoad() // work in the page object of the search history
.section(MainScreenPageObject. self) // in the same way we can go back to the main screen
To summarize
It is possible to write autotests without a page object factory. Page objects can be written very quickly and easily, especially if they do not need complex initialization. On the other hand, you have to be very careful when writing a test. The tester is responsible for the sequence of steps in the test, initialization of page objects, lack of wrappers over some actions, etc. If your application isn’t very complex, there’s probably no need to complicate it, and all these problems won’t be noticeable at all.
If at the very beginning you spend a little time on creating a factory, in the future it will, first, take care of all of the above responsibilities, and secondly, make the process of writing autotests very easy and with almost no chance of error.
Since there is no single correct solution here, it is up to you to decide. We continue to use both approaches, and everyone is comfortable and happy. Good luck and have beautiful autotests!