Wstęp
Dwa lata temu, przy refaktoryzacji testów w Cypressie, zadawałem sobie pytanie w stylu “App Actions czy Page Object Model”. Napisałem wtedy oba warianty na tej samej aplikacji demo i wyszło mi, że to kwestia gustu i skali projektu. Dzisiaj pytanie mam zupełnie inne, bo zestaw, o którym piszę, nie jest w JavaScripcie tylko w C#, stoi na Selenium 3.141 i po prostu urósł. Urósł w bardzo konkretny sposób: klasa HomePage ma ponad pięćset linii i czterdzieści kilka wywołań FindElement. Znajdę w niej nagłówek, listę artykułów, paginację, stopkę i przy okazji trzy metody pomocnicze do logowania, bo ktoś kiedyś potrzebował ich “na stronie głównej”. Dopisanie jednego testu zaczyna się od piętnastu minut czytania cudzej klasy.
Nie mam zamiaru przepisywać tego zestawu na inny wzorzec. Page Object zostaje. Zmienia się to, co uznaję za “jedną stronę”, oraz to, kto jest właścicielem WebDriver. Aplikacja pod testem to nadal publiczne demo w stylu RealWorld: logowanie, nagłówek z linkami zalogowanego użytkownika, lista artykułów. Żadnych nazw klientów, żadnego kodu z projektu komercyjnego.
Projekt stoi na net5.0, a wersje pakietów mam przypięte:
<PackageReference Include="Selenium.WebDriver" Version="3.141.0" />
<PackageReference Include="Selenium.Support" Version="3.141.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />Selenium 4 jest w tym momencie w becie, 4.0.0-beta-4 wyszło 7 czerwca. Trzymam je w osobnym projekcie do sprawdzania migracji, tak samo jak przy Gridzie w Dockerze, a codzienny przebieg zostaje na 3.141.0. Ten refaktor jest zresztą świadomie niezależny od wersji bindingów: chcę, żeby przejście na czwórkę było później zmianą w jednym miejscu, a nie w czterdziestu.
Co bolało
Cztery rzeczy, w kolejności od najbardziej wkurzającej. Zaczynam od statycznego drivera. Gdzieś na dnie projektu leży public static IWebDriver Driver, ustawiany raz w [OneTimeSetUp], i wszystkie page objecty sięgają po niego bez pytania. Dopóki testy chodzą szeregowo, to działa. Przy pierwszej próbie zrównoleglenia dwa testy zaczynają sterować tą samą przeglądarką.
PageFactory. W .NET-owych bindingach jest oznaczony jako przestarzały od marca 2018, przy okazji Selenium 3.11. Jim Evans opisał to wtedy na swoim blogu we wpisie o deprecjacji części .NET-owych bindingów, a kod trafił do osobnego projektu DotNetSeleniumExtras. Kompilator mówi to samo przy każdym buildzie:
warning CS0618: 'PageFactory.InitElements(ISearchContext, object)' is obsolete:
'The PageFactory and supporting classes are deprecated
and will be removed in a future release.'Komunikat skróciłem, ale sens jest jednoznaczny. Nie jest to “sposób zalecany przez Selenium na page objecty”, tylko kawałek historii, który u mnie w repozytorium nadal się kompiluje. Zostają asercje wewnątrz page objectu i Thread.Sleep, a te dwie rzeczy zwykle chodzą parą:
public class HomePage
{
public static IWebDriver Driver;
[FindsBy(How = How.CssSelector, Using = ".navbar .nav-link[href='/settings']")]
[CacheLookup]
private IWebElement _settings;
public HomePage() => PageFactory.InitElements(Driver, this);
public void AssertUserIsLoggedIn(string username)
{
Thread.Sleep(2000);
Assert.That(_settings.Displayed, Is.True);
Assert.That(Driver.FindElement(ProfileLink(username)).Displayed, Is.True);
}
}[CacheLookup] zapamiętuje referencję do elementu po pierwszym znalezieniu. Na stronie, która sama się przerysowuje po zalogowaniu, dostaję za to StaleElementReferenceException w losowym momencie. Thread.Sleep(2000) jest tam po to, żeby to zamaskować, i robi dokładnie dwie rzeczy: dokłada dwie sekundy do każdego testu i nie usuwa problemu, tylko zmniejsza jego częstotliwość. Metoda nazywa się AssertUserIsLoggedIn, więc kiedy pada, w raporcie widzę Expected: True, But was: False bez informacji, o który z dwóch linków chodzi.
Fowler: fragment, nie URL
Wróciłem do tekstu, od którego cała ta układanka się zaczęła, czyli do Page Object u Martina Fowlera. Kluczowe zdanie nie mówi nic o adresach URL. Page object opakowuje fragment interfejsu i wystawia go jako API, a to, czy tym fragmentem jest cała strona, czy jej kawałek, zależy od tego, co jest sensowne dla testu. Fowler wprost pisze, że jeśli strona zawiera istotny komponent, wart osobnego modelu, można dla niego zrobić osobny page object. Moje “jedna klasa na jeden URL” to była interpretacja, którą sobie sam dopisałem, i to ona wyhodowała mi pięćsetlinijkową HomePage. Skoro nagłówek jest na każdej stronie, a HomePage jest jedna, to helper do nagłówka wylądował właśnie tam. Nowy podział wygląda tak:
Header- pasek nawigacji, ten sam obiekt na każdej stronieLoginForm- formularz logowania, niezależnie od tego, gdzie go osadzęArticleFeediArticleRow- lista artykułów i pojedynczy wierszLoginPage,HomePage- cienkie klasy, które te komponenty tylko składają
Znika przy okazji BasePage z czterdziestoma metodami pomocniczymi, po której wszystko dziedziczyło. Nagłówek nie jest już czymś, co każda strona “dostaje w spadku”, tylko właściwością, którą strona posiada:
public sealed class HomePage
{
public HomePage(IWebDriver driver)
{
Header = new Header(driver);
Feed = new ArticleFeed(driver);
}
public Header Header { get; }
public ArticleFeed Feed { get; }
}Kompozycja zamiast dziedziczenia nie jest tu ozdobnikiem z podręcznika. Kiedy nagłówek jest osobnym typem, mogę go użyć w teście ustawień i w teście wylogowania bez przeciągania za sobą reszty strony głównej.
Bez PageFactory
Skoro PageFactory jest przestarzały, to co zamiast? Zwykłe pola By i wyszukiwanie elementu w momencie akcji.
public sealed class LoginForm
{
private static readonly By Root = By.CssSelector("form");
private static readonly By EmailInput = By.CssSelector("input[type=email]");
private static readonly By PasswordInput = By.CssSelector("input[type=password]");
private static readonly By SubmitButton = By.CssSelector("button[type=submit]");
private static readonly By ErrorItem = By.CssSelector(".error-messages li");
private readonly IWebDriver _driver;
public LoginForm(IWebDriver driver) => _driver = driver;
private IWebElement Container => _driver.FindElement(Root);
private void Fill(string email, string password)
{
var container = Container;
container.FindElement(EmailInput).SendKeys(email);
container.FindElement(PasswordInput).SendKeys(password);
container.FindElement(SubmitButton).Click();
}
}Trzy decyzje są tu celowe. Lokatory to static readonly By, a nie IWebElement. By jest opisem, jak znaleźć element, i można go bezpiecznie współdzielić między instancjami. IWebElement to uchwyt do konkretnego węzła w konkretnym stanie DOM i właśnie on psuje się po przerysowaniu strony.
Container jest właściwością, a nie polem ustawianym w konstruktorze, więc każde odwołanie robi świeży FindElement. Kosztuje to jedno dodatkowe zapytanie do przeglądarki i w zamian usuwa całą klasę błędów o nieaktualnym elemencie. Przy okazji zawęża wyszukiwanie: container.FindElement(EmailInput) szuka wewnątrz formularza, więc nie trafię w pole e-mail z newslettera w stopce.
I nie dokładam DotNetSeleniumExtras po to, żeby zachować [FindsBy]. Ten pakiet jest miejscem, w którym stary kod przetrwał, a nie kierunkiem rozwoju. Dokładanie zależności, żeby dalej używać czegoś, co z bindingów wyjęto, to sporo pracy w złą stronę.
Waits w komponencie
Fowler zwraca uwagę, że page object jest dobrym miejscem na ukrycie asynchroniczności interfejsu. Test ma powiedzieć “zaloguj się”, a nie “kliknij, poczekaj dziesięć sekund, sprawdź”. Dlatego WebDriverWait mieszka w komponencie i nigdy nie pojawia się w metodzie testowej. Rozbudowuję konstruktor LoginForm i dopisuję do niego resztę klasy:
private readonly WebDriverWait _wait;
public LoginForm(IWebDriver driver)
{
_driver = driver;
_wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10));
_wait.IgnoreExceptionTypes(typeof(StaleElementReferenceException));
}
public HomePage SubmitExpectingSuccess(string email, string password)
{
Fill(email, password);
_wait.Until(d => d.FindElements(Header.SettingsLink).Count == 1);
return new HomePage(_driver);
}
public LoginForm SubmitExpectingFailure(string email, string password)
{
Fill(email, password);
_wait.Until(d => d.FindElements(ErrorItem).Count > 0);
return this;
}
public IReadOnlyList<string> ErrorMessages() =>
_driver.FindElements(ErrorItem).Select(e => e.Text).ToList();Listę błędów czytam od drivera, a nie od Container, bo w tym demo .error-messages jest rodzeństwem <form>, a nie jego dzieckiem, i dokładnie tego samego zasięgu używa warunek, na który czekam.
Warunek jest lambdą, a nie ExpectedConditions. Ta klasa dostała etykietę “przestarzałe” tą samą decyzją co PageFactory, a jej dalsze życie toczy się w SeleniumExtras.WaitHelpers. Lambda z FindElements jest krótsza, nie wymaga dodatkowego pakietu, a zwrócenie pustej listy zamiast wyjątku sprawia, że warunek jest zwykłą wartością logiczną. WebDriverWait sam z siebie połyka NotFoundException, więc NoSuchElementException w warunku nie przerywa czekania, natomiast StaleElementReferenceException trzeba dodać jawnie. Robię to zawsze, bo w trakcie przerysowania strony jest to stan przejściowy, a nie błąd.
Dwie metody zamiast jednego Submit biorą się z wniosku, do którego doszedłem już w 2019 na Cypressie: po poprawnym logowaniu jestem na innej stronie niż po błędnym, więc metoda nie może zwracać jednego typu. Nazwa mówi wprost, czego się spodziewam, i to ona decyduje, na co czekam. Warto przy okazji odróżnić czekanie od ponawiania. WebDriverWait czeka na stan interfejsu, a nie powtarza akcji. Ponawianie samej operacji, tak jak przy politykach Polly w testach integracyjnych, to inne narzędzie do innego problemu i wsadzone w klikanie potrafi ukryć prawdziwy defekt.
NUnit 3.13 i cykl życia
Zostaje najciekawsza część, czyli statyczny driver. NUnit domyślnie tworzy jedną instancję klasy testowej na całą fixture i uruchamia na niej wszystkie testy. To dlatego driver wylądował w polu statycznym: skoro instancja i tak jest jedna, to różnica wydawała się kosmetyczna.
Od NUnit 3.13, wydanego 7 stycznia tego roku, jest na to atrybut. Wersja, którą mam przypiętą, to 3.13.2 z 27 kwietnia.
[TestFixture]
[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
public class LoginTests : IDisposable
{
private const string BaseUrl = "http://localhost:4100";
private readonly IWebDriver _driver;
public LoginTests()
{
var options = new ChromeOptions();
options.AddArgument("--window-size=1920,1080");
_driver = new ChromeDriver(options);
}
public void Dispose() => _driver.Quit();
}Przy InstancePerTestCase NUnit tworzy nową instancję klasy dla każdego przypadku testowego, więc konstruktor jest wywoływany raz na test, a Dispose po każdym teście. Driver przestaje być stanem współdzielonym i staje się polem instancyjnym, którego czas życia pokrywa się z czasem życia jednego testu. Żadnego static IWebDriver w całym projekcie. Dwie rzeczy wyszły przy tym w praniu. Po pierwsze, przy tym cyklu życia [OneTimeSetUp] i [OneTimeTearDown] muszą być statyczne, bo nie ma już jednej instancji, na której NUnit mógłby je wywołać. Po drugie, driver powstaje w konstruktorze, więc sprzątanie wieszam na IDisposable: NUnit wywołuje Dispose po każdym teście, o ile klasa ten interfejs implementuje. [SetUp] i [TearDown] działają tu tak samo jak zawsze, czyli przed i po każdym teście, więc jest to wybór dla symetrii z konstruktorem, a nie przymus. Kiedy zapomnę driver zamknąć, przeglądarki zostają otwarte i orientuję się po tym, że laptop zaczyna szumieć. Żeby nie powtarzać atrybutu na każdej klasie, można go ustawić raz dla całego assembly przez [assembly: FixtureLifeCycle(LifeCycle.InstancePerTestCase)].
Robię to teraz, chociaż przebieg nadal jest szeregowy. Równoległość w NUnit nie działa jak przełącznik, który wystarczy włączyć na końcu. Ona tylko ujawnia stan współdzielony, który był w kodzie od początku. Dopóki driver jest statyczny, dodanie Parallelizable nie przyspieszy testów, tylko zamieni je w generator losowych czerwonych wyników. Kiedy driver należy do instancji, a instancja żyje tyle co jeden test, można w ogóle zacząć rozmowę o puszczaniu tego równolegle na Gridzie. To jest temat na osobny wpis i wrócę do niego, gdy będę miał przebiegi, na których można coś pokazać.
Asercje
Fowler jest tu dość jednoznaczny: page object nie powinien zawierać asercji, bo miesza dwie odpowiedzialności. Obiekt ma dawać dostęp do stanu interfejsu, a to, czy ten stan jest poprawny, jest wiedzą testu. Wyjątek, który dopuszcza, to asercje pilnujące niezmienników strony, na przykład tego, że w ogóle jesteśmy tam, gdzie powinniśmy być, a nie tego, co konkretnie bada dany test.
W 2019 zrobiłem dokładnie odwrotnie. Wrzuciłem wtedy do Cypressa custom command shouldBeLoggedIn i schowałem w nim trzy asercje o linkach w nagłówku. Wyglądało to elegancko w teście i było kompletnie nieczytelne w raporcie z błędem. Teraz komponent zwraca dane, a test je ocenia:
public sealed class Header
{
public static readonly By SettingsLink = By.CssSelector("nav .nav-link[href='/settings']");
private static readonly By Root = By.CssSelector("nav.navbar");
private static readonly By AnyLink = By.CssSelector(".nav-link");
private readonly IWebDriver _driver;
public Header(IWebDriver driver) => _driver = driver;
public IReadOnlyList<string> LinkHrefs() =>
_driver.FindElement(Root).FindElements(AnyLink)
.Select(e => e.GetAttribute("href")).ToList();
}I same testy:
[Test]
public void PoprawneLogowaniePokazujeLinkiZalogowanegoUzytkownika()
{
_driver.Navigate().GoToUrl($"{BaseUrl}/login");
var home = new LoginPage(_driver).Form
.SubmitExpectingSuccess("test@test.com", "test");
Assert.That(home.Header.LinkHrefs(), Is.SupersetOf(
new[] { $"{BaseUrl}/settings", $"{BaseUrl}/editor", $"{BaseUrl}/@test" }));
}
[Test]
public void BledneHasloPokazujeKomunikatWalidacji()
{
_driver.Navigate().GoToUrl($"{BaseUrl}/login");
var form = new LoginPage(_driver).Form
.SubmitExpectingFailure("test@test.com", "zle-haslo");
Assert.That(form.ErrorMessages(), Is.EqualTo(new[] { "email or password is invalid" }));
}Test ma teraz trzy linie treści i widać w nich scenariusz, a nie mechanikę. Kiedy asercja padnie, NUnit pokaże pełną listę linków, które faktycznie były w nagłówku, zamiast Expected: True. To jest ta różnica, dla której warto było wyjąć asercje z page objectu. Czekanie nadal siedzi w SubmitExpectingSuccess, więc w teście nie ma ani jednego Wait: komponent wie, kiedy strona jest gotowa, a test wie, co ma na niej być.
Podsumowanie
Page Object Model zostaje. Po dwóch tygodniach grzebania w tym kodzie nie mam żadnego argumentu za wyrzuceniem wzorca, mam za to trzy za zmianę sposobu, w jaki go stosowałem.
- Granicą obiektu jest fragment interfejsu, a nie adres URL. Nagłówek, formularz i wiersz tabeli to osobne typy, składane w cienkie klasy stron.
- Elementy wyszukuję w momencie akcji. Bez
PageFactory, bez[CacheLookup], bez pakietu, który utrzymuje przy życiu kod wyjęty z bindingów trzy lata temu. WebDrivernależy do jednego testu.FixtureLifeCycle.InstancePerTestCasew NUnit 3.13 załatwia to jednym atrybutem i jest warunkiem wstępnym każdej rozmowy o równoległości.
Czego tu świadomie nie ma: selektorów. To, że w przykładach mam input[type=email] zamiast atrybutu dedykowanego testom, jest osobnym tematem i pisałem o nim przy refaktorze selektorów. Struktura kodu i sposób wskazywania elementów to dwa niezależne problemy i mieszanie ich w jednym refaktorze kończy się tym, że żaden nie jest skończony. I ostatnia rzecz: ten refaktor sprawia, że dopisanie testu zajmuje mi teraz kilkanaście minut zamiast godziny, ale nie sprawia, że testuję właściwe rzeczy. To pozostaje pytaniem o cały proces testowy, a nie o układ klas w projekcie.

