W codziennej pracy nad automatyzacją testów często spotykamy się z koniecznością oczekiwania na pewne zmiany w interfejsie użytkownika. Standardowym podejściem bywa używanie sztywnych timeoutów, czyli metody waitForTimeout, jednak takie rozwiązanie może prowadzić do problemów związanych z wydajnością i stabilnością testów. W tym artykule przyjrzymy się, dlaczego warto unikać sztywnych timeoutów, jakie są ich zalety i wady oraz przedstawimy alternatywne podejścia oparte na dynamicznym oczekiwaniu.

Dlaczego nie warto używać sztywnych timeoutów?

Sztywne timeouty, czyli stałe opóźnienia wprowadzane za pomocą waitForTimeout, mają kilka istotnych ograniczeń:

  • Nieoptymalny czas wykonania testów - Ustalony czas oczekiwania może być zbyt długi lub za krótki. Jeśli ustawimy zbyt długi timeout, testy będą wykonywały się wolniej; zbyt krótki może spowodować, że elementy nie zdążą się załadować.
  • Niższa deterministyczność - Testy oparte na sztywnych czeknięciach mogą być zawodnymi, gdyż opierają się na założeniach dotyczących czasu, a nie na faktycznym stanie elementów.
  • Trudności w utrzymaniu - W przypadku zmian w interfejsie, konieczne jest ręczne modyfikowanie timeoutów w wielu miejscach.

Alternatywy - oczekiwanie na konkretny stan elementu

Zamiast korzystać z sztywnych timeoutów, warto wdrożyć podejścia oparte na oczekiwaniu na określony stan elementu, na przykład visible, lub na zmianę atrybutów elementu. Takie podejście zwiększa stabilność i deterministyczność testów. Przykładem może być wykorzystanie metody waitForState w połączeniu z uniwersalnymi metodami pomocniczymi:

Przykładowe metody pomocnicze

Metoda oczekująca na wartość

/**
 * Wewnętrzna metoda pomocnicza, która czeka na oczekiwaną wartość.
 *
 * @param getValue - Funkcja zwracająca aktualną wartość (Promise<T | null>).
 * @param expectedValue - Oczekiwana wartość.
 * @param timeout - Maksymalny czas oczekiwania (domyślnie 5000 ms).
 * @param interval - Interwał pomiędzy kolejnymi próbami (domyślnie 100 ms).
 * @param useIncludes - Jeśli true, sprawdzamy czy wartość zawiera expectedValue.
 * @returns Zwraca znalezioną wartość lub null.
 */
private static async waitForValueInternal<T>(
  getValue: () => Promise<T | null>,
  expectedValue: string,
  timeout: number = 5000,
  interval: number = 100,
  useIncludes: boolean = false
): Promise<T | null> {
  const startTime = Date.now();

  while (Date.now() - startTime < timeout) {
    const value = await getValue();
    if (useIncludes
      ? typeof value === "string" && value.includes(expectedValue)
      : value === expectedValue) {
      return value;
    }
    await new Promise((resolve) => setTimeout(resolve, interval));
  }

  return null;
}

/**
 * Publiczna metoda oczekująca na wartość.
 *
 * @param getValue - Funkcja zwracająca aktualną wartość.
 * @param expectedValue - Oczekiwana wartość.
 * @param timeout - Maksymalny czas oczekiwania.
 * @param interval - Interwał pomiędzy próbami.
 * @param useIncludes - Czy używać metody includes przy porównaniu.
 * @returns Znaleziona wartość.
 * @throws Błąd, jeśli wartość nie pojawi się w zadanym czasie.
 */
static async waitForValue<T>(
  getValue: () => Promise<T | null>,
  expectedValue: string,
  timeout: number = 5000,
  interval: number = 100,
  useIncludes: boolean = false
): Promise<T> {
  const value = await Pb.waitForValueInternal(getValue, expectedValue, timeout, interval, useIncludes);
  if (value === null) {
    throw new Error(`Expected value "${expectedValue}" did not appear within the timeout period`);
  }
  return value;
}

/**
 * Metoda zwracająca boolean w oparciu o oczekiwanie na wartość.
 *
 * @param getValue - Funkcja zwracająca aktualną wartość.
 * @param expectedValue - Oczekiwana wartość.
 * @param timeout - Maksymalny czas oczekiwania.
 * @param interval - Interwał pomiędzy próbami.
 * @param useIncludes - Czy używać includes przy porównaniu.
 * @returns True, jeśli wartość pojawiła się, w przeciwnym razie false.
 */
static async waitForValueBoolean(
  getValue: () => Promise<string | null>,
  expectedValue: string,
  timeout: number = 5000,
  interval: number = 100,
  useIncludes: boolean = false
) {
  const value = await Pb.waitForValueInternal(getValue, expectedValue, timeout, interval, useIncludes);
  return value !== null;
}

Metody oczekujące na stan elementu

Poniżej przedstawiamy przykłady metod, które nie używają waitForTimeout, lecz czekają na określony stan elementu:

