Spis treści
Waity w Selenium.
Cześć! Pewnie nie raz spotkałeś się z sytuacją, że Twój automatyczny test świetnie działał, gdy pisałeś go na swoim komputerze, ale jakoś, nie wiadomo czemu, nie chciał działać puszczony na środowisku CI/CD (jak np. Jenkins/TeamCity). Dostawałeś błąd, że nie znaleziono elementu, albo pomimo tego, że ustawiłeś timeout na 10 sekund, Twój test czekał ponad 1 minutę 😮
Jeśli tak się stało, to mam dla Ciebie dobrą wiadomość – pomogę Ci zrozumieć co się wydarzyło. Cały sekret tkwi w waitach. W artykule opowiem Ci jak dokładnie działają waity w Selenium oraz dowiesz się jakie są ich rodzaje i który wait warto zastosować.
Rodzaje waitów w Selenium.
W Selenium wyróżniamy dwa podstawowe rodzaje waitów:
- implicit waity, są to tzw. ‘waity niejawne’. Dlaczego niejawne? Ponieważ działają automatycznie i nie masz nad nimi kontroli. Nie możesz zdefiniować np. w której sytuacji test ma nie czekać a w której czekać dłużej itd. Twój jedyny wpływ kończy się na ustawieniu MAKSYMALNEGO CZASU CZEKANIA na element
- explicit waity, czyli tzw. ‘waity jawne’. W tym przypadku masz większą kontrolę nad tym na co konkretnie chcesz czekać, a podany czas definiujesz każdorazowo do sytuacji. Niestety tak jak implicit jest waitem globalnym, który ustawiasz jeden raz, tak w przypadku explicit waitów będziesz musiał zdefiniować je dla każdej sytuacji, gdzie czekanie na element będzie konieczne.
Po takim opisie możesz pomyśleć, że sprawa jest prosta – ustawiasz globalny wait (implicit na X sekund) i dodajesz explicit waity, wszędzie tam, gdzie nie sprawdził się wait globalny. Niestety mam dla Ciebie złą wiadomość, wg autorów Selenium musisz wybrać tylko jedną z opcji – albo stosujesz implicit albo explicit wait.
W dalszej części artykułu dowiesz się co stanie się z Twoimi testami, jeśli zignorujesz tę uwagę od autorów.
Implicit Waity w Selenium.
Implicit Waity najczęściej będziemy definiować w bazowej klasie testowej wraz z uruchamianiem się wybranej przeglądarki. W Selenium wyróżniamy 3 rodzaje implicit waitów:
- scriptTimeout – pozwala na ustawienie maksymalnego czasu oczekiwania na skrypt JavaScriptowy. Jest ważnym parametrem w debugowaniu problemów z wydajnością aplikacji. Jeśli ustawisz tutaj zbyt dużą wartość, np. 5 minut, może umknąć Ci informacja o tym, że niektóre skrypty wykonują się zbyt długo i należałoby popracować nad ich optymalizacją (dev). Dlatego rekomenduję Ci wartość od kilkunastu sekund do max 1 minuty
- pageLoadTimeout – określa maksymalny czas oczekiwania na załadowanie się strony. Chodzi tu niestety jedynie o ten loader przy nazwie strony, który określa czy wszystkie pliki skryptowe, assety i kod html się załadowały. Niestety większość nowoczesnych stron ładuje się asynchronicznie, tzn., że po pobraniu tych zasobów strona wciąż może wczytywać jakieś treści bez wyświetlania wspomnianego loadera. Dlatego ustawienie nawet bardzo dużej wartości nie uchroni Cię we wspomnianej sytuacji przed nieznalezieniem elementu
- implicitlyWait – ostatni rodzaj określa jak długo test ma czekać na znalezienie elementu już po załadowaniu się strony. Jego domyślna wartość wynosi 0 milisekund. I to jest właśnie ten wait, który może przysporzyć Ci problemów, jeśli postanowisz nadać mu wartość >0ms i jednocześnie korzystać z explicit waitów. Jeśli nie planujesz korzystać z explicit waitów to warto tutaj dać czas z przedziału od kilku sekund do max 1 minuty.
Poniżej przykładowa klasa bazowa testowa z ustawieniem wszystkich 3 implicit waitów:
import org.openqa.selenium.Dimension;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import java.time.Duration;
public abstract class BaseTest {
public static final int MAX_TIMEOUT_SECONDS = 20;
protected WebDriver driver;
private final Dimension FULL_HD = new Dimension(1920, 1080);
@BeforeMethod
public void startChrome() {
ChromeOptions options = new ChromeOptions();
options.addArguments("--accept-lang=en-GB");
driver = new ChromeDriver(options);
driver.manage().timeouts().scriptTimeout(Duration.ofSeconds(MAX_TIMEOUT_SECONDS));
driver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(MAX_TIMEOUT_SECONDS));
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(5));
driver.manage().window().setSize(FULL_HD);
driver.get(ConfigurationLoader.getInstance().getBaseURL());
}
@AfterMethod
public void closeDriver() {
driver.quit();
}
}
Explicit Waity w Selenium.
Chcąc uzmysłowić sobie jak działają testy Selenium warto porównać je do kogoś, kto jest uwięziony pod wodą i każda sekunda blokuje go przed wydostaniem się na powierzchnię. Test chce jak najszybciej wykonać swoją pracę, nieważne z jakim rezultatem. W sytuacji, gdy po wykonaniu kliknięcia nie widzi jakiegoś dodatkowego przycisku, to po prostu odpuszcza i wychodzi z wody. Niestety sytuacja nie jest zero-jedynkowa. Czasem test przejdzie, bo przycisk pojawi się wystarczająco szybko, a czasami ten przycisk potrzebuje więcej czasu i test nie przechodzi. W rezultacie otrzymujemy właśnie tzw. ‘flaky testy’, czyli testy, które raz działają, a raz nie na tej samej wersji testowanej aplikacji.
Co możemy zrobić w takiej sytuacji? Możemy np. dodać instrukcję w stylu ‘czekaj maksymalnie X sekund aż element Y pojawi się na stronie w stanie Z’. Dzięki takiemu rozwiązaniu test wstrzyma się od dalszego wykonywania testu w momencie, gdy nie widzi od razu kolejnego elementu, a sam wynik testu stanie się bardziej stabilny.
W celu wykonania takiego jawnego czekania konieczne jest zadeklarowanie obiektu WebDriverWait oraz przekazania mu referencji do przeglądarki(driver), a także obiektu typu Duration, który opisuje jak długo zamierzamy pozwolić testowi czekać na określone zdarzenie.
WebDriverWait wait = new WebDriverWait(driver,
Duration.ofSeconds(MAXIMUM_TIMEOUT_IN_SECONDS));
W następnym kroku uruchamiamy metodę until() na utworzonym obiekcie typu WebDriverWait, gdzie jako argument precyzujemy określone zdarzenie(ExpectedCondition) na jakie chcemy czekać:
wait.until(ExpectedConditions.invisibilityOfElementLocated(by));
W powyższym przykładzie jest to oczekiwanie na zniknięcie elementu znajdującego się w lokalizacji określonej obiektem By.
Aby ograniczyć liczbę powtórzeń w kodzie, dobrą praktyką jest trzymanie tego typu waitów w jednym miejscu. Najprościej można to osiągnąć poprzez opakowanie waitów w metody statyczne w klasie typu Utils, dzięki czemu łatwo możemy wywoływać waity z dowolnego miejsca kodu:
import org.openqa.selenium.*;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.time.Duration;
public class WaitUtils {
private static final int MAXIMUM_TIMEOUT_IN_SECONDS = 5;
private WaitUtils() {
}
public static void waitUntilInvisibilityOfElementLocatedBy(WebDriver driver, By by) {
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(MAXIMUM_TIMEOUT_IN_SECONDS));
wait.until(ExpectedConditions.invisibilityOfElementLocated(by));
}
}
Powyższy wait możemy następnie wykorzystać przykładowo w metodzie, gdzie oczekujemy, że po kliknięciu przycisku, ten powinien zniknąć, a w jego miejsce powinno pojawić się boczne menu strony:
public BoardMenuPage openBoardMenu() {
if (FieldUtils.isElementDisplayed(driver, By.xpath(MORE_BUTTON_XPATH))) {
FieldUtils.safeClickButton(driver, By.xpath(MORE_BUTTON_XPATH));
WaitUtils.waitUntilInvisibilityOfElementLocatedBy(driver, By.xpath(MORE_BUTTON_XPATH));
}
return new BoardMenuPage(driver);
}
Dodatkowe parametry czekania w WebDriverWait.
- polling – czyli jak często (co ile jednostek czasu) chcemy sprawdzać czy warunek na który czekamy się już spełnił
- ignoring – czyli jakie wyjątki chcemy ignorować w trakcie czekania np. NoSuchElementException.class, w celu ignorowania sytuacji, gdzie brak elementu w drzewie DOM (bo my przykładowo czekamy na pojawienie się elementu max 10 sekund, więc do tego czasu go może nie być w tej strukturze)
- withMessage – pozwala określić wiadomość jaka pojawi się po timeoutcie
public static void waitUntilInvisibilityOfElementLocatedBy(WebDriver driver, By by) {
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(MAXIMUM_TIMEOUT_IN_SECONDS));
wait.pollingEvery(Duration.ofMillis(200));
wait.ignoring(NoSuchElementException.class)
.withMessage("Oh no, waiting has failed!");
wait.until(ExpectedConditions.invisibilityOfElementLocated(by));
}
Zasady jednoczesnego stosowania explicit i implicit waitów w Selenium.
Jak podają autorzy selenium w swoim artykule jest tak naprawdę jedna prosta zasada – nie mieszać explicit i implicit waitów, ponieważ ‘może to prowadzić do nieprzewidywalnych czasów czekania’.
O co chodzi?
Wyobraź sobie, że ustawiłeś implicitlyWait na 30 sekund. W tym momencie, przed każdorazowym skorzystaniem z dowolnego elementu Selenium będzie czekać do 30 sekund aż ten element się znajdzie w takim stanie jak go zadeklarowałeś. Problem najczęściej pojawia się, gdy przykładowo masz sytuację, że ten element powinien zniknąć, aby np. zrobić miejsce na inny element, np. pasek boczny. W momencie, gdy zgodnie z zachowaniem aplikacji zadeklarujesz, że chcesz, aby element zniknął i dodajesz czekanie typu Explicit na zniknięcie tego elementu w czasie np. 5 sekund, to w takiej sytuacji zachowanie Selenium wygląda następująco:
- Znajdź element X (ten który ma zniknąć)
- Odpalenie implicit waitu do 30 sekund
- Nie udało się odnaleźć od razu elementu
- Selenium czeka do 30 sekund, aż element się pojawi
- Po 30 sekundach timeout -> Selenium odpuszcza dalsze czekanie
- Odpala się explcit wait na 5 sekund
- Nie znajduje elementu przy 1 pollingu
- Explicit kończy się w czasie ~0 sekund
- Test kontynuuje pracę
Jak widzisz z przedstawionego wyżej schematu – mimo że oczekiwałbyś maksymalnie 5 sekund na zniknięcie elementu, to przez niezerowy implicitlyWait czas ten wydłużył się do aż 30+ sekund. Im więcej masz takich miejsc w kodzie tym dłuższe będzie wykonywanie całych suit testowych. Dlatego najlepszą praktyką jest zerowy implicitlyWait (nie dotyczy to oczywiście scriptTimeout i pageLoadTimeout które powinny być niezerowe) oraz używanie explicit waity, LUB niezerowy implicitlyWait i nieużywanie explicit waitów w ogóle.
Gdybyś już jednak połączył oba te waity i nie chciał dokonywać zmian w swoim kodzie to istnieje proste rozwiązanie, które co prawda wciąż łamie wspomnianą zasadę, ale ogranicza szkody wynikłe z zastosowania obu waitów.
Rozwiązaniem tym jest obniżanie implicit waita, tuż przed wykonaniem explicit waitu. Czyli dla przykładu masz implicitlyWait = 30 sekund, to przed wywołaniem wait.until() dla explicit waitu zmniejszasz ten czas do np. 100-500 milisekund, a po zakończeniu wait.until() wracasz z powrotem do wartości domyślnej tj. 30 sekund. Może to wyglądać tak jak poniżej:
public class WaitUtils {
private static final int LOWEST_IMPLICIT_WAIT_IN_MILLISECONDS = 100;
private WaitUtils() {
}
public static void waitUntilInvisibilityOfElementLocatedBy(WebDriver driver, By by) {
DriverUtils.setImplicitTimeOut(driver, LOWEST_IMPLICIT_WAIT_IN_MILLISECONDS);
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(MAXIMUM_TIMEOUT_IN_SECONDS));
wait.pollingEvery(Duration.ofMillis(200));
wait.ignoring(NoSuchElementException.class)
.withMessage("Oh no, waiting has failed!");
wait.until(ExpectedConditions.invisibilityOfElementLocated(by));
DriverUtils.setDefaultImplicitTimeout(driver);
}
Dzięki takiemu rozwiązaniu ogólny czas wykonywania testów będzie przybliżony do sytuacji takiej, w której nie miałbyś zadeklarowanej wartości dla implicitlyWait (~0s). Pamiętaj jednak, że najlepszą praktyką jest nie mieszanie obu waitów ze sobą!
Jak utworzyć własny Explicit Wait?
Tak jak wspomniałem we wcześniejszej części artykułu, do utworzenia Explicit Waitu będziesz potrzebować obiektu typu WebDriverWait. Następnie na tym obiekcie wywołujesz metodę until(), która jako argument przyjmuje specjalny typ danych – ExpectedCondition<Boolean>.
ExpectedCondition.
ExpectedCondition<Boolean> jest interfejsem Javowym, który rozszerza interfejs Function<F,T>, posiadającym funkcję T apply(F input). Czyli mamy tu do czynienia z typem danych, który wymusza zastosowanie metody apply, która może przyjąć dowolne parametry i dowolny typ zwrotny. W przypadku ExpectedCondition argumentem jest referencja WebDrivera oraz dowolny typ zwrotny, który najczęściej przyjmuje wyrażenie logiczne Boolean. Logika każdego obiektu implementującego ExpectedCondition jest następująca:
- Utworzenie obiektu dla odpowiedniego warunku, np. pojawienia się elementu
- Wywołanie metody apply() w trakcie pollingu
- Sprawdzenie warunku w ramach metody apply()
- Jeśli warunek nie został spełniony zwróć false, a jeśli został spełniony zwróć true
- Dla false następuje kolejny polling a dla true kończy się proces czekania
- Jeśli po czasie zadeklarowanego timeoutu nie otrzymamy true z metody apply to kończymy czekanie z wiadomością Timeoutu.
Aby skorzystać z predefiniowanych ExpectedCondition możemy skorzystać z klasy ExpectedConditions, gdzie po kropce możemy wybrać interesujący nas sposób czekania. Do naszej dyspozycji oddano m.in. takie opcje:
- elementToBeClickable – czekanie, aż element będzie klikalny
- visibiltiyOfElementLocated – czekanie, aż element będzie widoczny
- attributeToBe – czekanie, aż element będzie posiadał wybrany atrybut w danej wartości
- titleContains – czekanie, aż strona będzie posiadała wybrany tytuł
- textToBePresentInElementLocated – czekania, aż w elemencie pojawi się wskazany tekst
- oraz wiele więcej!
Załóżmy, że spośród tak wielu opcji nic nie rozwiąże Twojej unikatowej sytuacji. Czy to znaczy, że to już koniec? Na szczęcie nie! Jak pamiętasz z wcześniejszego tekstu ExpectedCondition to interfejs w związku z czym, możesz utworzyć własny warunek czekania implementując go w swojej klasie.
Definicja własnego ExpectedCondition.
Kiedy pierwszy raz wejdziesz na główną stronę trello (najlepiej w trybie incognito) to pojawi Ci się przycisk do zaakceptowania ciasteczek. Problem jest w tym, że przycisk znajduje się w ruchu i w teście automatycznym łatwo go przestrzelić i trafić w pasek nawigacyjny. Przez co w rezultacie otrzymujemy błąd. Niestety atrybuty tego elementu niespecjalnie się zmieniają przed i po animacji w związku z czym trudno jest wykorzystać jakikolwiek z predefiniowanych ExpectedCondition. W takiej sytuacji pozostaje właśnie napisanie własnego.
W tym celu wystarczy utworzyć dowolną klasę javową, a następnie zaimplementować (implements) interfejs ExpectedCondition. Po zaimplementowaniu interfejsu konieczne jest zaimplementowanie metody apply. To właśnie w tej metodzie trzeba zawrzeć logikę, która pomoże określić czy już wydarzyło się zdarzenie, na które czekamy. W poniższym przykładzie metoda ta będzie wyczekiwać sytuacji, kiedy element przestanie się poruszać. Czyli jego pozycja przy najnowszym sprawdzeniu będzie dokładnie taka sama jak przy poprzednim sprawdzeniu. Jak pamiętasz każde sprawdzenie odbywa się co zdefiniowany czas określony parametrem ‘polling’.
import org.openqa.selenium.By;
import org.openqa.selenium.Point;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.support.ui.ExpectedCondition;
public class ElementPositionNotChanging implements ExpectedCondition {
By by;
Point point;
public ElementPositionNotChanging(By by) {
this.by = by;
}
@Override
public Boolean apply(WebDriver driver) {
Point currentPoint = driver.findElement(by).getLocation();
if (currentPoint.equals(point)){
return true;
}else {
point = currentPoint;
return false;
}
}
@Override
public String toString() {
return "Element was still moving after timeout. Last position was %s".formatted(point);
}
}
Aby wykorzystać taki wait wystarczy go wywołać w ramach metody wait.until() :
public static void waitUntilElementStopMoving(WebDriver driver, By by) {
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(MAXIMUM_TIMEOUT_IN_SECONDS));
wait.until(new ElementPositionNotChanging(by));
}
FluentWait.
Co to jest Fluent Wait?
FluentWait umożliwia łatwiejsze zarządzanie czekaniem. Tak jak wspominałem w wcześniejszej części artykułu to właśnie FluentWait pozwala zdefiniować polling czy wskazać wyjątki do ignorowania. Dzięki temu, że WebDriverWait dziedziczy po FluentWaitcie to możemy skorzystać z tych dobrodziejstw pracując z samym WebDriverWaitem.
Natomiast FluentWait jest o tyle potężniejszym narzędziem, że umożliwia czekanie na dowolne zdarzenie, które może być również metodą, niemająca nic wspólnego z interfejsem ExpectedCondition.
Jakie korzyści daje Fluent Wait?
Pozwól, że pokażę Ci to na poniższych przykładach.
Załóżmy, że masz klasę bazową dla wszystkich swoich Page Objectów. Ustawiłeś 0 sekundowy implicitlyWait i musisz teraz upewnić się, że każdy z Twoich Page Objectów się dobrze załaduje, bo inaczej nie znajdzie Ci elementu, którego potrzebujesz. Mógłbyś spróbować dodawać czekanie w konstruktorze każdego takiego PO z osobna. Ale wtedy pojawia się ryzyko, że czasem zapomnisz dodać takiego waita. Łatwo możesz też doprowadzić do sytuacji, że każdy PO będzie miał inny czas na czekanie na załadowanie się strony – np. jeden 10 sekund, inny 20, a jeszcze inny tylko 5. Powstanie przez to bałagan i ciężko będzie nad nim zapanować. Zamiast tego, to co możesz zrobić to w metodzie BasePage zadeklarować metodę abstrakcyjną – prawda/fałsz, która zwróci Ci prawdę, jeśli Twoja strona się załaduje. Przez to, że będzie to metoda abstrakcyjna to wymusisz na każdym Page Objectcie, aby zawsze miał te metodę zaimplementowaną i wyeliminujesz tym samym czynnik ludzki.
Twoja klasa BasePage będzie mogła wyglądać następująco:
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.StaleElementReferenceException;
import org.openqa.selenium.TimeoutException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.ui.FluentWait;
import java.time.Duration;
public abstract class BasePage {
protected final WebDriver driver;
public BasePage(WebDriver driver) {
this.driver = driver;
waitForPageToBeLoaded();
initializePageFactory();
}
/**
* Should not base on @FindBy elements
* as it's called before page objects initialization
*
* @return true when the page is loaded
*/
public abstract boolean isLoaded();
private void initializePageFactory() {
PageFactory.initElements(driver, this);
}
protected void waitForPageToBeLoaded() {
long timeOutInSeconds = 15;
try {
final FluentWait wait = new FluentWait<>(this)
.withTimeout(Duration.ofSeconds(timeOutInSeconds))
.pollingEvery(Duration.ofMillis(500))
.ignoring(NoSuchElementException.class)
.ignoring(StaleElementReferenceException.class);
wait.until(BasePage::isLoaded);
} catch (TimeoutException e) {
throw new RuntimeException(String.format("Timed out after %d seconds on loading page %s",
timeOutInSeconds, this.getClass().getName()), e);
}
}
}
Zwróć uwagę, że w konstruktorze najpierw jest wywołanie metody waitForPageToBeLoaded, a dopiero potem zainicjowanie PageFactory. Dzięki temu minimalizujesz ryzyko, że elementy z adnotacją @FindBy nie zostaną znalezione na czas.
Teraz możesz już bardzo łatwo tworzyć nowe PO zwyczajnie rozszerzając klasę bazową i odpowiednio implementując metodę isLoaded(). Przykładowo na stronie logowania takim indykatorem, że strona się w pełni załadowała, będzie klikalność przycisku ‘zaloguj’.
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import pl.tjb.trello.utils.BasePage;
import pl.tjb.trello.utils.FieldUtils;
import pl.tjb.trello.utils.WaitUtils;
public class LoginPage extends BasePage {
private final static By LOGIN_BTN_BY = By.id("login-submit");
@FindBy(id = "username")
private WebElement userNameInput;
@FindBy(id = "password")
private WebElement passwordInput;
@FindBy(id = "login-submit")
private WebElement submitButton;
public LoginPage(WebDriver driver) {
super(driver);
}
@Override
public boolean isLoaded() {
return FieldUtils.isElementClickable(driver.findElement(LOGIN_BTN_BY));
}
public BoardsPage login(String username, String password) {
FieldUtils.typeText(userNameInput, username);
FieldUtils.safeClickButton(driver, driver.findElement(LOGIN_BTN_BY));
WaitUtils.waitUntilElementClickable(driver, passwordInput);
FieldUtils.typeText(passwordInput, password);
FieldUtils.safeClickButton(driver, submitButton);
WaitUtils.waitUntilLoaderDisappears(driver);
return new BoardsPage(driver);
}
}
Kierując się wyborem na co chcesz czekać przy konkretnym widoku sugeruj się tym, na jaki element musisz najdłużej czekać. Zwykle będą to przyciski lub jakieś pola tekstowe typu input. W bardziej złożonych przypadkach możesz czekać na kilka elementów, aby znalazły się w pożądanym przez Ciebie stanie.
Podsumowanie.
No i to by było na tyle! Mam nadzieję, że teraz waity w Selenium nie mają przed Tobą tajemnic. Pamiętaj – albo explicit, albo implicit, bo mieszanie ich to przepis na nieprzewidywalne czasy czekania. Jeśli chcesz mieć pełną kontrolę, FluentWait będzie Twoim najlepszym przyjacielem.
Jeśli chcesz zobaczyć, jak to wszystko wygląda w praktyce na rzeczywistej aplikacji Trello i dowiedzieć skąd biorą się Thread.sleepy w kodzie, koniecznie sprawdź moje wideo na YouTube.
Pozdrawiam!
Piotr
P.S.
Jeśli interesuje Cię jakiś konkretny temat związany z Selenium i chciałbyś poznać mój punkt widzenia albo praktyki jakie w związku z tym stosuje daj proszę znać w komentarzu!
0 komentarzy