👨💻 Kod / Programowanie
Lokalizacja elementów z viewport i bounding box w Playwright
W poprzednim wpisie poznaliśmy czym jest viewport i jak z niego korzystamy w Playwright: 🔗 https://playwright.info/playwright-viewport
W tym wpisie poznasz kreatywne wykorzystanie tej wiedzy i uzupełnienie jej o nowe obszary😉
Problem: w jaki sposób skorzystać z viewport bez asercji?
Już wiemy, że łatwo sprawdzimy elementy, poprzez asercję:
await expect(browsersImage).toBeInViewport();
Ale jak samodzielnie sprawdzić, np. dla listy elementów, które znajdują się w widoku, aby potem działać właśnie na takich elementach?
Załóżmy, że chcemy pobrać wszystkie obrazki widoczne na stronie:
Widoczne oczywiście w viewport😉
Pierwszy krok - zbieramy lokatory
Na początek pobierzmy lokatory do wszystkich obrazków ze strony (zwróć uwagę na sprytne wykorzystanie
all()
):test("check if found images are in viewport", async ({page}) => { await page.goto('https://playwright.dev/'); const images = await page.getByRole('img').all(); console.log('Found images:', images.length); });
Wynik:
Found images: 13
Sporo tych obrazków, a my chcemy tylko te w pełni widoczne w viewport.
Drugi krok: przechodzimy po lokatorach
Mamy listę lokatorów w obiekcie
images
. Stwórzmy funkcję, która umożliwi nam wykonanie operacji na poszczególnych lokatorach:const getElementsfromViewport = async (elements: Locator[]) => { for (const element of elements) { } };
Ta funkcja przyjmuje listę lokatorów
elements: Locator[]
. A docelowo ma nam zwrócić listę elementów znajdujących się w viewport.Dodajmy pustą listę
elementsInViewport
, którą uzupełnimy w pętli a następnie ją zwrócimy:const getElementsfromViewport = async (elements: Locator[]): Promise<Locator[]> => { const elementsInViewport: Locator[] = []; for (const element of elements) { elementsInViewport.push(element); } return elementsInViewport; };
Zauważ, że funkcja zwraca
Promise<Locator[]>
, czyli promise. Jest to związane z tym, że używamy słowa kluczowego async
w deklaracji funkcji. Dzięki temu promise zostanie rozwiązany przy użyciu tej funkcji z operatorem await
. I dostaniemy tym samym czystą listę lokatorów Locator[]
.W obecnym kształcie nasz kod przerzuca wszystkie lokatory z jednej listy do drugiej, a nie o to nam chodzi😁
Czas dodać funkcję filtrującą, której użyjemy w pętli. Zasymulujmy jej użycie poprzez weryfikację elementów przez jeszcze nieistniejącą funkcję
await isInViewport(element)
:const getElementsfromViewport = async (elements: Locator[]): Promise<Locator[]> => { const elementsInViewport: Locator[] = []; for (const element of elements) { if (await isInViewport(element)) { elementsInViewport.push(element); } } return elementsInViewport; };
Jeśli
await isInViewport(element)
zakończy się powodzeniem możemy dodać taki element do naszej listy elementsInViewport
.Funkcja weryfikacji elementu
Zróbmy prototyp dla użycia funkcji
await isInViewport(element)
const isInViewport = async (element: Locator): Promise<boolean> => { };
Jak widzimy funkcja przyjmuje dany lokator
element: Locator
i zwróci true
albo false
(Promise<boolean>
). Chcemy, aby to się stało w zależności od tego czy dany element znajduje się w viewport. Zacznijmy więc ją uzupełniać!
Na początek pobierzemy sobie obecny viewport strony. Odniesiemy się do strony w sprytny sposób, korzystając z wywołania
page
poprzez element
const viewportSize = element.page().viewportSize();
Przypomnijmy jak wygląda wartość
vieport
: { width: 1280, height: 720 }
Ok skoro wiemy w jakich granicach ma być element, musimy pobrać współrzędne i wielkość elementu aby dowiedzieć się czy znajduje się w obszarze
vieport
:Zrealizujemy to za pomocą funkcji
boundingBox()
Wyjaśnienie boundingBox
Odskoczmy na chwilę od naszych funkcji i przyjrzyjmy się bliżej
boundingBox()
. Zobaczmy jak będzie wyglądał
boundingBox()
dla obrazka z logo przeglądarek:
test("browsers image bounding box", async ({page}) => { await page.goto('https://playwright.dev/'); const browsersImage = page.getByAltText('Browsers'); console.log(await browsersImage.boundingBox()); });
Wynik:
{ x: 384, y: 549.171875, width: 512, height: 125.203125 }
Na grafice poniżej wyjaśniam współrzędne, początek miary znajduje się w lewym górnym rogu:
- x: dotyczy szerokości od początku widoku do pierwszego piksela elementu w osi poziomej
- y: od początku widoku do pierwszego piksela elementu w osi pionowej
- w: szerokość elementu
- h: wysokość elementu
Znając te wartości możemy śmiało obliczyć czy x (odległość od lewej) + w (szerokość) oraz y (odległość od góry) + h (wysokość) nie wychodzą poza wartości viewport:
x + w <= viewport.x 896 <= 1280
y + h <= viewport.y 674 <= 720
Z obliczeń jasno wynika, że sprawdzany element jako całość znajduje się w viewport. Jeśli chociaż jeden piksel naszego elementu wychodzi po za viewport wtedy nie będzie on brany pod uwagę.
Sprawdzenie boundingBox()
w funkcji isInViewport
Dodajemy więc pobranie
boundingBox()
do naszej funkcji:const isInViewport = async (element: Locator): Promise<boolean> => { const viewportSize = element.page().viewportSize(); const boundingBox = await element.boundingBox(); };
Warto upewnić się, że pobrane wartości zawierają niezbędne dane (obie funkcje
viewportSize()
i boundingBox()
mogą zwrócić wartości jak undefined
lub null
). W przypadku wystąpienia problemów, zwracamy od razu false
- element nie jest w widoku.const isInViewport = async (element: Locator): Promise<boolean> => { const viewportSize = element.page().viewportSize(); const boundingBox = await element.boundingBox(); if ( !viewportSize || !boundingBox) { return false; } };
Warunek
!viewportSize || !boundingBox
nie jest najłatwiejszy do przeczytania ale zastosowanie takiej konstrukcji pozwala nam płynnie układać narrację w naszej funkcji.Następnie przechodzimy do sprawdzenia elementu.
Czy jest on widoczny?
const isBoundingBoxVisible = boundingBox.x >= 0 && boundingBox.y >= 0;
Czy mieści się on w
viewport
?const isBoundingBoxInViewport = boundingBox.x + boundingBox.width <= viewportSize.width && boundingBox.y + boundingBox.height <= viewportSize.height;
Otrzymane wartości pozwalają nam zwrócić wynik:
return isBoundingBoxVisible && isBoundingBoxInViewport;
Cała funkcja będzie wyglądać tak:
const isInViewport = async (element: Locator): Promise<boolean> => { const viewportSize = element.page().viewportSize(); const boundingBox = await element.boundingBox(); if ( !viewportSize || !boundingBox) { return false; } const isBoundingBoxVisible = boundingBox.x >= 0 && boundingBox.y >= 0; const isBoundingBoxInViewport = boundingBox.x + boundingBox.width <= viewportSize.width && boundingBox.y + boundingBox.height <= viewportSize.height; return isBoundingBoxVisible && isBoundingBoxInViewport; };
Wykorzystujemy kod!
Mamy test i dwie funkcje, które pozwalają na weryfikację znalezionych elementów.
W ostatnich dwóch linijkach testu dodałem, użycie funkcji:
test("check if found images are in viewport", async ({page}) => { await page.goto('https://playwright.dev/'); const images = await page.getByRole('img').all(); console.log('Found images:', images.length); const imagesInViewport = await getElementsfromViewport(images); console.log('images in viewport:', imagesInViewport.length); }); const getElementsfromViewport = async (elements: Locator[]): Promise<Locator[]> => { const elementsInViewport: Locator[] = []; for (const element of elements) { if (await isInViewport(element)) { elementsInViewport.push(element); } } return elementsInViewport; }; const isInViewport = async (element: Locator): Promise<boolean> => { const viewportSize = element.page().viewportSize(); const boundingBox = await element.boundingBox(); if ( !viewportSize || !boundingBox) { return false; } const isBoundingBoxVisible = boundingBox.x >= 0 && boundingBox.y >= 0; const isBoundingBoxInViewport = boundingBox.x + boundingBox.width <= viewportSize.width && boundingBox.y + boundingBox.height <= viewportSize.height; return isBoundingBoxVisible && isBoundingBoxInViewport; };
Wiemy, że test bez asercji to nie test😁
Proponuję w teście dodać znaną nam asercję
toBeInViewport({ ratio: 1 })
aby upewnić się, że nasze obliczenia były poprawne. Zuważ, dodaną parametryzację {ratio: 1}
dzięki temu weryfikujemy w identyczny sposób jak nasza funkcja: czy cały element znajduje się w viewport.Na końcu testu umieszczamy taką pętlę:
for (const image of imagesInViewport) { await expect.soft(image).toBeInViewport({ratio: 1}); }
Zastosowałem
expect.soft
aby asercja nie zatrzymała pętli w razie niepowodzenia. Dzięki temu dowiemy się czy wszystkie obrazki zostały pobrane poprawnie.Dodatkowo możemy pobrać wszystkie obrazki, aby fizycznie je sprawdzić!
Dodajemy tę akcję przed asercją (zastosowałem znacznik czasu
Date.now()
aby otrzymać unikalne nazwy plików):for (const image of imagesInViewport) { await image.screenshot({path: `screenshots/${Date.now()}.png`}); await expect.soft(image).toBeInViewport({ratio: 1}); }
Cały test:
test("check if found images are in viewport", async ({page}) => { await page.goto('https://playwright.dev/'); const images = await page.getByRole('img').all(); console.log('Found images:', images.length); const imagesInViewport = await getElementsfromViewport(images); console.log('Images in viewport:', imagesInViewport.length); for (const image of imagesInViewport) { await image.screenshot({path: `screenshots/${Date.now()}.png`}); await expect.soft(image).toBeInViewport({ratio: 1}); } });
Po odpaleniu testu dostajemy wszystkie obrazki w folderze screenshots i informację na konsoli
Found images: 13 Images in viewport: 5
Możemy usprawnić nazwy zapisywanych plików o podanie wartości x y:
for (const image of imagesInViewport) { const imageBoundingBox = await image.boundingBox(); await image.screenshot({path: `screenshots/imgs/y${ Math.floor(imageBoundingBox!.y)}_x${ Math.floor(imageBoundingBox!.x)}.png`}); await expect.soft(image).toBeInViewport(); }
Przy okazji zminimalizowałem precyzję wartości z
imageBoundingBox
poprzez zastosowanie Math.floor()
. Jeśli nie wiesz jak ona działa proponuję przetestować kod bez zastosowania tej funkcji i sprawdzić nazwy obrazków😉 Z Math.floor()
wyglądają tak:Podsumowanie
Korzystając z dostępnych funkcji związanych z położeniem elementów, z łatwości można je wykorzystać do kreatywnych testów związanych z danym widokiem😉 W samych testach zastosowałem kilka prostych tricków, które mogą być inspiracją także dla Twoich testów.
Warto zaznaczyć, że przypadku zaprezentowanych funkcji sprawdzamy czy wszystkie piksele naszego elementu znajdują się w viewport. Jeśli będziemy chcieli bardziej precyzyjnie określić czy na przykład procentowa wartość elementu znajduje się w viewport musimy zmodyfikować naszą funkcję
isInViewport
.
Jeśli interesuje Ciebie implementacja procentowa przejdź do akapitu BonusCały kod testów:
test("check if found images are in viewport", async ({page}) => { await page.goto('https://playwright.dev/'); const images = await page.getByRole('img').all(); console.log('Found images:', images.length); const imagesInViewport = await getElementsfromViewport(images); console.log('images in viewport:', imagesInViewport.length); for (const image of imagesInViewport) { const imageBoundingBox = await image.boundingBox(); await image.screenshot({path: `screenshots/imgs/y${ Math.floor(imageBoundingBox!.y)}_x${ Math.floor(imageBoundingBox!.x)}.png`}); await expect.soft(image).toBeInViewport(); } }); const getElementsfromViewport = async (elements: Locator[]): Promise<Locator[]> => { const elementsInViewport: Locator[] = []; for (const element of elements) { if (await isInViewport(element)) { elementsInViewport.push(element); } } return elementsInViewport; }; const isInViewport = async (element: Locator): Promise<boolean> => { const viewportSize = element.page().viewportSize(); const boundingBox = await element.boundingBox(); if ( !viewportSize || !boundingBox) { return false; } const isBoundingBoxVisible = boundingBox.x >= 0 && boundingBox.y >= 0; const isBoundingBoxInViewport = boundingBox.x + boundingBox.width <= viewportSize.width && boundingBox.y + boundingBox.height <= viewportSize.height; return isBoundingBoxVisible && isBoundingBoxInViewport; }; test("browsers image bounding box", async ({page}) => { await page.goto('https://playwright.dev/'); const browsersImage = page.getByAltText('Browsers'); console.log(await browsersImage.boundingBox()); });
Bonus
Jeśli chcielibyśmy usprawnić naszą funkcję o wyszukiwanie elementów widocznych w zakresie cząstkowym możemy to łatwo zrobić.
Po pierwsze refaktorujemy funkcję
isInViewport
aby przyjęła odpowiedni parametr:const isInViewport = async (element: Locator, ratio: number = 0): Promise<boolean> => {
Wzorujemy się tu na oryginalnym parametrze z funkcji
toBeInViewport()
i tam możemy ustawić ratio
w zakresie od 0
(dowolna część obrazu znajduje się w vieport) poprzez np. 0.5
(50%) po 1
czyli cały obraz musi być w viewport.Przechodząc do implementacji, po linii z
const isBoundingBoxVisible = boundingBox.x >= 0 && boundingBox.y >= 0;
dodajemy:const ratioPixelsWidth = ratio ? boundingBox.width * ratio : 1 const ratioPixelsHeight = ratio ? boundingBox.height * ratio : 1
Sprawdzamy czy
ratio
jest równe 0
, jak nie jest, to mnożymy szerokość i wysokość razy zakładane ratio
. Gdy przychodzi wartość 0
to wtedy wystarczy, że jeden piksel będzie w viewport, czyli zwracamy wtedy 1
.Uaktualniamy kolejne linie w funkcji o użycie nowych stałych:
const isBoundingBoxInViewport = boundingBox.x + ratioPixelsWidth <= viewportSize.width && boundingBox.y + ratioPixelsHeight <= viewportSize.height;
I to wszystko. Tak wygląda uaktualniona funkcja:
const isInViewport = async ( element: Locator, ratio: number = 0, ): Promise<boolean> => { const viewportSize = element.page().viewportSize(); const boundingBox = await element.boundingBox(); if (!viewportSize || !boundingBox) { return false; } const isBoundingBoxVisible = boundingBox.x >= 0 && boundingBox.y >= 0; const ratioPixelsWidth = ratio ? boundingBox.width * ratio : 1; const ratioPixelsHeight = ratio ? boundingBox.height * ratio : 1; const isBoundingBoxInViewport = boundingBox.x + ratioPixelsWidth <= viewportSize.width && boundingBox.y + ratioPixelsHeight <= viewportSize.height; return isBoundingBoxVisible && isBoundingBoxInViewport; };
Teraz czas na uaktualnienie funkcji
getElementsfromViewport
aby też przyjmowała parametr ratio
i będzie wygladała tak:const getElementsfromViewport = async (elements: Locator[], ratio:number = 0): Promise<Locator[]> => { const elementsInViewport: Locator[] = []; for (const element of elements) { if (await isInViewport(element, ratio)) { elementsInViewport.push(element); } } return elementsInViewport; };
Na koniec pozostał test. Tutaj dodajemy sobie stałą
ratio
po to aby manipulować wartością w teście.test("check if found images are in viewport", async ({page}) => { const ratio = 0.5;
Następnie aktualizujemy miejsca gdzie możemy tę stałą użyć.
Pierwsze miejsce to wywołanie funkcji:
const imagesInViewport = await getElementsfromViewport(images, ratio);
I asercja - gdyż tam też weryfikujemy poprawność naszych założeń:
await expect.soft(image).toBeInViewport({ ratio: ratio});
Cały kod testu będzie wyglądał tak:
test('check if found images are in viewport', async ({ page }) => { const ratio = 0.5; await page.goto('https://unsplash.com'); const images = await page.getByRole('img').all(); console.log('Found images:', images.length); const imagesInViewport = await getElementsfromViewport(images, ratio); console.log('images in viewport:', imagesInViewport.length); for (const image of imagesInViewport) { const imageBoundingBox = await image.boundingBox(); await image.screenshot({ path: `screenshots/imgs/y${Math.floor(imageBoundingBox!.y)}_x${Math.floor( imageBoundingBox!.x, )}.png`, }); await expect.soft(image).toBeInViewport({ ratio: ratio }); } });
No i testujemy nowy serwis - gdyż tam łatwo zobaczymy różnice.
Zadanie dla Ciebie
Uruchom testy z ratio na różnych ustawieniach
0
, 1
, czy 0.5
.Zobacz ile obrazów jest przechwytywanych. Czy funkcja działa jak należy?
Cały kod
import { expect, test, Locator } from '@playwright/test'; test('check if found images are in viewport', async ({ page }) => { const ratio = 0.5; await page.goto('https://unsplash.com'); const images = await page.getByRole('img').all(); console.log('Found images:', images.length); const imagesInViewport = await getElementsfromViewport(images, ratio); console.log('images in viewport:', imagesInViewport.length); for (const image of imagesInViewport) { const imageBoundingBox = await image.boundingBox(); await image.screenshot({ path: `screenshots/imgs/y${Math.floor(imageBoundingBox!.y)}_x${Math.floor( imageBoundingBox!.x, )}.png`, }); await expect.soft(image).toBeInViewport({ ratio: ratio }); } }); const getElementsfromViewport = async ( elements: Locator[], ratio: number = 0, ): Promise<Locator[]> => { const elementsInViewport: Locator[] = []; for (const element of elements) { if (await isInViewport(element, ratio)) { elementsInViewport.push(element); } } return elementsInViewport; }; const isInViewport = async ( element: Locator, ratio: number = 0, ): Promise<boolean> => { const viewportSize = element.page().viewportSize(); const boundingBox = await element.boundingBox(); if (!viewportSize || !boundingBox) { return false; } const isBoundingBoxVisible = boundingBox.x >= 0 && boundingBox.y >= 0; const ratioPixelsWidth = ratio ? boundingBox.width * ratio : 1; const ratioPixelsHeight = ratio ? boundingBox.height * ratio : 1; const isBoundingBoxInViewport = boundingBox.x + ratioPixelsWidth <= viewportSize.width && boundingBox.y + ratioPixelsHeight <= viewportSize.height; return isBoundingBoxVisible && isBoundingBoxInViewport; };