Probably the best architecture for UI tests



Probably, somewhere there is an ideal article that immediately and completely reveals the topic of test architecture, easy to write, and read, and support, and so that it is understandable for beginners, with examples of implementation and areas of application. I would like to offer my vision of this “ideal article” in the format I dreamed of, only after receiving the first task “write autotests”. To do this, I will talk about the well-known and not so well-known approaches to web autotests, why, how and when to use them, as well as about successful solutions for storing and creating data.



Hello, Habr! My name is Diana, I am the head of the user interface testing group, I have been automating web and desktop tests for five years. Code examples will be in java and for the web, but, in practice, it has been tested, the approaches are applicable to python with a desktop.



In the beginning it was ...



In the beginning there was a word, and there were a lot of words, and they filled all the pages evenly with code, regardless of your architectures and DRY principles (don't repeat yourself - no need to repeat the code that you already wrote three paragraphs above).



Sheet



In fact, the architecture of the "footcloth", aka "sheet", aka unstructured code heaped in a heap that evenly fills the screen, is not so bad and is quite applicable in the following situations:



  • A quick click in three lines (okay, two hundred and three) for very small projects;
  • For code examples in the mini demo;
  • For the first code in the "Hello Word" style among autotests.


What do you need to do to get the architecture of the Bed Sheet? just write all the necessary code into one file, a common canvas.



import com.codeborne.selenide.Condition;
import com.codeborne.selenide.WebDriverRunner;
import org.testng.annotations.Test;

import static com.codeborne.selenide.Selenide.*;

public class RandomSheetTests {
    @Test
    void addUser() {
        open("https://ui-app-for-autotest.herokuapp.com/");
        $("#loginEmail").sendKeys("test@protei.ru");
        $("#loginPassword").sendKeys("test");
        $("#authButton").click();
        $("#menuMain").shouldBe(Condition.appear);

        $("#menuUsersOpener").hover();
        $("#menuUserAdd").click();

        $("#dataEmail").sendKeys("mail@mail.ru");
        $("#dataPassword").sendKeys("testPassword");
        $("#dataName").sendKeys("testUser");
        $("#dataGender").selectOptionContainingText("");
        $("#dataSelect12").click();
        $("#dataSelect21").click();
        $("#dataSelect22").click();
        $("#dataSend").click();

        $(".uk-modal-body").shouldHave(Condition.text(" ."));

        WebDriverRunner.closeWebDriver();
    }
}


If you are just starting to get acquainted with autotests, then the "sheet" is already enough for a simple test task, especially if you show good knowledge of test design and good coverage. But this is too easy for large-scale projects, so if you have ambitions, but do not have time to ideally execute each test case, then at least your gita should have an example of a more complex architecture.



PageObject



Heard of rumors that PageObject is deprecated? You just don't know how to cook it!



The main working unit in this pattern is a “page”, that is, a complete set of elements and actions with them, for example, MenuPage - a class that describes all actions with a menu, that is, clicks on tabs, expanding drop-down items, and so on.







It is a little more difficult to compose a PageObject for the modal window (for short "modal") of object creation. The set of class fields is clear: all input fields, checkboxes, drop-down lists; and for methods there are two options: you can make both universal methods "fill in all modal fields", "fill all modal fields with random values", "check all modalk fields", and separate methods "fill in the name", "check the name", "Fill in the description" and so on. What to use in a particular case is determined by priorities - the “one method for the entire modal” approach increases the speed of writing a test, but compared to the “one method for each field” approach it loses a lot in the readability of the test.



Example
Let's compose a common Page Object of creating users for both types of tests:

public class UsersPage {

    @FindBy(how = How.ID, using = "dataEmail")
    private SelenideElement email;
    @FindBy(how = How.ID, using = "dataPassword")
    private SelenideElement password;
    @FindBy(how = How.ID, using = "dataName")
    private SelenideElement name;
    @FindBy(how = How.ID, using = "dataGender")
    private SelenideElement gender;
    @FindBy(how = How.ID, using = "dataSelect11")
    private SelenideElement var11;
    @FindBy(how = How.ID, using = "dataSelect12")
    private SelenideElement var12;
    @FindBy(how = How.ID, using = "dataSelect21")
    private SelenideElement var21;
    @FindBy(how = How.ID, using = "dataSelect22")
    private SelenideElement var22;
    @FindBy(how = How.ID, using = "dataSelect23")
    private SelenideElement var23;
    @FindBy(how = How.ID, using = "dataSend")
    private SelenideElement save;

    @Step("Complex add user")
    public UsersPage complexAddUser(String userMail, String userPassword, String userName, String userGender, 
                                    boolean v11, boolean v12, boolean v21, boolean v22, boolean v23) {
        email.sendKeys(userMail);
        password.sendKeys(userPassword);
        name.sendKeys(userName);
        gender.selectOption(userGender);
        set(var11, v11);
        set(var12, v12);
        set(var21, v21);
        set(var22, v22);
        set(var23, v23);
        save.click();
        return this;
    }

    @Step("Fill user Email")
    public UsersPage sendKeysEmail(String text) {...}

    @Step("Fill user Password")
    public UsersPage sendKeysPassword(String text) {...}

    @Step("Fill user Name")
    public UsersPage sendKeysName(String text) {...}

    @Step("Select user Gender")
    public UsersPage selectGender(String text) {...}

    @Step("Select user variant 1.1")
    public UsersPage selectVar11(boolean flag) {...}

    @Step("Select user variant 1.2")
    public UsersPage selectVar12(boolean flag) {...}

    @Step("Select user variant 2.1")
    public UsersPage selectVar21(boolean flag) {...}

    @Step("Select user variant 2.2")
    public UsersPage selectVar22(boolean flag) {...}

    @Step("Select user variant 2.3")
    public UsersPage selectVar23(boolean flag) {...}

    @Step("Click save")
    public UsersPage clickSave() {...}

    private void set(SelenideElement checkbox, boolean flag) {
        if (flag) {
            if (!checkbox.isSelected()) checkbox.click();
        } else {
            if (checkbox.isSelected()) checkbox.click();
        }
    }
}


:



    @Test
    void addUser() {
        baseRouter.authPage()
                .complexLogin("test@protei.ru", "test")
                .complexOpenAddUser()
                .complexAddUser("mail@test.ru", "pswrd", "TESTNAME", "", true, false, true, true, true)
                .checkAndCloseSuccessfulAlert();
    }


:



    @Test
    void addUserWithoutComplex() {
        //Arrange
        baseRouter.authPage()
                .complexLogin("test@protei.ru", "test");
        //Act
        baseRouter.mainPage()
                .hoverUsersOpener()
                .clickAddUserMenu();
        baseRouter.usersPage()
                .sendKeysEmail("mail@test.ru")
                .sendKeysPassword("pswrd")
                .sendKeysName("TESTNAME")
                .selectGender("")
                .selectVar11(true)
                .selectVar12(false)
                .selectVar21(true)
                .selectVar22(true)
                .selectVar23(true)
                .clickSave();
        //Assert
        baseRouter.usersPage()
                .checkTextSavePopup(" .")
                .closeSavePopup();
    }


. : , , , , — . , , , .



The bottom line is that all actions with pages are encapsulated inside the pages (the implementation is hidden, only logical actions are available), thus, business functions are already used in the test. And this, in turn, allows you to write your own pages for each platform (web, desktop, mobile phones) without changing the tests.



The only pity is that absolutely identical interfaces are rare on different platforms.



To reduce the discrepancy between the interfaces, there is a temptation to complicate individual steps, they are taken out into separate intermediate classes, and the tests become less and less readable, up to two steps: "log in, do well", the test is over. In addition to the web, there were no additional interfaces in our projects, and we have to read cases more often than write, therefore, for the sake of readability, historical PageObjects have acquired a new look.



PageObject is a classic that everyone knows about. You can find many articles on this approach with examples in almost any programming language. The use of PageObject is very often used to judge whether a candidate knows something about testing user interfaces. Performing a test assignment using this approach is what most employers expect, and a lot of it lives in production projects, even if only the web is testing.



What else happens?



Oddly enough, not a single PageObject!



  • Often found pattern ScreenPlay, which you can read for example here . It did not take root in our country, since using bdd approaches without involving people who cannot read the code is a senseless violence against automators.
  • js- , PageObject, - , , .
  • - , , ModelBaseTesting, . , .


And I'll tell you in more detail about the Page Element, which allows you to reduce the amount of the same type of code, while increasing readability and providing quick understanding of tests, even for those who are not familiar with the project. And on it (with its own blackjacks and preferences, of course!) The popular non-js frameworks htmlElements, Atlas and Epam's JDI are built.



What is Page Element?



To build the Page Element pattern, start with the lowest-level element. As Wiktionary says , a "widget" is a software primitive of a graphical user interface that has a standard appearance and performs standard actions. For example, the simplest widget "Button" - you can click on it, you can check its text and color. In the "Input field", you can enter text, check what text is entered, click, check the focus display, check the number of entered characters, enter the text and press "Enter", check the placeholder, check the highlighting of the "mandatory" field and the error text, and that's it, what else may be needed in a particular case. Moreover, all actions with this field are standard on any page.







There are more complex widgets for which the actions are not so obvious, for example, tree tables of contents. When writing them, you need to build on what the user does with this area of ​​the program, for example:



  • Click on an element of the table of contents with the specified text,
  • Checking the existence of an element with the given text,
  • Checking the indentation of an element with a given text.


Widgets can be of two types: with a locator in the constructor and with a locator sewn into the widget without the ability to change it. The table of contents is usually one on the page, its search method on the page can be left "inside" the actions with the table of contents, it makes no sense to take out its locator separately, since the locator can be accidentally damaged from the outside, but there is no benefit from its separate storage. In turn, a text field is a universal thing, on the contrary, you need to work with it only through the locator from the constructor, because there can be many input fields at once. If at least one method appears that is intended only for one special input field, for example, with an additional click on the drop-down hint, this is no longer just an input field, it's time to create your own widget for it.



To reduce the overall chaos, widgets, like page elements, are combined into the same pages, from which, apparently, the name Page Element is composed.



public class UsersPage {

    public Table usersTable = new Table();

    public InputLine email = new InputLine(By.id("dataEmail"));
    public InputLine password = new InputLine(By.id("dataPassword"));
    public InputLine name = new InputLine(By.id("dataName"));
    public DropdownList gender = new DropdownList(By.id("dataGender"));
    public Checkbox var11 = new Checkbox(By.id("dataSelect11"));
    public Checkbox var12 = new Checkbox(By.id("dataSelect12"));
    public Checkbox var21 = new Checkbox(By.id("dataSelect21"));
    public Checkbox var22 = new Checkbox(By.id("dataSelect22"));
    public Checkbox var23 = new Checkbox(By.id("dataSelect23"));
    public Button save = new Button(By.id("dataSend"));

    public ErrorPopup errorPopup = new ErrorPopup();
    public ModalPopup savePopup = new ModalPopup();
}


To use all of the above created in tests, you need to sequentially refer to the page, widget, action, thus we get the following construction:



    @Test
    public void authAsAdmin() {
        baseRouter
                .authPage().email.fill("test@protei.ru")
                .authPage().password.fill("test")
                .authPage().enter.click()
                .mainPage().logoutButton.shouldExist();
    }


You can add a classic step layer if there is a need for this in your framework (the implementation of the remote library in Java for RobotFramework requires a step class as input, for example), or if you want to add annotations for beautiful reports. We made it an annotation-based generator, if you're interested, write in the comments, we'll tell you.



An example of an authorization step class
public class AuthSteps{

    private BaseRouter baseRouter = new BaseRouter();

    @Step("Sigh in as {mail}")
    public BaseSteps login(String mail, String password) {
        baseRouter
                .authPage().email.fill(mail)
                .authPage().password.fill(password)
                .authPage().enter.click()
                .mainPage().logoutButton.shouldExist();
        return this;
    }
    @Step("Fill E-mail")
    public AuthSteps fillEmail(String email) {
        baseRouter.authPage().email.fill(email);
        return this;
    }
    @Step("Fill password")
    public AuthSteps fillPassword(String password) {
        baseRouter.authPage().password.fill(password);
        return this;
    }
    @Step("Click enter")
    public AuthSteps clickEnter() {
        baseRouter.authPage().enter.click();
        return this;
    }
    @Step("Enter should exist")
    public AuthSteps shouldExistEnter() {
        baseRouter.authPage().enter.shouldExist();
        return this;
    }
    @Step("Logout")
    public AuthSteps logout() {
        baseRouter.mainPage().logoutButton.click()
                .authPage().enter.shouldExist();
        return this;
    }
}
public class BaseRouter {
//    ,      ,     
    public AuthPage authPage() {return page(AuthPage.class);}
    public MainPage mainPage() {return page(MainPage.class);}
    public UsersPage usersPage() {return page(UsersPage.class);}
    public VariantsPage variantsPage() {return page(VariantsPage.class);}
}




These steps are very similar to the steps inside the pages, practically no different. But separating them into separate classes opens up scope for code generation, while the hard link with the corresponding page is not lost. At the same time, if you do not write steps in the page, then the meaning of encapsulation disappears, and if you do not add a class of steps to pageElement, then interaction with the page will still remain separated from the business logic.



, , . . , , , « , ». — , page object , !





It would be wrong to talk about the architecture of a project without touching upon the methods of conveniently operating with test data.



The easiest way is to pass data directly in the test "as is" or in variables. This is fine for sheet architecture, but big projects get messy.



Another method is storing data as objects, it turned out to be the best for us, since it collects all data related to one entity in one place, removing the temptation to mix everything up and use something in the wrong place. In addition, this method has many additional improvements that can be useful on individual projects.



For each entity, a model describing it is created, which in the simplest case contains the names and types of fields, for example, here is the user model:



public class User {
    private Integer id;
    private String mail;
    private String name;
    private String password;
    private Gender gender;

    private boolean check11;
    private boolean check12;
    private boolean check21;
    private boolean check22;
    private boolean check23;

    public enum Gender {
        MALE,
        FEMALE;

        public String getVisibleText() {
            switch (this) {
                case MALE:
                    return "";
                case FEMALE:
                    return "";
            }
            return "";
        }
    }
}


Life hack # 1: if you have a rest-like architecture of client-server interaction (json or xml objects go between the client and the server, and not pieces of unreadable code), then you can google json to <your language> object, probably the generator you need already exists ...



Life hack # 2: if your server developers write in the same object-oriented programming language, then you can use their models.



Life hack # 3: if you are a javist and a company allows you to use third-party libraries, and there are no nervous colleagues around, predicting a lot of pain for heretics who use additional libraries instead of pure and beautiful Java, take Lombok ! Yes, usually IDEcan generate getters, setters, toString and builders. But when comparing our Lombok models and development ones without a Lombok, a profit of hundreds of lines of "empty" code that does not carry business logic for each class is visible. When using a Lombok, you do not have to beat the hands of those who mix fields and getters, setters, the class is easier to read, you can get an idea of ​​the object at once, without scrolling through three screens.



Thus, we have wireframes of objects on which we need to stretch the test data. Data can be stored as final static variables, for example, this can be useful for the main system administrator, from under which other users are created. It is better to use final, so that there is no temptation to change the data in tests, because then the next test, instead of the administrator, can get a “powerless” user, not to mention the parallel execution of tests.



public class Users {
    public static final User admin = User.builder().mail("test@protei.ru").password("test").build();
}


To obtain data that does not affect other tests, you can use the "prototype" pattern and clone your instance in each test. We decided to make it easier: to write a method that randomizes the fields of the class, something like this:



    public static User getUserRandomData() {
        User user = User.builder()
                .mail(getRandomEmail())
                .password(getShortLatinStr())
                .name(getShortLatinStr())
                .gender(getRandomFromEnum(User.Gender.class))
                .check11(getRandomBool())
                .check21(getRandomBool())
                .check22(getRandomBool())
                .check23(getRandomBool())
                .build();
//business-logic: 11 xor 12 must be selected
        if (!user.isCheck11()) user.setCheck12(true); 
        if (user.isCheck11()) user.setCheck12(false);
        return user;
    }


At the same time, methods that create direct randomness should be placed in a separate class, since they will be used in other models as well:







In the method for obtaining a random user, the "builder" pattern was used , which is needed in order not to create a new type of constructor for each required set fields. Instead, of course, you can simply call the desired constructor.



This method of storing data uses the Value Object pattern, based on which you can add any of your wishes, depending on the needs of the project. You can add the storage of objects to the database, and thus prepare the system before the test. You can not randomize users, but load them from the properties files (and another cool library). You can use the same user everywhere, but make the so-called data registry for each type of object, in which the value of the end-to-end counter will be added to the name or other unique field of the object, and the test will always have its own unique testUser_135.



You can write your own Object Storage (google object pool and flyweight), from which you can request the necessary entities at the beginning of the test. The warehouse gives one of its ready-to-work objects and marks it as occupied. At the end of the test, the object is returned to the storage, where it is cleaned as necessary, marked free, and given to the next test. This is done if the operations of creating objects are very resource-intensive, and with this approach, the storage works independently of the tests and can prepare data for the following cases.



Data creation



For user edit cases, you will definitely need a created user that you will be editing, while, in general, the edit test does not care where this user came from. There are several ways to create it:



  • press the buttons with your hands before the test,
  • leave data from the previous test,
  • deploy before the test from backup,
  • create by clicking on the buttons directly in the test,
  • use the API.


All these methods have drawbacks: if before the test you need to enter something in the system manually, then this is a bad test, and therefore they are called autotests, that they should act as independently of human hands as possible.



Using the results of the previous test violates the principle of atomicity and does not allow you to run the test separately, you will have to run the entire batch, and ui tests are not that fast. It is considered good form to write tests so that everyone can be run in splendid isolation and without additional dances. In addition, a bug in the creation of an object that dropped the previous test does not at all guarantee a bug in editing, and in such a design, the editing test will fall next, and it is impossible to find out whether the editing is working.



Using backup (a saved image of the database) with the data necessary for the test is already a less good approach, especially if the backup is deployed automatically or if the tests themselves put the data in the database. However, why this particular object is used in the test is not obvious, data intersection problems can also begin with a large number of tests. Sometimes backup stops working correctly due to an update of the database architecture, for example, if you need to run tests on an old version, and backup already contains new fields. You can fight this by organizing a backup storage for each version of the application. Sometimes backup ceases to be valid again due to the database architecture update - new fields appear regularly, so the backup needs to be updated regularly. And suddenly it may bethat exactly such a single user-from-backup never crashes, and if the user was just created or the name was given to him a little randomly, you would find a bug. This is called the "pesticide effect", the test stops catching bugs, because the application is "accustomed" to the same data and does not fall, and there are no deviations to the side.



If the user is created in the test through clicks on the same interface, then the pesticide decreases and the non-obviousness of the appearance of the user disappears. The disadvantages are similar to using the results of the previous test: the speed is so-so, and if there is a bug in creation, even the smallest (especially a test bug, for example, the locator of the save button will change), then we will not know if editing works.



Finally, another way to create a user is through the http-API from the test, that is, instead of clicking on the buttons, immediately send a request to create the desired user. Thus, the pesticide is reduced as much as possible, it is obvious where the user came from, and the speed of creation is much higher than when clicking on buttons. The disadvantages of this method are that it is not suitable for projects without json or xml in the communication protocol between the client and the server (for example, if developers write using gwt and do not want to write an additional api for testers). It is possible, when using the API, to lose a piece of logic executed by the admin panel, and create an invalid entity. The API can change, causing the tests to fail, but usually this is known, and no one needs changes for the sake of changes, most likely this is new logic that will still have to be checked.It is also possible that there will be a bug at the API level, but not a single method other than ready-made backups is safe from this, so it is better to combine approaches to creating data.



Add a droplet API



Among the methods for preparing data, the http-API for the current needs of a separate test and deploying a backup for additional test data that do not change in tests, for example, icons for objects, so that tests of these objects do not crash when the icons are loaded, are most suitable for us.



To create objects via the API in Java, it turned out to be most convenient to use the restAssured library, although it is not really intended for this. I want to share a couple of found chips, you know more - write!



The first pain is authorization in the system. Its method needs to be selected separately for each project, but there is one common thing - authorization needs to be placed in the request specification, for example:



public class ApiSettings {
    private static String loginEndpoint="/login";

    public static RequestSpecification testApi() {
        RequestSpecBuilder tmp = new RequestSpecBuilder()
                .setBaseUri(testConfig.getSiteUrl())
                .setContentType(ContentType.JSON)
                .setAccept(ContentType.JSON)
                .addFilter(new BeautifulRest())
                .log(LogDetail.ALL);
        Map<String, String> cookies = RestAssured.given().spec(tmp.build())
                .body(admin)
                .post(loginEndpoint).then().statusCode(200).extract().cookies();
        return tmp.addCookies(cookies).build();
    }
}


You can add the ability to save cookies for a specific user, then the number of requests to the server will decrease. The second possible extension of this method is to save the received Cookies for the current test, and toss them to the browser driver, skipping the authorization step. The winnings are seconds, but if you multiply them by the number of tests, you can speed up pretty well!



There is a bun for the gait and beautiful reports, pay attention to the line .addFilter(new BeautifulRest()):



BeautifulRest class


public class BeautifulRest extends AllureRestAssured {
        public BeautifulRest() {}

        public Response filter(FilterableRequestSpecification requestSpec, FilterableResponseSpecification responseSpec, FilterContext filterContext) {
            AllureLifecycle lifecycle = Allure.getLifecycle();
            lifecycle.startStep(UUID.randomUUID().toString(), (new StepResult()).setStatus(Status.PASSED).setName(String.format("%s: %s", requestSpec.getMethod(), requestSpec.getURI())));
            Response response;
            try {
                response = super.filter(requestSpec, responseSpec, filterContext);
            } finally {
                lifecycle.stopStep();
            }
            return response;
        }
}




Object models fit perfectly into restAssured, since the library itself handles serialization and deserialization of models in json / xml (conversion from json / xml formats to an object of a given class).



    @Step("create user")
    public static User createUser(User user) {
        String usersEndpoint = "/user";
        return RestAssured.given().spec(ApiSettings.testApi())
                .when()
                .body(user)
                .post(usersEndpoint)
                .then().log().all()
                .statusCode(200)
                .body("state",containsString("OK"))
                .extract().as(User.class);
    }


If you consider several steps in a row for creating objects, you can notice the identity of the code. To reduce the same code, you can write a general method for creating objects.



    public static Object create(String endpoint, Object model) {
        return RestAssured.given().spec(ApiSettings.testApi())
                .when()
                .body(model)
                .post(endpoint)
                .then().log().all()
                .statusCode(200)
                .body("state",containsString("OK"))
                .extract().as(model.getClass());
    }

    @Step("create user")
    public static User createUser(User user) {
                  create(User.endpoint, user);
    }


Once again about routine operations



As part of checking the editing of an object, we generally do not care how the object appeared in the system - via api or from backup, or was it created by a ui test. Important actions are to find an object, click the "edit" icon on it, clear the fields and fill them in with new values, click "save" and check if all the new values ​​have been saved correctly. All unnecessary information that is not directly related to the test should be removed in separate methods, for example, in the step class.



    @Test
    void checkUserVars() {        
//Arrange
        User userForTest = getUserRandomData();
       
 //         , 
 //      -  , 
 //   ,   
        usersSteps.createUser(userForTest);
        authSteps.login(userForTest);
       
 //Act
        mainMenuSteps
                .clickVariantsMenu();
       
 //Assert
        variantsSteps
                .checkAllVariantsArePresent(userForTest.getVars())
                .checkVariantsCount(userForTest.getVarsCount());
        
//Cleanup
        usersSteps.deleteUser(userForTest);
    }


It is important not to get carried away, as a test consisting only of "complex" actions becomes less readable and more difficult to reproduce without digging through the code.



    @Test
    void authAsAdmin() {
        authSteps.login(Users.admin);
//  ,    .     . 
//   ,   ? 


If practically the same tests appear in the suite, which differ only in the preparation of the data (for example, you need to check that all three types of "different" users can perform the same actions, or there are different types of control objects, for each of which you need to check creation of identical dependent objects, or you need to check the filtering by ten types of object statuses), you still cannot move the repeated parts into a separate method. Not at all if readability is important to you!



Instead, you need to read about data-driven tests, for Java + TestNG it will be something like this:



    @Test(dataProvider = "usersWithDifferentVars")
    void checkUserDifferentVars(User userForTest) {
        //Arrange
        usersSteps.createUser(userForTest);
        authSteps.login(userForTest);
        //Act
        mainMenuSteps
                .clickVariantsMenu();
        //Assert
        variantsSteps
                .checkAllVariantsArePresent(userForTest.getVars())
                .checkVariantsCount(userForTest.getVarsCount());
    }

 //         . 
 // ,   -.
    @DataSupplier(name = "usersWithDifferentVars")
    public Stream<User> usersWithDifferentVars(){
        return Stream.of(
            getUserRandomData().setCheck21(false).setCheck22(false).setCheck23(false),
            getUserRandomData().setCheck21(true).setCheck22(false).setCheck23(false),
            getUserRandomData().setCheck21(false).setCheck22(true).setCheck23(false),
            getUserRandomData().setCheck21(false).setCheck22(false).setCheck23(true),
            getUserRandomData().setCheck21(true).setCheck22(true).setCheck23(false),
            getUserRandomData().setCheck21(true).setCheck22(false).setCheck23(true),
            getUserRandomData().setCheck21(false).setCheck22(true).setCheck23(true),
            getUserRandomData().setCheck21(true).setCheck22(true).setCheck23(true)
        );
    }


It uses the Data Supplier library , which is an add-on over the TestNG Data Provider that allows you to use typed collections instead of Object [] [], but the essence is the same. Thus, we get one test, which is executed as many times as it receives input data.



conclusions



So, to create a large, but convenient project of user interface autotests, you need:



  • Describe all the small widgets encountered in the application,
  • Collect widgets into pages,
  • Create models for all kinds of entities,
  • Add methods to generate all kinds of entities based on models,
  • Consider a suitable method for creating additional entities
  • Optional: generate or collect step files manually,
  • Write tests so that in the section of the main actions of a particular test there are no complex actions, only obvious operations with widgets.


Done, you've created a PageElement-based project with simple methods for storing, generating and preparing data. You now have an architecture that is easily maintainable, manageable, and flexible enough. Both an experienced tester and a beginner June can easily navigate the project, since autotests in the format of user actions are most convenient to read and understand.



Code examples from the article in the form of a finished project are added to the git .



All Articles