News

Wyzwania automatyzacji testów interfejsu użytkownika

Przez ostatnie kilka miesięcy pracowałem nad bardzo ekscytującym projektem, w którym wykorzystywaliśmy Domain Driven Design oraz testy pisane zgodnie z podejściem BDD (Behaviour Driven Development) oraz zasadą “testuj najpierw”. Największe pokrycie kodu było na poziomie testów integracyjnych, a testy jednostkowe wykorzystywane były głównie do weryfikacji skomplikowanej logiki biznesowej naszych bogatych modeli.

#top_oferta: Fintech Consultant / Manager

30000 - 50000 pln

Aplikuj

Artykuł przygotował dla nas Maksymilian Majer. CEO itCraftship. Maksymilian jest inżynierem i architektem oprogramowania z trzynastoletnim doświadczeniem. Jego pasją jest optymalizacja i automatyzacja procesów wokół jakości oprogramowania oraz pracy w zdalnych zespołach. Od ponad ośmiu lat jest związany z branżą startup’ów także od strony biznesowej, ale nigdy nie rozstaje się z kodem na zbyt długi czas. Obecnie pomaga firmom IT wdrażać kulturę pracy zdalnej i budować efektywne zespoły rozwoju produktu.


 

Żeby było zabawniej, DDD stosowaliśmy w projekcie pisanym w Python + Django, więc niestety trzeba było iść na pewne kompromisy (brak value objects, niemożliwa enkapsulacja, itp.), ale to materiał na zupełnie odrębny artykuł. Mimo to nasze podejście do testów sprawdzało się, mieliśmy dobrą informację zwrotną w przypadku wprowadzenia regresji, a po zastosowaniu sewera continuous integration (Semaphore CI w naszym przypadku) bardzo usprawnił się nasz przepływ pracy. Natomiast brakowało dodatkowej warstwy, która wykrywałaby regresje na poziomie interfejsu użytkownika.

W związku z tym, że na froncie nadal wykorzystywane jest leciwe jQuery i nie korzystamy niestety z nowoczesnych bibliotek typu React, Vue czy Angular, testowanie widoków było terenami, na które zespół niechętnie się zapuszczał.

Pierwsze podejście do wprowadzenia testowania interfejsu (tzw. end to end testing lub e2e) zrobiliśmy krótko po tym, kiedy zacząłem współpracę z zespołem. Na tamtym etapie rozważaliśmy wiele podejść, m.in. następujące:

  • Puppeteer
  • NightmareJS z xvfb
  • Cypress
  • Protractor + Selenium + Headless Chrome lub Chrome z xvfb
  • Behave + Splinter + Selenium
  • Behave + Selenium Python driver

Po dłuższej analizie (m.in. po tym świetnym artykule) zdecydowaliśmy się na podejście Behave + Splinter + Selenium. Najważniejszym powodem było to, że nie chcieliśmy wprowadzać dodatkowej komplikacji w postaci projektu w JavaScript i myśleliśmy, że dobrym pomysłem będzie ponowne użycie specyfikacji z naszych testów integracyjnych. Sugerowaliśmy się m.in. tym cytatem z dokumentacji projektu behave django:

„If you want to test/exercise also the “user interface”, it may be a good idea to reuse the feature files, that test the model layer, by just replacing the test automation layer (meaning mostly the step implementations). This approach ensures that your feature files are technology-agnostic, meaning they are independent how you interact with “system under test” (SUT) or “application under test” (AUT)”.

Od początku napotykaliśmy problemy. Chcieliśmy odpalać testy na przeglądarce Google Chrome, ponieważ większość użytkowników aplikacji właśnie z tej przeglądarki korzysta. W środowisku lokalnym miało to sens, ale odpalanie na serwerze CI wymagało wprowadzenia konteneryzacji. Biorąc pod uwagę nasze nieduże doświadczenie na tamten czas z Docker Compose i ogólnie konteneryzacją aplikacji, ciężko było nam zestawić usługi tak, żeby kontener z Selenium łączył się naszą aplikacją w innym kontenerze.

Kiedy wszystko udało się lokalnie, to na serwerze CI testy „wysypywały” się w nieprzewidywalny sposób. Zależało nam na wiarygodnej, przewidywalnej i zautomatyzowanej informacji na temat wprowadzonych regresji, a nie dochodzeniu, czy testy nie przechodzą ze względu na środowisko lub jeszcze inne powody. W końcu testy, których się nie odpala są równie słabe, jak brak testów.

