My – Javowcy – jesteśmy skazani na paradygmat obiektowy. Nie odbierajcie tego jednak jako złej cechy tego języka. Jak pokazuje instalator 3 Billion Devices Run Java (niezmiennie od początku tego wieku), Java wpisała się do kanonu programistycznego i ciężko znaleźć osobę w IT, która o tym języku nie słyszała.
Od pierwszego wydania Javy minęło już ponad ćwierć wieku i choć ta wciąż ewoluuje, to jednej prawdy zmienić się nie da – Java nie została stworzona z myślą o programowaniu funkcyjnym. To stwierdzenie stało się mniej dotkliwe po aktualizacji Java 8, która wprowadziła wyrażenia lambda oraz strumienie do naszego kodu.
Dziś uniwersum JVM nie ogranicza się tylko do Javy. Powstało wiele wartych uwagi języków programowania. Niektóre z nich zrobiły zwrot w stronę funkcyjnego programowania, co świetnie uzupełnia język Java.
W tym artykule, chciałbym przybliżyć Wam wybrane funkcjonalności języka Kotlin, które urozmaicą oraz ułatwią pisanie testów automatycznych UI na przykładzie testowania webowego.
Kotlin – pierwsze kroki
Wprowadzenie do języka Kotlin zostało już omówione w artykule Rafała Hałasy. Firma Jetbrains przygotowała również bardzo ciekawy wstęp do języka. Tych, którzy nie znają jeszcze Kotlina, zachęcam do zapoznania się zarówno z artykułem jak i tutorialem.
Używanie języka Kotlin w projekcie
Środowisko IntelliJ
W przypadku IntelliJ, większość pracy przy dodawaniu wsparcia dla języka Kotlin wykona za nas IDE. Użytkownik musi dodać plugin Kotlin w IDE i utworzyć w strukturze projektu nowy plik kotlinowy (.kt). IntelliJ automatycznie wykryje użycie Kotlina i zapyta, czy chcemy skonfigurować projekt. Dokonane zostaną automatycznie niezbędne zmiany w dependency (dodanie kotlin-stdlib) i pluginie.
Inne środowisko
Jeżeli używasz innego IDE niż IntelliJ, wykorzystaj poradnik do implementacji Kotlina w projektach Gradle oraz Maven.
Kilka ciekawostek
Język Kotlin powstał z myślą o pełnej kooperacji z językiem Java. Szukając rozwiązania problemu z kodem, możemy równie dobrze wyszukiwać źródła w języku Java jak i Kotlin. Jeżeli skopiujemy kod Java do schowka i wkleimy go w pliku .kt, IntelliJ zapyta nas czy chcemy przetłumaczyć wklejany kod na język Kotlin:
Dlaczego to działa?
Zarówno Java jak i Kotlin kompilują się do bytecode’u Java. Weźmy dla przykładu fragment kodu napisanego w języku Kotlin:
fun main() {
val numbers = listOf(1, 2, 3, 4, 5).map { it * it }
}
IntelliJ umożliwia podgląd bytecode’u, który ten fragment generuje. Opcja znajduję się pod ścieżką:
Tools → Kotlin → Show Kotlin Bytecode
Część bytecode’u jest pokazana poniżej
public final static main()V
L0
LINENUMBER 5 L0
ICONST_5
ANEWARRAY java/lang/Integer
DUP
ICONST_0
ICONST_1
INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;
…
Możemy teraz ten bytecode zdekompilować z powrotem do języka Java. Rezultat jednak nie jest zachęcający. Komputer przetłumaczył instrukcje na język Java, ale styl kodu pozostawia wiele do życzenia:
public static final void main() {
Iterable $this$map$iv = (Iterable)CollectionsKt.listOf(new Integer[]{1, 2, 3, 4, 5});
int $i$f$map = false;
Collection destination$iv$iv = (Collection)(new ArrayList(CollectionsKt.collectionSizeOrDefault($this$map$iv, 10)));
int $i$f$mapTo = false;
Iterator var6 = $this$map$iv.iterator();
while(var6.hasNext()) {
Object item$iv$iv = var6.next();
int it = ((Number)item$iv$iv).intValue();
int var9 = false;
Integer var11 = it * it;
destination$iv$iv.add(var11);
}
List numbers = (List)destination$iv$iv;
}
Niemniej, jest to równoważne (z punktu widzenia JVM) z kodem napisanym w języku Kotlin.
Środowisko Intellij ma dopracowaną zamianę kodu Java → Kotlin, a kod powstały w ten sposób jest w większości przypadków strukturalnie akceptowalny. Klasy Javowe możemy bez problemu używać w kodzie kotlinowym. Ta relacja jest zwrotna, czyli kod Kotlinowy możemy równie dobrze zaimplemenotwać w kodzie Javowym.
Weźmy przykładową klasę napisaną w języku Kotlin:
class Person(private val name: String, private val age: Int) {
fun greet() = println("My name is $name and I am $age ${if (age > 1) "years" else "year"} old.")
}
Teraz możemy użyć ją w pliku .java:
public class Foo {
public static void main(String[] args) {
new Person("Tom", 24).greet();
}
}
Output:
My name is Tom and I am 24 years old.
Funkcjonalności języka Kotlin w praktyce
Przejdźmy do głównej części artykułu. W niej przedstawię dwa podejścia języka Kotlin, które pozwolą na ułatwienie pisania i utrzymania kodu.
Delegate by Locator
Poczekaj aż lodówka będzie widoczna i ją otwórz. Poczekaj aż zobaczysz jajka i je wyciągnij.
Zadając instrukcje do wykonania, często pomijamy oczywiste szczegóły, których jesteśmy pewni, że każdy założy przed wykonaniem powierzonego zadania. Prostym przykładem może być klikanie w elementy na ekranie. Po prostu oczekujemy, że będą widoczne zanim w nie klikniemy.
Używając delegatorów “by” i funkcji wyższego rzędu (ang. higher-order function) możemy utworzyć przyjemny mechanizm do wyszukiwania web elementów oraz decydowania, kiedy mają być one interaktywne:
private val usernameField by Id("username")
private val passwordField by Id("password")
private val loginButton by Xpath(".//button[@type='submit']") {
//poczekaj aż pola username i password zostaną wypełnione
it.isDisplayed && usernameField.getAttribute("value").isNotEmpty() && passwordField.getAttribute("value").isNotEmpty()
}
(…)
fun pressLoginButton() = loginButton.click()
Takie podejście pozwala ukryć oczekiwanie na dostępność web elementów w metodach testowych i skupienie się wyłącznie na logice scenariusza testowego.
Jak to wygląda od kuchni
Założenia wstępne:
Projekt korzysta z bibilioteki Selenium. W projekcie istnieje referencja do WebDrivera, który operuje testami UI. Tworzymy obiekt FluentWait, który posłuży nam do oczekiwania na web elementy.
val wait: FluentWait<WebDriver> = FluentWait(driver)
.withTimeout(Duration.ofSeconds(System.getProperty("wait.timeout")?.toLong() ?: 10L))
.pollingEvery(Duration.ofMillis(200))
.ignoring(NoSuchElementException::class.java)
Obiekt wait nie musi być polem żadnej klasy. Zdefiniowanie go poza ciałem klas oznacza, że dostęp do tego obiektu jest globalny.
Tworzymy abstrakcyjną klasę Locator rozszerzającą klasę ReadOnlyProperty. Klasa Locator zawierać będzie całą logikę dot. operowania na web elementach.
Opis parametrów klasy:
locator – wybrany mechanizm do wyszukiwania elementów (np.: Id, Xpath, CssSelector itd.)
path – wartość ścieżki, która wskazuje na szukany web element
accessRule – funkcja przyjmująca jako parametr web element i zwracająca boolean (warunek na interakcję z web elementem).
abstract class Locator(
private val locator: (String) -> By,
private val path: String,
private val accessRule: (WebElement) -> Boolean
) : ReadOnlyProperty<Any?, WebElement> {
private val element: WebElement by lazy {
wait.until { driver.findElement(locator(path)) }
}
override fun getValue(thisRef: Any?, property: KProperty<*>): WebElement {
wait.until { accessRule(webElement) }
return webElement
}
}
Delegator by lazy oznacza leniwą ewaluację. Pole element nie zostanie obliczone do czasu pierwszego użycia.
Metodę getValue możemy dowolnie rozszerzać o kolejne instrukcje dostępu do web elementu. Są przypadki, w których nie chcemy czekać aż obiekt będzie widoczny. Takie obiekty możemy oznaczyć dowolną adnotacją (np.: @Hidden). Dostęp do adnotacji obiektu delegowanego uzyskamy przez argument property.
Pozostaje już tylko utworzenie potrzebnych nam klas rozszerzających Locator wedle potrzeb:
class Xpath(
path: String,
// poczekaj aż będzie widoczny
accessRule: (WebElement) -> Boolean = { it.isDisplayed }
) : Locator(By::xpath, path, accessRule)
class Id(
id: String,
//zwracaj zawsze
accessRule: (WebElement) -> Boolean = { true }
) : Locator(By::id, id, accessRule)
I użycie ich tak jak w przykładzie powyżej.
Integracja z Javą
Tak jak wspomniałem wcześniej, kod kotlinowy możemy wykorzystać w klasie Javowej. Język Java nie dysponuje jednak wyrażeniem delegatorów by. Pomysłem na integrację jest zdefiniowanie web elementów w klasie .kt i rozszerzenie klasy .java o tę klasę:
class MyScreenPageObject extends MyScreenKotlinPageObject {
(…)
public void clickLoginButton() {
getLoginButton().click();
}
}
Gettery do zdefiniowanych przez delegatory pól zostały automatycznie wygenerowane na potrzeby operowania w języku Java, ale cała logika dostępów i ewaluacji elementów została nienaruszona.
Pamiętaj, że w tym przypadku modyfikator dostępu w klasie .kt nie może być ustalony jako private.
Wszystkie klasy kotlinowe (oprócz abstrakcyjnych) są w domyślne final, czyli nie można po nich dziedziczyć. W tym przypadku wymagane jest oznaczenie klasy .kt słowem open (odwrotność znaczenia final).
Dla porównania ten sam kod – Vanilla Java + Selenium (w uproszczeniu):
class MyScreen {
(…)
public void clickLoginButton() {
WebElement usernameField = getWait().until(ExpectedConditions.visibilityOfElementLocated(By.id("username")));
WebElement passwordField = getWait().until(ExpectedConditions.visibilityOfElementLocated(By.id("password")));
getWait().until(driver -> !usernameField.getAttribute("value").isEmpty()
&&
!passwordField.getAttribute("value").isEmpty());
getWait().until(ExpectedConditions.visibilityOfElementLocated(By.xpath(".//button[@type='submit']"))).click();
}
}
Robot pattern
Na stronie logowania, wpisz nazwę użytkownika.
Na stronie logowania, wpisz hasło.
Na stronie logowania, kliknij przycisk Login.
Wykonując kroki w obrębie jednej strony aplikacji webowej, znamy kontekst. Odwoływanie się za każdym razem do obiektu w celu wywołania metody może być uciążliwe, szczególnie kiedy kroków jest kilkanaście.
loginPage.fillCredentialsForUser("blogersii001);
loginPage.pressLoginButton();
loginPage.verifyUserIsSuccessfullyLoggedIn();
Pomysłem na usprawnienie jest ponownie wykorzystanie funkcji wyższego rzędu i stworzenie przyjemnego mechanizmu tworzenia zakresów (ang. Scope), w których kontekst jest znany.
@Test
fun `user can access website and log in`() {
mainPage {
verifyHeaderTitleIsEqualTo("<bloger_sii/>")
clickOnSignInButton()
}
loginPage {
fillCredentialsForUser("blogersii001")
pressLoginButton()
verifyUserIsSuccessfullyLoggedIn()
}
}
Funkcje w języku Kotlin mogą być nazywane standardowo za pomocą camelCase lub za pomocą dowolnego ciągu znaków oznaczonego pomiędzy znakami ''
(Back quote)
Jak to wygląda od kuchni
Powyżej pokazałem, że etapy testów są podzielone na zakresy, które nazywane są robotami. Każdy robot posiada odniesienie do obiektu PageObject, dla którego wykonywany jest zestaw funkcji. Jedynymi dostępnymi funkcjami w każdym robocie są funkcje, które zostały zdefiniowane w klasie PageObject, dla której robot został stworzony.
Sekretem tego podejścia jest funkcja – robot, która przyjmuje metody jednej konkretnej strony (zdefiniowane w PageObject) i wykonuje je w kolejności ich podania:
fun loginPage(func: LoginPage.() -> Unit) {
LoginPage().func()
}
Argumentem funkcji loginPage jest funkcja, która występuje w klasie LoginPage i zwraca wartość Unit (odpowiednik void w Java). Funkcja loginPage tworzy nowy obiekt klasy LoginPage i aplikuje do niego wszystkie podane jako argument metody.
Analogicznie powstanie robot dla klasy MainPage z przykładu powyżej.
Roboty mogą być dostępne globalnie. Zdefiniuj je w osobnym pliku (np.: robots.kt) dla czystości projektu.
Integracja z Javą
Java nie wspiera mechanizmu robotów jako funkcji wyższego rzędu. To, co możemy zrobić w tej sytuacji, to stworzenie funkcji-robotów dla już istniejących klas PageObject napisanych w języku Java:
public class AddBlogPage {
(...)
public void addBlogTitle(String blogTitle) {
(...)
}
public void submitBlogArticle() {
(...)
}
}
Dla klasy Java tworzymy funkcję – robota:
fun addBlogPage(func: AddBlogPage.() -> Unit) {
AddBlogPage().func()
}
I wykorzystujemy w testach napisanych w Kotlinie:
@Test
fun `Integrate Java class in Kotlin robot test`() {
addBlogPage {
addBlogTitle("Blogersii - Kotlin")
submitBlogArticle()
}
}
Ktoś może zapytać:
Okej, ale po co to robić, skoro znamy Builder design pattern i method chaining, które są dostępne w zwykłej Javie?
Wykonanie podobnej do robotów struktury jest możliwe w języku Java, ale niesie za sobą problemy.
Żeby łączyć metody w łańcuchy, musza one zwracać referencję do obiektu, z którego są wywoływane. Każda metoda, którą chcemy łączyć, musi mieć zmieniony zwracany typ z void na własną klasę. W przypadku Kotlin refaktoryzacja kodu nie jest wymagana. Usprawnienie dodajemy bez zmian w ciele klas PageObject.
Przykład łączenia metod w łańcuchy – vanilla Java:
public class AddBlogPage {
(...)
public AddBlogPage addBlogTitle(String blogTitle) {
(...)
return this;
}
public AddBlogPage submitBlogArticle() {
(...)
return this;
}
}
Użycie w testach:
@Test
void javaTest(){
new AddBlogPage()
.addBlogTitle("Blogersii - Kotlin")
.submitBlogArticle();
}
Drugim podejściem, nieco mniej znanym i stosowanym, do osiągnięcia podobnego rezultatu w Java jest inicjalizowanie obiektów przy użyciu podwójnych nawiasów (ang. Double brace initializaiton). W tym przypadku nie musimy zmieniać typów zwracanych w klasie AddBlogPage, ponieważ referencja do klasy w środku nawiasów odbywa się przez wartość this.
Użycie w testach:
@Test
void javaTest(){
new AddBlogPage(){{
addBlogTitle("Blogersii - Kotlin");
submitBlogArticle();
}};
}
To podejście niesie za sobą ryzyko. Taki sposób inicjalizacji generuje za każdym razem wewnątrz dodatkową klasę anonimową, która rozszerza po klasie PageObject. Choć używanie tego typu inicjalizacji w śladowych ilościach nie utrudni pracy Garbage Collectora, to nagminne inicjalizowanie obiektów w ten sposób, może doprowadzić do wycieków pamięci.
Podsumowanie
Język Kotlin wciąż się rozwija. Pokazanie wszystkich ciekawostek i usprawnień jakie oferuje, z pewnością wykracza poza treść jednego artykułu.
Mam nadzieję, że artykuł wzmocni w czytelnikach zainteresowanie językiem Kotlin, a zaproponowane rozwiązania (być może po twórczych modyfikacjach) znajdą zastosowanie w Waszych projektach.
Artykuł powstał na bazie rozwijanego przeze mnie hobbystycznie frameworku do testów UI-owych.
Kod źródłowy oraz przykłady znajdują się w publicznym repozytorium.
Sprawdź również play.kotlinlang.org, aby wypróbować Kotlin online bez instalacji.
Zostaw komentarz