/**
 * Uniwersalna metoda, która czeka, aż element osiągnie określony stan,
 * a następnie wykonuje zadaną akcję.
 *
 * @param locator - Obiekt Locator.
 * @param state - Oczekiwany stan elementu (np. Visible).
 * @param action - Callback z akcją do wykonania.
 * @param timeout - Maksymalny czas oczekiwania.
 */
private static async performAction(
  locator: Locator,
  state: LocatorState,
  action: () => Promise<void>,
  timeout: number = 5000
) {
  await this.waitForState(locator, state, timeout);
  await action();
}

/**
 * Czeka aż element stanie się widoczny, a następnie wykonuje kliknięcie.
 *
 * @param locator - Obiekt Locator.
 */
static async waitAndClick(locator: Locator) {
  await this.performAction(locator, LocatorState.Visible, () => locator.click());
}

/**
 * Czeka, aż element stanie się widoczny, wypełnia go podaną wartością,
 * wykonuje blur, a następnie weryfikuje wartość.
 *
 * @param locator - Obiekt Locator.
 * @param value - Wartość do wpisania.
 * @throws Błąd, jeśli wartość wpisana nie odpowiada oczekiwanej.
 */
static async waitAndFill(locator: Locator, value: string) {
  await this.performAction(locator, LocatorState.Visible, async () => {
    await locator.fill(value);
    await locator.blur();
  });
  const inputValue = await locator.inputValue();
  if (inputValue !== value) {
    throw new Error(`Input value mismatch: expected "${value}", but got "${inputValue}"`);
  }
}

/**
 * Metoda oczekująca na stan elementu.
 *
 * @param locator - Obiekt Locator.
 * @param state - Oczekiwany stan.
 * @param timeout - Maksymalny czas oczekiwania.
 * @throws Błąd, jeśli element nie osiągnie oczekiwanego stanu.
 */
static async waitForState(locator: Locator, state: LocatorState, timeout: number = 5000): Promise<void> {
  const count = await locator.count();
  for (let i = 0; i < count; i++) {
    const element = locator.nth(i);
    try {
      await element.waitFor({ state, timeout });
    } catch (error) {
      throw new Error(
        `Element at index ${i} with selector "${locator["_selector"]}" did not reach state "${state}" within ${timeout}ms.`
      );
    }
  }
}

Dodatkowo, dla sytuacji gdy oczekujemy na pojawienie się określonej liczby elementów, pomocna może być metoda:

/**
 * Czeka, aż liczba elementów odpowiadających locatorowi osiągnie minimalną wartość.
 *
 * @param locator - Obiekt Locator.
 * @param minCount - Minimalna wymagana liczba elementów.
 * @param timeout - Maksymalny czas oczekiwania.
 * @returns True, gdy warunek zostanie spełniony.
 * @throws Błąd, jeśli warunek nie zostanie spełniony.
 */
static async waitForMinimumCount(locator: Locator, minCount: number, timeout: number = 5000) {
  const startTime = Date.now();
  let currentCount = 0;

  while (Date.now() - startTime < timeout) {
    currentCount = await locator.count();
    if (currentCount >= minCount) {
      return true;
    }
    await new Promise((resolve) => setTimeout(resolve, 100));
  }

  throw new Error(`Expected at least ${minCount} elements, but found ${currentCount}.`);
}

Zalety i wady podejścia opartego na dynamicznym oczekiwaniu

Zalety

  • Stabilność testów - Akcje są wykonywane dopiero, gdy elementy osiągną oczekiwany stan (np. stają się widoczne), co minimalizuje ryzyko wystąpienia błędów.
  • Lepsza wydajność - Brak sztywnych opóźnień (hardcoded wait) sprawia, że testy kończą się szybciej, gdy elementy ładują się szybciej niż zakładano.
  • Łatwiejsze utrzymanie - Zmiany w logice oczekiwania można wprowadzić w centralnych metodach, co wpływa na całą bazę testów.

Wady

  • Dodatkowa implementacja - Wdrożenie metod oczekujących może wymagać dodatkowego wysiłku i modyfikacji istniejącego kodu.
  • Skomplikowane debugowanie - W przypadku awarii może być trudniej zdiagnozować, dlaczego element nie osiągnął oczekiwanego stanu.
  • Możliwość nieoczekiwanych timeoutów - Jeśli warunki w interfejsie ulegną zmianie lub wystąpią opóźnienia, metody oczekujące mogą spowodować przekroczenie czasu oczekiwania.

Podsumowanie

Unikanie sztywnych timeoutów (waitForTimeout) na rzecz dynamicznego oczekiwania na określony stan elementów znacząco podnosi stabilność i wydajność testów. Stosując uniwersalne metody pomocnicze, które czekają na określone warunki - takie jak widoczność elementów, pojawienie się konkretnej wartości lub osiągnięcie minimalnej liczby elementów - możemy zbudować bardziej deterministyczną i odporną na drobne zmiany aplikacji bazę testów.

Zachęcamy do wypróbowania przedstawionych technik w swoich projektach Playwright, by doświadczyć korzyści płynących z bardziej inteligentnego podejścia do oczekiwania na stan elementów.

Miłego testowania!