Po tej nieudanej próbie tymczasowo porzuciliśmy nasze zapędy, żeby testować interfejs użytkownika i koncentrowaliśmy się nadal na naszych testach integracyjnych. Testowanie regresji w interfejsie użytkownika pozostało manualne i wykonywane na głównych scenariuszach użycia aplikacji za każdym razem, kiedy mieliśmy wprowadzać nowszą wersję aplikacji na produkcję. Jednym słowem: porażka.

Drugie podejście do testowania UI

Niedawno natknąłem się na interesujący projekt o nazwie CodeceptJS. Zaintrygowała mnie jego trywialna składnia, bliska naturalnemu językowi. Zdawało mi się, że nawet średnio-techniczna osoba jest w stanie zrozumieć testy pisane z użyciem CodceptJS. Ponadto CodeceptJS wprowadza dodatkową warstwę abstrakcji nad API udostępniane przez narzędzia do automatyzacji przeglądarek (Selenium, NightmareJS, Protractor, Puppeteer czy Appium) ujednolicając sposób w jaki wchodzimy z nimi w interakcję.

W międzyczasie zdobyliśmy więcej doświadczenia z dockerem i fakt, że projekt jest w JavaScript nie bolał już tak bardzo. Korzystając z docker-compose oraz prywatnego kontenera w rejestrze Docker Hub mogliśmy wydzielić testy do osobnego repozytorium, które tylko korzysta z obrazu naszej aplikacji w Pythonie. Tym samym nie bałaganimy dodatkowymi zależnościami ze świata JavaScriptu (i stricte testów e2e) w naszym głównym repozytorium aplikacji, która jest pisana w Pythonie.

Zachęciło nas to do podjęcia kolejnej próby do testów e2e, teraz bogatszych już o doświadczenia z poprzedniej porażki.

Poniżej przedstawiam przykłady testu logowania błędnymi danymi i jak wygląda to w popularnych narzędziach do automatyzacji. Dla porównania jest tam też test napisany z użyciem CodeceptJS, który będzie wyglądał tak samo niezależnie od tego, z którym narzędziem zostanie użyty.

Gist: https://gist.github.com/maksymilian-majer/797fa346a8a94eee5633ad0b36e284f1

Zobacz rezultat naszego testu w CodeceptJS i jak zgrabnie wypisane są polecenia w stylu BDD, które pisane są niemal naturalnym językiem:

Dodatkowym atutem CodeceptJS jest to, że w standardzie robi zrzut z ekranu, kiedy test nie przeszedł – pomaga to dojść dlaczego tak się stało.

Po pierwszych próbach z tym narzędziem widzieliśmy już, że testowanie E2E będzie od teraz dużo bardziej przyjemne. Nasze testy są pisane z użyciem czytelnego, ekspresyjnego API a ich wyniki są czytelne nawet dla nietechnicznego personelu. Przede wszystkim tworzenie testów jest łatwiejsze a ich rezultaty bardziej przewidywalne, co sprawia, że oszczędzamy czas na dochodzeniu, czemu nie działają i śpimy spokojnie o wprowadzanie regresji w UI. Dodatkowym bonusem jest łatwiejszy on-boarding testerów, ponieważ praca z CodeceptJS nie wymaga dużego doświadczenia w testowaniu automatycznym.

W dalszej części artykułu zamierzam pokazać, na przykładzie popularnego projektu WordPress, jak udało nam się uzyskać przewidywalne rezultaty z testów i cieszyć większym spokojem przy wprowadzaniu nowych wersji naszej aplikacji.Do dalszej lektury przydatna będzie podstawowa znajomość Node i NPM, aby podążać za wszystkimi instrukcjami. Jeśli te zagadnienia są Ci obce, możesz pobrać kod źródłowy towarzyszący temu artykułowi, który znajduje się na GitHubie i zastosować się do instrukcji w README.

Konfiguracja Docker Compose

Zanim będziemy mogli testować naszego WordPressa musimy skonfigurować środowisko Docker Compose tak, żeby WordPress był widzialny zarówno w naszej lokalnej sieci, jak i w izolowanej sieci kontenerów. Przede wszystkim należy zainstalować Docker Compose. Instrukcje jak to zrobić znajdują się tutaj.

