When I got acquainted with Kotlin DSL, I thought: great stuff, it’s a pity in product development, it’s not useful. However, I was wrong: he helped us to do a very concise and elegant way written by the End-to-end UI tests in Android.
First, a little context about our service, so that you can understand why we made these or other decisions.
We help job seekers and employers find each other:
In order to simulate real user scenarios and make sure that the application works correctly on them, we need to create all this test data on the server. You will say: “So create test employers and applicants in advance, and then in the tests you should work with them”. But there are a couple of problems here:
End-to-end tests run on test benches. They are almost fighting environment, but there is no real data. In this regard, when adding new data indexing occurs almost instantly.
To add data to the stand, we use special fixture methods. They add data straight to the database and instantly perform indexing:
interface TestFixtureUserApi { @POST("fx/employer/create") fun createEmployerUser(@Body employer: TestEmployer): Call<TestEmployer> }
Fixtures are only available from the local network and only for test benches. Methods are called from the test immediately before starting the starting Activity.
Here we have reached the most juicy. How are the data set for the test?
initialisation{ applicant { resume { title = "Resume for similar Vacancy" isOptional = true resumeStatus = ResumeStatus.APPROVED } resume { title = "Some other Resume" } } employer { vacancy { title = "Resume for similar Vacancy" } vacancy { title = "Resume for similar Vacancy" description = "Working hard" } vacancy { title = "Resume for similar Vacancy" description = "Working very hard" } } }
In the initialisation block, we start the entities necessary for the test: in the example above, we created one applicant with two resumes, as well as one employer who provided several vacancies.
To eliminate errors associated with the intersection of test data, we generate a unique identifier for the test and for each entity.
Relationships between entities
What is the main limitation when working with DSL? Because of its tree, it is quite difficult to build connections between different branches of a tree.
For example, in our application for applicants there is a section “Suitable vacancies for resumes”. In order for vacancies to appear on this list, we need to set them so that they are linked to the current user's resume.
initialisation { applicant { resume { title = "TEST_VACANCY_$uniqueTestId" } } employer { vacancy { title = "TEST_VACANCY_$uniqueTestId" } } }
For this, a unique test identifier is used. Thus, when working with an application, the specified vacancies are recommended for this resume. In addition, it is important to note that no other vacancies will appear in this list.
Initialization of the same type of data
And what if you need to do a lot of jobs? Is every block so copied? Of course no! We make a method with a block of vacancies, which indicates the required number of vacancies and a transformer, in order to diversify them depending on the unique identifier.
initialisation { employer { vacancyBlock { size = 10 transformer = { it.also { vacancyDsl -> vacancyDsl.description = "Some description with text ${vacancyDsl.uniqueVacancyId}" } } } } }
In the vacancyBlock block, we indicate how many clones of vacancies we need to create and how to transform them depending on the sequence number.
Work with data in the test
During the test, working with data becomes very simple. All data created by us is available to us. In our implementation, they are stored in special wrappers for collections. You can get data from them both by the order number of the task (vacancies [0]), by the tag that can be set in dsl (vacancies [“my vacancy”]), and by shortcuts (vacancies.first ()
class TaggedItemContainer<T>( private val items: MutableList<TaggedItem<T>> ) { operator fun get(index: Int): T { return items[index].data } operator fun get(tag: String): T { return items.first { it.tag == tag }.data } operator fun plusAssign(item: TaggedItem<T>) { items += item } fun forEach(action: (T) -> Unit) { for (item in items) action.invoke(item.data) } fun first(): T { return items[0].data } fun second(): T { return items[1].data } fun third(): T { return items[2].data } fun last(): T { return items[items.size - 1].data } }
In almost 100% of cases, when writing tests, we use the methods first () and second (), the rest we keep for flexibility. The following is an example of a test with initialization and with steps on Kakao
initialisation { applicant { resume { title = "TEST_VACANCY_$uniqueTestId" } } }.run { mainScreen { positionField { click() } jobPositionScreen { positionEntry(vacancies.first().title) } searchButton { click() } } }
What does not fit in DSL
Is all data fit in DSL? Our goal was to keep the DSL as concise and simple as possible. In our implementation, due to the fact that the order of assignment of applicants and employers is not important, it is impossible to accommodate their relationship - responses.
The creation of responses is already performed in the subsequent block of operations on entities already created on the server.
As you understood from the article, the algorithm for setting the test data and running the test is as follows:
Parsing data from the initialisation block
What is there magic going on? Consider how the high-level element TestCaseDsl is constructed:
@TestCaseDslMarker class TestCaseDsl { val applicants = mutableListOf<ApplicantDsl>() val employers = mutableListOf<EmployerDsl>() val uniqueTestId = CommonUtils.unique fun applicant(block: ApplicantDsl.() -> Unit = {}) { val applicantDsl = ApplicantDsl( uniqueTestId, uniqueApplicantId = CommonUtils.unique applicantDsl.block() applicants += applicantDsl } fun employer(block: EmployerDsl.() -> Unit = {}) { val employerDsl = EmployerDsl( uniqueTestId = uniqueTestId, uniqueEmployerId = CommonUtils.unique employerDsl.block() employers += employerDsl } }
In the applicant method, we create ApplicantDsl.
@TestCaseDslMarker class ApplicantDsl( val uniqueTestId: String, val uniqueApplicantId: String, var tag: String? = null, var login: String? = null, var password: String? = null, var firstName: String? = null, var middleName: String? = null, var lastName: String? = null, var email: String? = null, var siteId: Int? = null, var areaId: Int? = null, var resumeViewLimit: Int? = null, var isMailingSubscription: Boolean? = null ) { val resumes = mutableListOf<ResumeDsl>() fun resume(block: ResumeDsl.() -> Unit = {}) { val resumeDslBuilder = ResumeDsl( uniqueTestId = uniqueTestId, uniqueApplicantId = uniqueApplicantId, uniqueResumeId = CommonUtils.unique ) resumeDslBuilder.apply(block) this.resumes += resumeDslBuilder } }
Then we perform operations on it from the block block: ApplicantDsl. () -> Unit. It is this design that allows us to easily operate ApplicantDsl fields in our DSL.
Please note that uniqueTestId and uniqueApplicantId (unique identifiers for connecting entities to each other) at the time of block execution are already defined and we can refer to them.
The initialisation block from the inside is similar:
fun initialisation(block: TestCaseDsl.() -> Unit): Initialisation { val testCaseDsl = TestCaseDsl().apply(block) val testCase = TestCaseCreator.create(testCaseDsl) return Initialisation(testCase) }
We create a test, apply block actions to it, then use TestCaseCreator to create data on the server and stack it into a collection. The TestCaseCreator.create () function is quite simple - we iterate over the data and create it on the server.
Some tests are very similar and differ only in the input data and the ways to control their display (for example, when a different currency is indicated in the vacancy).
In our case, there were not many such tests, and we decided not to overload DSL with a special syntax
In the days before DSL, data indexation took a long time, and to save time, we did a lot of tests in one class and created all the data in a static block.
Do not do this - it will make it impossible for you to restart a fallen test. The fact is that during the launch of the fallen test we could change the original data on the server. For example, we could add a job to your favorites. Then, when you restart the test, clicking on the asterisk will lead to the opposite, to remove the vacancy from the list of favorites, and this is a behavior that we do not expect.
This way of setting the test data made working with tests very simple:
When writing tests, there is no need to think about whether there is a server and in what order to initialize the data;
All entities that can be set on the server easily drop out at IDE prompts;
A single method of initialization and communication of data between them appeared.
Materials on the topic
If you are interested in our approach to UI testing, then before starting, I suggest to get acquainted with the following materials:
What's next
This article is the first of a series of pro tools and high-level frameworks for writing and supporting Android UI tests. As new parts are released, I will link them to this article.
Source: https://habr.com/ru/post/455042/
All Articles