Wstęp
W lutym opisałem tutaj technical preview Copilota: dostęp z listy oczekujących, wtyczka do VS Code, szary tekst pojawiający się przed kursorem, Tab i Esc. Tamten wpis był o narzędziu. Sprawdziłem, że działa, i odłożyłem temat na miesiąc. Marzec jest pierwszym miesiącem, w którym mam tę wtyczkę włączoną cały czas w projekcie, który faktycznie utrzymuję, więc mogę zadać pytanie węższe i bardziej użyteczne.
Pytanie brzmi tak: czy ghost text skraca nudną część pisania testu, czy tylko produkuje zielone metody bez asercji. Nudna część jest realna i mierzalna. To nazwa metody, nawigacja do adresu, trzy klamry, using, powtórzony atrybut, kolejny [InlineData], pusta klasa komponentu z pięcioma lokatorami. Landing Copilota reklamuje dokładnie to: “tests without the toil” i “autofill for repetitive code”. W marcu 2022 sprawdzam, ile z tej obietnicy zostaje, kiedy stackiem jest C#, a nie JavaScript z GitHuba.
Aplikacja pod testem to nadal to samo publiczne demo w stylu RealWorld, którego używałem przy refaktorze page objectów: logowanie, nagłówek z linkami zalogowanego użytkownika, lista artykułów. Żadnego kodu klienta. Solucja ma dwa projekty testowe, bo tak wyszło historycznie: UI stoi na NUnit, a warstwa pomocnicza i logika przygotowania danych na xUnit. Oba są na net6.0 i mają przypięte wersje.
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="Selenium.WebDriver" Version="4.1.0" /><PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />Wtyczka to GitHub.copilot w VS Code 1.65.2. Nic poza tym nie instalowałem i nic poza edytorem nie jest mi tu potrzebne.
Kontekst, nie czat
Pierwsza rzecz, którą trzeba sobie poukładać w głowie, zanim zacznie się narzekać na jakość podpowiedzi: nie ma tu miejsca, w którym można o cokolwiek zapytać. Nie ma pola tekstowego, nie ma okna z odpowiedzią, nie ma “napisz mi testy do tej klasy”. Jest edytor, kursor i szary tekst. Cały kanał wejściowy to plik.
W praktyce mam trzy dźwignie i wszystkie są w kodzie.
Pierwsza to komentarz nad sygnaturą. Zwykły // albo komentarz XML, jedno zdanie po polsku lub po angielsku, opisujące zachowanie, a nie implementację. Druga to nazwa metody. Jeżeli w projekcie testy nazywają się zdaniami, to nazwa jest połową promptu i model dopisuje ciało pod nią. Trzecia, najmniej oczywista, to sąsiednie zakładki. FAQ preview mówi wprost, że wtyczka wysyła zawartość edytowanego pliku, a w części przypadków także treść z plików sąsiednich. To nie jest teoria. Do demo poniżej wróciłem dwa razy: raz z otwartym samym plikiem testowym, raz z otwartym obok LoginForm.cs. Podpowiedzi były inne i za chwilę pokażę jak.
Warto zestawić to z tym, co robiłem na Completions API w czerwcu. Tam budowałem prompt z kilkoma przykładami few-shot, ustawiałem temperaturę i limit tokenów, wysyłałem HTTP i dostawałem JSON, który potem walidowałem schematem. Miałem pełną kontrolę nad wejściem i pełną odpowiedzialność za sklejenie tego z projektem. Tutaj jest odwrotnie: zero kontroli nad promptem, zero parametrów, ale wyjście od razu ląduje w odpowiednim pliku, w odpowiedniej klasie, z odpowiednimi typami. To dwa różne narzędzia do dwóch różnych problemów. Tamto generowało dane, to generuje szkielet.
Demo 1: metoda NUnit
Klasa testowa istnieje od sierpniowego refaktoru. Ma [FixtureLifeCycle(LifeCycle.InstancePerTestCase)], driver w polu instancyjnym tworzonym w konstruktorze i Dispose, który go zamyka. Obok leży jeden gotowy test poprawnego logowania. Dopisuję komentarz i zaczynam pisać atrybut.
// logowanie niepoprawnym haslem pokazuje komunikat walidacji nad formularzem
[Test]Pierwsza podpowiedź, przy otwartym tylko pliku testowym:
[Test]
public void Powinien_PokazacKomunikat_GdyHasloJestNiepoprawne()
{
_driver.Navigate().GoToUrl($"{BaseUrl}/login");
var login = new LoginPage(_driver);
login.Form.Fill("test@test.com", "zle-haslo");
login.Form.Submit();
Thread.Sleep(2000);
Assert.IsTrue(login.Form.ErrorMessages().Count > 0);
}Wygląda to sensownie przez pierwsze trzy sekundy i ma cztery osobne problemy.
Fill jest w moim LoginForm prywatne, a Submit w ogóle nie istnieje. Model zaproponował API, które brzmi jak moje, ale go nie ma, więc to się nie kompiluje. To jest zresztą najlepszy rodzaj błędu, bo kompilator go łapie. Gorsze są te, które się kompilują.
Thread.Sleep(2000) to dokładnie ta linia, którą w sierpniu wycinałem z całego zestawu. Model nie wie, że w tym repozytorium jej nie ma, bo w jego danych treningowych jest jej pełno.
Assert.IsTrue(...) to stary model asercji NUnit. Kiedy padnie, w raporcie zobaczę Expected: True, But was: False i tyle. Constraint model, czyli Assert.That, pokazuje w takiej sytuacji rzeczywistą wartość.
I najważniejsze: asercja sprawdza, że komunikatów jest więcej niż zero. Test przejdzie także wtedy, gdy aplikacja wyświetli komunikat o zupełnie innej treści, na przykład o niedostępnym serwerze. Zielony wynik nie będzie wtedy niczego znaczył.
Teraz to samo z otwartym obok LoginForm.cs. Podpowiedź zmienia się w sposób, który dobrze tłumaczy poprzedni akapit o kontekście:
[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.Not.Empty);
}Model zobaczył SubmitExpectingSuccess i SubmitExpectingFailure i zaczął ich używać. Zniknął Thread.Sleep, bo czekanie siedzi w komponencie i w widocznym kodzie nie ma po nim śladu. Zniknęła konwencja nazewnicza rodem z innego repozytorium. Została słaba asercja, którą poprawiam sam na Is.EqualTo z konkretnym tekstem komunikatu.
Wychodzi z tego test, który wygląda praktycznie tak samo jak ten, który napisałem ręcznie w sierpniu, i to jest właśnie sedno. Copilot nie wymyślił mi lepszego wzorca. Odtworzył ten, który już był w plikach obok, i zaoszczędził mi klepania. Zmierzyłem to zresztą z ciekawości na pięciu kolejnych testach: oszczędność jest w okolicach jednej trzeciej naciśnięć klawiszy i zera procent decyzji. Nazwa, nawigacja, klamry i wywołanie komponentu piszą się same. To, co ma być w asercji, nadal muszę wiedzieć ja.
Demo 2: szkic xUnit
Drugi projekt, bez przeglądarki. Testuję w nim pomocnika, który zamienia tytuł artykułu na slug, bo testy API muszą przewidzieć, pod jakim adresem znajdzie się utworzony artykuł. Reguła jest prosta: małe litery, spacje na myślniki, znaki specjalne usuwane. Piszę komentarz i [Theory].
Pierwsza obserwacja jest taka, że dopóki nie napisałem [Theory] własnoręcznie, model proponował trzy skopiowane [Fact] z inną stałą w każdym. To działa, tylko rozjeżdża się przy czwartym przypadku. Sparametryzowanie musiałem zacząć sam.
[Theory]
[InlineData("Hello World", "hello-world")]
[InlineData(" Trailing spaces ", "trailing-spaces")]
[InlineData("C# and .NET 6", "c-and-net-6")]
public void Slugify_ZwracaOczekiwanaPostacAdresu(string title, string expected)
{
Assert.Equal(expected, Slug.From(title));
}To jest dobry szkielet i przyjmuję go prawie w całości. Ale zaraz po nim wtyczka zaczyna dokładać kolejne wiersze, bo [InlineData] to wymarzony kształt dla modelu przewidującego następną linię. Dostałem między innymi propozycję z adresem e-mail wyglądającym na prawdziwy, imieniem i nazwiskiem w domenie popularnego dostawcy poczty. Kasuję takie rzeczy odruchowo. GitHub sam publikował w 2021 badanie o tym, jak często model recytuje fragmenty danych treningowych, i przy danych osobowych to nie jest ciekawostka, tylko kategoria do odrzucenia bez zastanowienia. Do tego dochodzi rzecz zupełnie przyziemna: nie chcę mieć w repozytorium czegoś, co przy pierwszym audycie ktoś weźmie za prawdziwy adres użytkownika.
Druga rzecz, którą trzeba w tej teorii sprawdzić, jest mniej efektowna, ale ważniejsza. Trzy wygenerowane przypadki przechodzą przez tę samą gałąź kodu. Wszystkie mają znaki ASCII. Tymczasem jedyna gałąź, która w mojej implementacji faktycznie potrafi się zepsuć, to polskie znaki diakrytyczne, i takiego [InlineData] model nie zaproponował ani razu, dopóki sam nie wpisałem połowy. Teoria z trzema przypadkami wygląda na trzy razy lepszy test niż [Fact], a bywa jednym testem uruchomionym trzy razy.
To jest ten sam problem, który opisywałem przy strategiach zarządzania danymi testowymi. Dane wymyślone w locie potwierdzają to, co i tak wiemy. Dane dobrane pod konkretną gałąź albo wyprodukowane z ustalonym ziarnem pokazują coś nowego. Copilot świetnie generuje pierwszą kategorię i w ogóle nie zna drugiej, bo nie ma pojęcia, jak wygląda moja implementacja Slug.From poza tym, co widzi w pliku.
Demo 3: page object, nie 50 FindElement
Trzeci scenariusz to ten, na którym najbardziej mi zależało. Zakładam nowy plik komponentu, piszę w klasie jedno zdanie komentarza i patrzę, co się dopisze.
public sealed class LoginForm
{
private readonly IWebDriver _driver;
public LoginForm(IWebDriver driver) => _driver = driver;
public void TypeEmail(string email) =>
_driver.FindElement(By.Id("email")).SendKeys(email);
public void TypePassword(string password) =>
_driver.FindElement(By.Id("password")).SendKeys(password);
public void ClickLogin() =>
_driver.FindElement(By.XPath("//*[@id='root']/div/div/form/fieldset/button")).Click();
public bool IsErrorDisplayed() =>
_driver.FindElement(By.ClassName("error-messages")).Displayed;
}Kompiluje się i nie nadaje się do niczego. Po kolei, według zasad, które ustaliłem w sierpniu.
Lokatory są wymyślone. By.Id("email") brzmi wiarygodnie i w tym demo takiego identyfikatora nie ma. Absolutna ścieżka XPath w ClickLogin pochodzi z jakiegoś innego drzewa DOM i rozjedzie się przy pierwszej zmianie układu. To najbardziej zdradliwa kategoria, bo kod przechodzi review wzrokiem i wywala się dopiero na przebiegu.
API jest zbiorem setterów. Test musiałby wywołać trzy metody po kolei i sam wiedzieć, w jakiej kolejności, czyli mechanika wraca do metody testowej, którą właśnie chciałem od niej odciąć.
IsErrorDisplayed to asercja przemycona do page objectu. Zwraca bool, więc test skończy na Assert.That(form.IsErrorDisplayed(), Is.True) i przy błędzie zobaczę znowu Expected: True. Komponent ma zwracać dane, a nie werdykt.
Nie ma żadnego czekania. Nie ma By jako static readonly. Za to kiedy zostawiłem pusty plik bez żadnego kontekstu i tylko komentarz “page object strony głównej”, dostałem klasę z kilkunastoma polami i wszystkimi selektorami aplikacji w jednym miejscu, czyli dokładnie tę tłustą HomePage, którą rozbijałem przez dwa tygodnie. Nie dlatego, że model jest głupi, tylko dlatego, że taki kształt dominuje w publicznym kodzie.
Zadziałało dopiero odwrócenie kolejności. Najpierw sam wpisuję blok lokatorów, potem zaczynam metodę:
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");Od tego momentu ghost text przestaje wymyślać selektory i zaczyna używać moich. Dopisuje Container, dopisuje szukanie wewnątrz kontenera, dopisuje WebDriverWait z lambdą, kiedy zobaczy, że taki wait jest w sąsiednim komponencie. Wniosek jest prosty i powtarza się we wszystkich trzech demach: Copilot kopiuje lokalną konwencję, jeżeli konwencja jest widoczna w pliku. Jeżeli nie jest, kopiuje konwencję internetu.
Co odrzucam od razu
Po dwóch tygodniach mam listę rzeczy, których nawet nie czytam do końca. Wciskam Esc i piszę sam.
- Test bez asercji albo z asercją typu “nie poleciał wyjątek”.
- Absolutna ścieżka XPath w stylu
//*[@id='root']/div/div/form. Skopiowana z cudzego DOM, u mnie nieprawdziwa. - Adres e-mail, hasło albo numer wyglądające na prawdziwe dane osoby.
- Test, który już istnieje trzy metody wyżej pod inną nazwą. Zdarza się często, bo sąsiedztwo jest dla modelu najsilniejszą wskazówką.
- Wywołanie metody, której nie ma w projekcie. Kompilator to złapie, ale kosztuje minutę i rozprasza.
public static IWebDriverw jakiejkolwiek postaci.Thread.Sleepw jakiejkolwiek postaci.
Warto też odnotować, czego wtyczka w tym preview nie robi: nie mówi, skąd wzięła się dana sugestia, i nie ma żadnego panelu, w którym mógłbym o to dopytać. Cała weryfikacja jest po mojej stronie i jest to weryfikacja kodu, nie rozmowa.
Uruchomienie jest częścią recenzji
Najprostsza zasada z tego miesiąca brzmi: nie commituję testu, którego nie uruchomiłem. Brzmi banalnie, dopóki test nie napisał się w dwie sekundy i nie wygląda na poprawny.
dotnet test --filter "FullyQualifiedName~LoginTests"Model, który zaproponował mi metodę testową, nie uruchomił mojej aplikacji ani razu. Nie wie, jaki dokładnie tekst pokazuje formularz przy złym haśle, ile trwa przekierowanie i czy .error-messages jest dzieckiem <form>, czy jego rodzeństwem. Wie tylko, jak taki kod zwykle wygląda. Zielony wynik z takiego testu jest wart tyle, ile weryfikacja, którą sam mu zrobię.
Robię więc dwa przebiegi zamiast jednego. Pierwszy potwierdza, że test przechodzi. Drugi, że potrafi być czerwony: zmieniam na chwilę oczekiwany tekst komunikatu i sprawdzam, czy test faktycznie pada i czy komunikat błędu z NUnit jest czytelny. Jeżeli po podmianie oczekiwanej wartości test nadal jest zielony, to znaczy, że nie sprawdza tego, co myślę. Ten drugi przebieg wyłapał u mnie w marcu trzy wygenerowane asercje.
Copilot nie zastępuje też niczego poniżej warstwy kodu. Kiedy zacząłem pisać test integracyjny w miejscu, gdzie bazę stawiam Testcontainers, ghost text bardzo płynnie napisał mi konfigurację kontenera z API, które nie zgadzało się z wersją pakietu, którą mam przypiętą. Wtyczka nie wie, jaki obraz mam w rejestrze, ile pamięci ma maszyna CI i czy port jest wolny. Infrastruktura testu zostaje moja w stu procentach, a im dalej od czystego kodu, tym mniej z tego narzędzia korzyści.
I rzecz najbardziej oczywista, o której najłatwiej zapomnieć przy narzędziu, które daje natychmiastową gratyfikację: recenzja przed wrzuceniem na CI nie jest formalnością, tylko częścią procesu testowego. Tempo pisania właśnie wzrosło, więc pilnowanie tego, co wpada do zestawu, staje się ważniejsze, a nie mniej ważne.
Podsumowanie
Po dwóch tygodniach codziennej pracy z tą wtyczką odpowiedź na pytanie ze wstępu jest podzielona. Boilerplate tak, odpowiedzialność za zestaw nie.
Tak, ghost text realnie skraca nudną część. Nazwy metod, atrybuty, nawigacja, kolejny [InlineData], szkielet komponentu, using na górze pliku. Jedna trzecia klepania mniej to nie jest rewolucja, ale to jest zauważalne i nie kosztuje mnie zmiany narzędzi ani procesu.
Nie, nie zdejmuje ze mnie ani jednej decyzji. Asercja, lokator, dobór danych do gałęzi i cykl życia fixture to cztery miejsca, w których podpowiedź była systematycznie prawdopodobna zamiast poprawna. Trzy zasady, które z tego zostają, są krótkie: napisz konwencję zanim poprosisz o kod, czytaj asercję i lokator znak po znaku, uruchom to lokalnie i sprawdź, czy potrafi być czerwone.
Copilot jest wciąż w technical preview, wciąż za listą oczekujących, wciąż tylko w edytorze. Kiedyś z tego preview wyjdzie i wtedy zmieni się cena i dostępność, a nie charakter narzędzia. To jest podpowiadanie kolejnej linii na podstawie tego, co już jest w pliku, i dopóki tak zostanie, nawyk recenzji będzie dokładnie ten sam co dzisiaj.