Po zainstalowaniu musimy utworzyć plik docker-compose.yml, który definiuje naszą usługę wraz ze wszystkimi zależnościami:

Gist: https://gist.github.com/maksymilian-majer/53f4a1c6ba5463542c87eebf8f22e6ab

Poniżej opiszę nasze serwisy oraz wyjaśnię kilka istotniejszych ustawień:

chrome

Ten serwis zawiera selenium wraz przeglądarką chrome, na której odtwarzane będą nasze testy. Dla naszej lokalnej sieci udostępniamy dwa porty (4445, 5901), które różnią się od domyślnych, aby uniknąć konfliktów z lokalną wersją selenium. Ponadto nadajemy kontenerowi wewnętrzną nazwę selenium – to będzie dopiero, kiedy będziemy chcieli podłączyć się do selenium z innego kontenera, co jest poza zakresem tego artykułu.

db

To jest nasza WordPressowa baza danych. Ważną zmianą w stosunku do domyślnej konfiguracji jest to, że do katalogu /code/ w kontenerze mapujemy nasz główny folder aplikacji:

To będzie wykorzystane później podczas przywracania bazy przed uruchamianiem pierwszych testów.

wordpress

To jest kontener, na którym działa WordPress. W przypadku testowania własnej aplikacji tutaj zdefiniujesz kontener bazujący na Twoim obrazie z rejestru (np. Docker Hub) albo lokalnego Dockerfile. Dla potrzeb naszego przykładu wyeksportowaliśmy port 80, ponieważ chcemy, aby nasz wordpress miał taki sam URL, kiedy odwołujemy się do niego z naszej lokalnej sieci oraz z innych kontenerów w środowisku Docker Compose:

Jeśli masz inną usługę słuchającą na porcie 80, to musisz ją wyłączyć. Nie doszukałem się w dokumentacji obrazu WordPress’a czy wewnętrzny port można zmienić, bo wtedy wystawiłbym pod innym portem. W Twojej aplikacji prawdopodobnie będziesz mieć na to wpływ i zalecam wykorzystanie innych portów, ale takich samych – np. „8080:8080”.

Najważniejsze jest jednak ustawienie nazwy hosta, pod którą WordPress będzie widziany przez inne kontenery – w szczególności interesuje nas selenium:

Ponieważ chcemy mieć też możliwość odpalania testów lokalnie, a wordpress przechowuje główny URL w bazie danych, będziemy musieli mapować też nazwę hosta wptests w naszym pliku hosts. Na komputerze z macOs możesz zrobić to w następujący sposób:

Na innych systemach operacyjnych musisz poradzić sobie z tym samemu.

Przydałoby się jeszcze, żebyśmy nie musieli ręcznie przechodzić przez instalację WordPress’a, zatem przygotowałem plik backup.sql, który możesz znaleźć tutaj.

Musisz zapisać go w głównym folderze projektu.

Po utworzeniu pliku docker-compose.yml i wprowadzeniu zmiany w pliku hosts jesteśmy już gotowi, żeby sprawdzić czy nasze środowisko Docker Compose działa i jest gotowe na testy. Należy odpalić poniższą komendę:

To polecenie uruchamia wszystkie nasze serwisy i ich zależności. Pozostało tylko odtworzyć bazę danych z pliku backup.sql. Dokonasz tego poleceniem:

Po wykonaniu tych kroków otwórz swoją przeglądarkę i upewnij się, że wszystko działa poprawnie otwierając poniższy URL:

http://wptests

Powinien powitać Cię taki widok:

Jeśli będziesz odpalać testy w środowisku Continuous Integration, to przeważnie wykonasz dwa powyższe polecenia na samym początku i już możesz uruchomić odpalać testy E2E. Na końcu tego artykułu dowiesz się, jak tego dokonać na serwerze Semaphore CI.

W prawdziwym świecie

Jeśli testujesz prawdziwą instalację WP, nie wystarczy tylko odtworzenie bazy danych. Potrzebne będą jeszcze wszystkie pluginy, wgrane pliki (uploads), itp. Nie będę zagłębiał się w szczegóły, ponieważ jest ten dobry artykuł, który to wyjaśnia.

Praca z CodeceptJS

Uruchomienie w środowisku lokalnym i w kontenerze

