Co to są Czyste Testy?
Zasady Czystego Kodu dotyczą w równym stopniu kodu produkcyjnego oraz kodu testowego. Wykonuj więc czyszczenie kodu za każdym razem, także gdy piszesz testy. Okazje do refaktoryzacji zauważysz często już zaraz po dodaniu nowego testu albo nawet jeszcze przed jego napisaniem w zastanym kodzie legacy . Będzie tak, kiedy w nowym teście potrzebujesz części znajdujących się już w innych testach, takie jak asercje lub konfiguracje systemu.
Takie korekty powinny uwzględniać podstawowe zasady Czystego Kodu. Dotyczą one głownie zachowania czytelności jak i utrzymania łatwości wprowadzania kolejnych zmian. Zadbajmy także o to, aby zastany kod można było łatwo przeczytać i zrozumieć.
Przykład do refaktoryzacji
Poniżej umieściłem zestaw kilku testów integracyjnych. Sprawdzają one cennik pobytu w klubie fitness (siłownia, sauna, basen). W logice umieściłem dodatkowo wyliczenia punktów stałego klienta.
Chociaż przykład tego testu jest dosyć krótki to już zawiera kilka duplikacji kodu. Powtórzenia kodu można znaleść na początku oraz końcu każdego przypadku testowego.
@Test
public void twoHoursinGYm_isOnly_payEntryFee() {
Facility beFitGym = new Facility("Be Fit Gym", Facility.GYM);
Visit visit = new Visit(beFitGym, 2);
Client client = new Client("Mike");
// when
client.addVisit(visit);
String payment = client.getReceipt();
// Then
assertThat(payment)
.valueByXPath("/table/tr[1]/td[1]")
.isEqualTo("Be Fit Gym");
assertThat(payment)
.valueByXPath("/table/tr[1]/td[2]")
.isEqualTo("4.0");
assertThat(payment)
.valueByXPath("/table/tr[1]/td[3]")
.isEqualTo("100");
}
@Test
public void twoHoursInJacuzzi_PayForEach() {
// Given
Facility beFitGym = new Facility("Jacuzzi", Facility.STEAM_BATH);
Visit visit = new Visit(beFitGym, 2);
Client client = new Client("Mike");
// When
client.addVisit(visit);
String payment = client.getHtmlReceipt();
// Then
assertThat(payment)
.valueByXPath("/table/tr[1]/td[1]")
.isEqualTo("Be Fit Jacuzzi");
assertThat(payment)
.valueByXPath("/table/tr[1]/td[2]")
.isEqualTo("10.0");
assertThat(payment)
.valueByXPath("/table/tr[1]/td[3]")
.isEqualTo("300");
}
Refaktoryzacja kodu legacy w małych krokach
Formatowanie
Zanim dokonam pierwszej tranformacji, zauważ jak przydatne jest formatowanie kodu. Powyższy kod został już zformatowany. Wcześniej wyglądał jak poniżej. Zapewne widzisz róznicę kiedy kod jest czytelniejszy a kiedy jest mnie czytelny jak poniżej?
@Test
public void twoHours_PayForEach() {
...
assertThat(payment).valueByXPath("/table/tr[1]/td[1]").isEqualTo("Gym");
assertThat(payment).valueByXPath("/table/tr[1]/td[2]").isEqualTo("10.0");
assertThat(payment).valueByXPath("/table/tr[1]/td[3]").isEqualTo("300");
}
Uzależnij asercje od zmiennych lokalnych
W kodzie dobrze sformatowanym powtórzenia kodu są bardziej widoczne. W ten sposób przygotowuję kod do wyciągnięcia metod zawierających powtórzenia logiki. Zanim wykonam ekstrakcję metody uzależnię powtarzający się kod od zmiennych lokalnych poprzez ich ekstrakcję.
@Test
public void twoHours_payEntryFee() {
// Given
Facility beFitGym = new Facility("Be Fit Gym", Facility.GYM);
Visit visit = new Visit(beFitGym, 2);
Client client = new Client("Mike");
// When
client.addVisit(visit);
String payment = client.getReceipt();
// Then
String facilityName = "Be Fit Gym";
String facilityPrice = "4.0";
String facilityPoints = "100";
assertThat(payment)
.valueByXPath("/table/tr[1]/td[1]")
.isEqualTo(facilityName);
assertThat(payment)
.valueByXPath("/table/tr[1]/td[2]")
.isEqualTo(facilityPrice);
assertThat(payment)
.valueByXPath("/table/tr[1]/td[3]")
.isEqualTo(facilityPoints);
}
Wykonaj ekstrakcję metod z asercjami
Teraz czas na ekstrakcję metody. Jest to automatyczna tranformacja kodu dostępna w większości środowisk programistycznych dla języka Java. Oto nasza nowa metoda.
private void assertFacility(String payment,
String facilityName,
String facilityPrice,
String facilityPoints) {
assertThat(payment)
.valueByXPath("/table/tr[1]/td[1]")
.isEqualTo(facilityName);
assertThat(payment)
.valueByXPath("/table/tr[1]/td[2]")
.isEqualTo(facilityPrice);
assertThat(payment)
.valueByXPath("/table/tr[1]/td[3]")
.isEqualTo(facilityPoints);
}
Wyciągnięte zmienne lokalne nie są już potrzebne, wieć możemy dokonać ich wchłonięcia. Poniżej rezultat przekształcenia.
@Test
public void twoHours_isOnly_payEntryFee() {
Facility beFitGym = new Facility("Be Fit Gym", Facility.GYM);
Visit visit = new Visit(beFitGym, 2);
Client client = new Client("Mike");
// when
client.addVisit(visit);
String payment = client.getReceipt();
// Then
assertFacility(payment, "Be Fit Gym", 4.0, 100);
}
@Test
public void twoHours_PayForEach() {
// Given
Facility beFitGym = new Facility("Jacuzzi", Facility.STEAM_BATH);
Visit visit = new Visit(beFitGym, 2);
Client client = new Client("Mike");
// When
client.addVisit(visit);
String payment = client.getReceipt();
// Then
assertFacility(payment, "Jacuzzi", 10.0, 150);
}
Zwracaj uwagę na parametry metod
Zauważ, że testy stały się krótsze. Problemem jednak jest teraz ilość parametrów które dodatkowo należą do dwóch grup. Pierwsza grupa to dane wejściowe (pierwszy parametr) a druga to wartości poszczególnych asercji (kolejne trzy parametry). Dodatkowo jeżeli parametry obok siebie są tego samego typu to łatwo się pomylić w ich kolejności.
[FM_form id=”2″]Utwórz nową klasę asercji
Dalej wykorzystam dwie powyższe grupy parametrów jako kierunku do kolejnych zmian. Umieszczam metodę w nowej klasie oraz definiuję jedną z grup jako parametr kontruktora. Wówczas obecna metoda będzie ostatecznie zawierać tylko parametry z drugiej grupy a dostęp do pierwszej grupy uzyska już poprzez pola klasy.
Dokonaj ektrakcji klasy poprzez ekstrakcję delegata
Aby utworzyć nową klasę wykonuję ekstrakcję delegata, która jest kolejnym zautomatyzowanym przekszałceniem w IntelliJ IDE dla języka Java.
Oto rezultat tranformacji kodu.
private final FacilityAssertion facilityAssertion = new FacilityAssertion();
@Test
public void twoHours_isOnly_payEntryFee() {
Facility beFitGym = new Facility("Be Fit Gym", Facility.GYM);
Visit visit = new Visit(beFitGym, 2);
Client client = new Client("Mike");
// when
client.addVisit(visit);
String payment = client.getReceipt();
// Then
facilityAssertion.assertFacility(payment, "Be Fit Gym", 4.0, 100);
}
@Test
public void twoHours_PayForEach() {
// Given
Facility beFitGym = new Facility("Jacuzzi", Facility.STEAM_BATH);
Visit visit = new Visit(beFitGym, 2);
Client client = new Client("Mike");
// When
client.addVisit(visit);
String payment = client.getReceipt();
// Then
facilityAssertion.assertFacility(payment, "Jacuzzi", 10.0, 150);
}
Wykonaj wchłonięcie pola
Dodatkowe pole w klasie testów nie było moim celem. Było to efektem ubocznym automatycznego przekształcenia które użyłem. Dokonuję więć wchłonięcia tej pola. Wówczas nowy obiekt asercji będzie utworzony od nowa w każdym miejscu gdzie pole było używane przez logikę.
@Test
public void twoHours_isOnly_payEntryFee() {
Facility beFitGym = new Facility("Be Fit Gym", Facility.GYM);
Visit visit = new Visit(beFitGym, 2);
Client client = new Client("Mike");
// when
client.addVisit(visit);
String payment = client.getReceipt();
// Then
new FacilityAssetion().assertFacility(payment, "Be Fit Gym", 4.0, 100);
}
@Test
public void twoHours_PayForEach() {
// Given
Facility beFitGym = new Facility("Jacuzzi", Facility.STEAM_BATH);
Visit visit = new Visit(beFitGym, 2);
Client client = new Client("Mike");
// When
client.addVisit(visit);
String payment = client.getReceipt();
// Then
new FacilityAssetion().assertFacility(payment, "Jacuzzi", 10.0, 150);
}
Następnie wykonuję ponownie ekstrakcję metody “assertFacility”. Dzięki tem wywołanie kontruktora assercji znajdzie się się tyko w jednym miejscu. Poniżej ostateczny rezultat
private void assertFacility(String payment, String facilityName,
String facilityPrice, String facilityPoints) {
new FacilityAssertion()
.assertFacility(payment, facilityName,
facilityPrice, facilityPoints);
}
@Test
public void twoHours_isOnly_payEntryFee() {
Facility beFitGym = new Facility("Be Fit Gym", Facility.GYM);
Visit visit = new Visit(beFitGym, 2);
Client client = new Client("Mike");
// when
client.addVisit(visit);
String payment = client.getReceipt();
// Then
assertFacility(payment, "Be Fit Gym", 4.0, 100);
}
@Test
public void twoHours_PayForEach() {
// Given
Facility beFitGym = new Facility("Jacuzzi", Facility.STEAM_BATH);
Visit visit = new Visit(beFitGym, 2);
Client client = new Client("Mike");
// When
client.addVisit(visit);
String payment = client.getReceipt();
// Then
assertFacility(payment, "Jacuzzi", 10.0, 150);
}
Przenieś parametr z metody do konstruktora
Konstruktor jest obecnie wywoływany tylko z jednego miejsca. Dodaję więc nowy parametr w kontruktorze, następnie pole w klasie. Kiedy metoda korzysta już z pola “payment” zamiast z parametru metody “payment” – to usuwam – już nieużywany – parametr.
Zamień konstruktor na wywołanie metody statycznej
W dalszej kolejności w klasie FacilityAssertion uruchamiam automatyczne przekształcenie “Zamień wywołanie konstruktora metodą statyczną”.
public class FacilityAssertion {
private String payment;
private FacilityAssertion(String payment) {
this.payment = payment;
}
public static FacilityAssertion assertThat(String payment) {
return new FacilityAssertion(payment);
}
void hasAttributes(String facilityName, String facilityPrice,
String facilityPoints) {
XmlAssert.assertThat(this.payment)
.valueByXPath("/table/tr[1]/td[1]")
.isEqualTo(facilityName);
XmlAssert.assertThat(this.payment)
.valueByXPath("/table/tr[1]/td[2]")
.isEqualTo(facilityPrice);
XmlAssert.assertThat(this.payment)
.valueByXPath("/table/tr[1]/td[3]")
.isEqualTo(facilityPoints);
}
}
Zamień metodę na łańcuch metod
Czas na utworzenie łańcucha metod. Wykonuję więc już ostatnią ekstrakcję kilku nowych metod i dodaję na ich końcu “return this”. To umożliwi mi połączenie tych metod w łańcuch wywołań.
public class FacilityAssertion {
private String payment;
private FacilityAssertion(String payment) {
this.payment = payment;
}
public static FacilityAssertion assertThat(String payment) {
return new FacilityAssertion(payment);
}
FacilityAssertion hasAttributes(String facilityName,
String facilityPrice,
String facilityPoints) {
return hasName(facilityName)
.hasPrice(facilityPrice)
.hasPoints(facilityPoints);
}
FacilityAssertion hasPoints(String facilityPoints) {
XmlAssert.assertThat(this.payment)
.valueByXPath("/table/tr[1]/td[3]")
.isEqualTo(facilityPoints);
return this;
}
FacilityAssertion hasPrice(String facilityPrice) {
XmlAssert.assertThat(this.payment)
.valueByXPath("/table/tr[1]/td[2]")
.isEqualTo(facilityPrice);
return this;
}
FacilityAssertion hasName(String facilityName) {
XmlAssert.assertThat(this.payment)
.valueByXPath("/table/tr[1]/td[1]")
.isEqualTo(facilityName);
return this;
}
}
Wchłonięcie pierwotnej metody asercji
@Test
public void twoHours_isOnly_payEntryFee() {
Facility beFitGym = new Facility("Be Fit Gym", Facility.GYM);
Visit visit = new Visit(beFitGym, 2);
Client client = new Client("Mike");
// when
client.addVisit(visit);
String payment = client.getReceipt();
// Then
assertThat(payment)
.hasName("Be Fit Gym")
.hasPrice("4.0")
.hasPoints("100");
}
@Test
public void twoHours_PayForEach() {
// Given
Facility beFitGym = new Facility("Jacuzzi", Facility.STEAM_BATH);
Visit visit = new Visit(beFitGym, 2);
Client client = new Client("Mike");
// When
client.addVisit(visit);
String payment = client.getReceipt();
// Then
assertThat(payment)
.hasName("Jacuzzi")
.hasPrice("10.0")
.hasPoints("150");
}
Zastosuj analogicznie wzorzec budowniczego lub fabryki do konfiguracji testów
Z pewnością zauważyłeś że teraz konfiguracje testów różnią się pomiędzy sobą tylko typem pomieszczenia oraz ilością godzin. Zwracana nazwa pomieszczenia jest zawsze taka sama więc można sprawdzić ją osobno i tylko tylko raz.
@Test
public void twoHours_isOnly_payEntryFee() {
// Given
String payment = newPaymentFor(Facility.GYM, 2);
// Then
assertThat(payment)
.hasPrice("4.0")
.hasPoints("100");
}
@Test
public void twoHours_PayForEach() {
// Given
String payment = newPaymentFor(Facility.STEAM_BATH, 2);
// Then
assertThat(payment)
.hasPrice("10.0")
.hasPoints("150");
}
Jak widać uzyskaliśmy czyste testy. Nie zawierają duplikacji kodu, są łatwe do zrozumienia. Napisanie kolejnego testu jest także proste.
Biblioteki promujące wzorzec fluent builder
Jedną z bibliotek promujących płynne asercje jest assertJ. Biblioteka ta dobrze integruje się z JUnit. Umożliwia budowanie własnych modułów płynnych asercji . Ułatwia napisanie jednego szczegółowego komunikatu w przypadku niepowodzenia testu lub zwrócenie nowej zagnieżdżonej instancji asercji, która sprawdza na przykład kolejne dane w teście integracyjnym lub jednostkowym.
Dbaj o czytelność testów
Wujek Bob kiedyś powiedział (albo napisał) : “traktuj swoje testy jak obywatela pierwszej kategorii”. Dbaj więc o testy poprzez ich ciągłą refaktoryzację! Czysty Kod to także Czyste Testy!
Pamiętaj, że koncepcja piramidy refaktoryzacji jak i zasady SOLID mają takie samo zastosowanie przy refaktoryzacji testów!
Dodatkowo polecam artykuł Martina Fowler : Fluent Interface na powyższy temat architektury kodu.