Na tym etapie masz już działającą instalację WordPress’a gotową do testowania CodeceptJS. Pora zainstalować i skonfigurować CodeceptJS. Framework ma dosyć dobrą dokumentację, w szczególności QuickStart, ale ja chcę pokazać bardziej zaawansowane zaawansowane możliwości konfiguracyjne, które przydadzą się w większych projektach.

Żeby kontynuować wymagany będzie Node w wersji min. 8.9 a także NPM. Polecam stosowanie NVM (Node Version Manager) oraz możliwości automatycznego przełączania wersji Node’a w przypadku używania pliku .npmrc i Node w wersji >9.

Moim celem jest zawsze minimalizowanie globalnych zależności, żeby ułatwić rozpoczęcie pracy nad nowym projektem, dlatego CodeceptJS oraz Selenium instalowałem jako zależności lokalne:

Po zainstalowaniu tych zależności można zainicjować projekt używając CLI dostarczonego z CodeceptJS, ale wtedy trzeba by było mieć zainstalowaną paczkę globalnie. W naszym wypadku możemy do skryptów NPM dodać następujący wpis, aby móc używać komend CLI:

Ten zabieg pozwoli wywoływać komendy CLI z pomocą takiego polecenia:

Przykładowo dla inicjalizacji projektu będzie to:

Natomiast używanie CLI jest opcjonalne, a bardziej zaawansowane możliwości konfiguracyjne nie są obsługiwane, dlatego preferuję ręczne dodawanie testów i konfiguracji, żeby lepiej organizować złożone projekty.

Mój cały package.json wygląda następująco:

Gist: https://gist.github.com/maksymilian-majer/94a9cab5224a54bafbc2533cb8d81f21

Zdefiniowałem w nim kilka przydatnych skrytpów NPM, których działanie wyjaśnię szerzej w dalszej części artykułu.

W trakcie inicjalizacji projektu tworzony jest plik konfiguracyjny codecept.json, ale framework umożliwia bardziej elastyczną formę konfiguracji poprzez plik codecept.conf.js. Warto pamiętać, żeby mieć tylko jeden z tych plików.

Gist: https://gist.github.com/maksymilian-majer/ededa5eaf2bb1c8e5c79b1991de9c421

Ciekawą techniką, którą tutaj wykorzystałem jest rozdzielenie konfiguracji poszczególnych helperów. CodeceptJS umożliwia odpalanie testów jednocześnie przy pomocy kilku narzędzi do automatyzacji przeglądarek. Ja wykorzystuję tylko jedno, ale równie dobrze można odpalać testy równolegle w kilku. Dla czytelności w konfiguracji warto rozdzielić pliki i wczytywać w głównym pliku konfiguracyjnym, jak w powyższym przykładzie.

Ponadto warto zwrócić uwagę na te dwie dodatkowe linijki, które dodałem na potrzeby odpalania testów zarówno przy użyciu lokalnego selenium oraz selenium w kontenerze dockera:

Chcąc uniknąć konfliktów z lokalnym selenium, nasz kontener będzie wystawiał port 4445, więc w przypadku odpalania testów na lokalnym serwerze selenium i w lokalnej przeglądarce musimy zmienić port na 4444. Natomiast gdybyśmy chcieli skorzystać z kontenera dockera CodeceptJS, dodałem też opcję konfiguracji nazwy hosta, pod którym znajduje się selenium. Ja zrezygnowałem z odpalania CodeceptJS w kontenerze, ponieważ większość serwerów CI ma Node i NPM i spokojnie można zainstalować na nich CodeceptJS. Odpalanie testów w kontenerze wymagało dużo większych zasobów, testy przechodziły wolniej i często doświadczaliśmy przekroczonych czasów żądania (timeouts). Dla chętnych z przyjemnością udostępnię plik docker-compose.yml z dodatkową możliwością odpalania testów w kontenerze.

Jeszcze są dwa istotne ustawienia, o których napiszę więcej w dalszej części artykułu, ale wspomnę o nich już teraz:

Sekcja includes pozwala zdefiniować parametry, jakie będą wstrzykiwane (Dependency Injection) jako parametry naszych scenariuszy. Pierwsza zależność I jest domyślna i tworzony podczas inicjalizacji CodeceptJS, natomiast drugą dodałem, żeby zdefiniować powtarzalne czynności związane z bezpieczeństwem – w tym wypadku logowanie.

Mając za sobą podstawową konfigurację musimy jeszcze dodać ustawienia selenium w pliku webdriver.conf.js:

Warto zauważyć jedno z istotnych ustawień:

W naszej konfiguracji sieci w Docker Compose nadamy kontenerowi z WordPressem nazwę wptests, żeby kontener z selenium mógł go widzieć.

Po dodaniu konfiguracji należy dodać plik z definicją I, czyli steps_file.js:

Obecnie Twój projekt powinien mieć następującą strukturę:

Pora napisać pierwszy scenariusz testowy. Najpierw dodaj pusty moduł w pliku

Później dodamy więcej kodu do tego pliku, ale na tą chwilę to wystarczy, żeby można było uruchomić pozostałe testy.

Pierwszy test

Następnie utwórz swój pierwszy scenariusz testowy w pliku

Test zaczynamy od wejścia na stronę logowania i sprawdzenia czy widzimy na niej odpowiedni tekst. Jak widzisz API aktora CodeceptJS jest bardzo intuicyjne i nie sprawi problemów nawet mało technicznych osobom. Zdecydowanie polecam każdemu testerowi manualnemu, który chce zrobić pierwsze kroki w kierunku automatyzacji. Spróbujmy uruchomić testy.

Zaczniemy od uruchomienia w lokalnym selenium. Zakładam, że kontener z WP już działa po wykonaniu poprzednich instrukcji. Przyszła pora na zainstalowanie selenium lokalnie:

Następnie uruchom selenium – najlepiej w osobnej karcie swojej aplikacji terminalowe. Ewentualnie możesz wywołać polecenie w trybie detached (przełącznik -d), tylko potem pamiętaj, żeby zabić proces:

Kiedy selenium już działa możesz uruchomić testy:

Jeśli otworzy się przeglądarka Chrome i później zobaczysz poniższy rezultat w konsoli to znaczy, że odnieśliśmy pierwszy sukces 🙂

Teraz pora na kolejną próbę – odpalenie na selenium działającym w kontenerze:

Rezultat powinien być podobny, za wyjątkiem tego, że nie zobaczysz przeglądarki, a port selenium będzie miał numer 4445. Jeśli wszystko poszło pomyślnie, to masz już konfigurację, w której możesz wygodnie rozwijać testy lokalnie, a później odpalać je w kontenerach, co przyda się w środowisku CI.

Mając podstawową konfigurację i działające środowisko do testów E2E możemy przejść do bardziej interesujących testów i zaawansowanych możliwości CodeceptJS. Zacznijmy od sprawdzenia, czy walidacja pól działa poprawnie. Dopiszemy scenariusze do naszych testów funkcjonalności logowania, które sprawdzają walidację oraz logowanie poprawnymi danymi. Twoje testy powinny wyglądać następująco:

Powyższe scenariusze pokrywają nam główne cele funkcji logowania. Warto pamiętać, że testy E2E są kosztowne i warto pisać je tylko dla kluczowych przypadków użycia aplikacji. Dla potrzeb przykładów w tym artykule nie będę stosował się do tego zalecenia.

Don’t Repeat Yourself – powtarzalne kroki

Logowanie, które przetestowaliśmy powyżej jest czynnością, którą będziemy chcieli wykonać przed testowaniem każdej funkcjonalności w panelu administracyjnym WordPress’a. W związku z tym wypadałoby przenieść ją wspólnych definicji interakcji, które w CodeceptJS nazywają PageObjects.

Zaczniemy od tego, żeby przenieść wspólny kod do wcześniej utworzonego pliku

Jeśli dobrze pamiętasz, powyższy plik został już dodany w pliku codecept.conf.js. W związku z tym możemy go wstrzyknąć jako parametr do naszych scenariuszy w taki sposób:

Po refaktoryzacji z wykorzystaniem PageObjects nasze testy będą wyglądały następująco:

Cały kod źródłowy projektu w obecnym stanie możesz znaleźć w tym commicie: https://github.com/maksymilian-majer/codeceptjs-docker-tutorial/tree/ab01c2d9e22c59664bf3c5502eda39fc872d0b69

Przygotowanie testu

Po przetestowaniu logowania chciałbym zająć się najważniejszą funkcjonalnością WP, czyli zarządzaniem postami. W tym celu utworzę kolejny plik tests/02_posts/01_posts_test.js. Zanim będę mógł utworzyć nowy post muszę być zalogowany. Chciałbym, żeby to odbyło się przed każdym scenariuszem w moim pliku.

Uwaga: W konfiguracji włączyłem opcję zachowania cookies i state przeglądarki, więc nie jest to potrzebne, ale dla celów demonstracji wykomentowałem te linie w pliku webdriver.conf.js:

Teraz, aby zalogować się przed każdym scenariuszem użyję hook’a Before i nasze testy mogą wyglądać teraz następująco:

Uruchamianie tylko wybranych testów

Często będziecie chcieli pracować tylko nad testami w jednym pliku. Można to uzyskać przekazując parametr –grep przy uruchamianiu testów. Ja dla wygody skorzystałem z funkcji tagowania testów i utworzyłem pomocniczy skrypt NPM, żeby uruchamiać tylko konkretne testy.

W package.json:

Wewnątrz testu, który chcesz odpalić możesz dodać tag @current w taki sposób:

Następnie wystarczy, że uruchomisz:

Debugging

Jedną ze słabo udokumentowanych możliwości jest debugowanie z wykorzystaniem IDE – np. WebStorm czy VS Code. W moim package.json skonfigurowałem komendę, aby odpalić testy w trybie interaktywnego debuggera, aby umożliwić podłączenie się z poziomu IDE:

Ponadto dla VS Code dodałem konfigurację launch.json, która umożliwia ustawianie breakpoint’ów w kodzie. Po wywołaniu powyższego polecenia Node będzie czekał aż podłączymy się do niego debuggerem, aby kontynuować.

Natomiast interaktywne debugowanie jest według mnie dużo mniej przydatne, niż odpalanie CodeceptJS z przełącznikiem –debug, o czym możesz przeczytać w dokumentacji: https://codecept.io/advanced/#debug.

Usuwanie problemów

W tej sekcji będę dodawał problemy zgłaszane przez czytelników wraz z rozwiązaniami.

Nieprawidłowe dane logowania

Kiedy zalogujesz się jako admin w lokalnej przeglądarce możesz otrzymywać błędy w logowaniu przy próbie logowania poprawnymi danymi. Polecam wtedy wylogować się na lokalnej przeglądarce. W przypadku odpalania testów testów z poziomu serwera CI taki problem nie wystąpi, ponieważ zwykle są one uruchamiane w odizolowanych wirtualnych maszynach.

Następne kroki

Nie będę bardziej zagłębiał się w API CodeceptJS oraz jego możliwości, ponieważ są one dobrze udokumentowane. Chciałem pokazać bardziej życiową konfigurację i wykorzystanie na realnym projekcie, aby stworzyć szkieleton, który możesz wykorzystać dla swoich celów. Zapraszam do kontynuowania lektury na stronie CodeceptJS: https://codecept.io/basics/.

Konfiguracja projektu w Semaphore CI

Jednym z głównych celów, o którym wspominałem na początku, jest automatyzacja sprawdzania regresji. W związku z tym na koniec chcę pokazać, jak można skonfigurować projekt, który będzie uruchamiał nasze testy w Semaphore CI.

1. Zacznij od rejestracji konta w Semaphore i podłączenia Github’a.

2. Następnie przejdź na stronę dodawania nowego projektu https://semaphoreci.com/new i wybierz Github:

3. Odnajdź swoje repozytorium i kliknij w nie:

4. Wybierz swój branch:

5. Wybierz właściciela projektu:

6. I poczekaj aż Semaphore wykryje jego typ:

7. Powinna pokazać Ci się rekomendacja wyboru platformy opartej na Dockerze, więc wybierz ją:

8. Następny krok z wyborem Deployment platform możesz pominąć klikając link Skip this step na dole.

9. Ostatnim krokiem jest konfiguracja parametrów build’a:

Z naszej perspektywy ważne jest, aby wyłączyć serwisy mysql oraz apache2, żeby nie było konfliktów z portami naszych serwisów z docker-compose.yml. Poniżej treść do przeklejenia:

10. Ostatecznie kliknij przycisk Build with this settings

11. I Voilà – twój projekt zbuduje się na Semaphore w niecałe dwie minuty 🙂

Podobne artykuły