fbpx

Dlhý príklad na TDD

Príklad na TDD (Test-Driven Development) v tomto článku bol príliš jednoduchý. TDD si zaslúži komplikovanejší a dlhší príklad. Tentokrát postup nebudem vysvetľovať tak podrobne, takže ak nemáte s TDD skúsenosti, prečítajte si najprv ten starší článok.

Zdrojový kód príkladu

Niektoré časti tu zjednoduším, ale celý príklad na TDD si môžete stiahnuť z githubu a podrobne preskúmať. Ideálne bude keď si počas čítania článku postupne prejdete cez celú históriu Git repozitára a spustíte si každý krok.

Urobiť sériu checkout revision v jednom projekte v IDE je jednoduchšie ako mať kopu adresárov, kde každý adresár obsahuje celý zdrojový kód. Takto cez Git si aj ľahko sprístupníte diff a pozriete čo presne bolo zmenené.

Čo budeme programovať?

Urobíme komponent, ktorý bude evidovať záujemcov o webináre. Bude možné zaevidovať nový webinár, prihlásiť účastníka a ten si overí emailovú adresu. Nakoniec všetci účastníci dostanú email o začiatku webináru.

Keďže ide len o ukážku fungovania TDD, tak nebudeme riešiť aspekty potrebné na dosiahnutie produkčnej kvality ako je databáza; množstvo potrebných údajov o webinári a účastníkoch; ani integráciu s webservrom. Výsledok nebude thread-safe. Príklad má byť jednoduchý a ľahko nasledovateľný.

V príklade budem používať knižnicu AssertJ na testovanie výnimiek a obsahu kolekcií.

V príklade postupne prejdeme cez jednotlivé požiadavky na komponent. Vždy bude nasledovať zlyhávajúci test a po ňom produkčný kód, ktorý spĺňa požiadavky testu. Ak to dáva zmysel, bude nasledovať refaktoring. Ale pre skrátenie textu som mnohokrát zahrnul menší refaktoring priamo do kódu.


Krok 1: „Bez webinára sa účastník neprihlási”

Niekedy je ťažké rozhodnúť sa aký test spraviť ako prvý. Kent Beck radí začať s niečím jednoduchým, čo nám pomôže posunúť sa ďalej. Buď je to nejaký triviálny alebo chybný vstup.

Ja zvyčajne nezačínam testovaním chybného vstupu, ale tentokrát sa mi to hodí ku vysvetľovaniu.

Zlyhávajúci test

Keď sa bude účastník chcieť zaregistrovať na neexistujúci webinár, tak dostaneme výnimku s popisom chyby. Tú otestujeme pomocou knižnice AssertJ.

public class WebinarServiceTest {
    private WebinarService tested;

    @Before
    public void setUp() {
        tested = new WebinarService();
    }

    @Test
    public void shouldRefuseToRegisterParticipantForWrongWebinar() {
        Participant participant = new Participant("my@email.com");

        assertThatThrownBy(() -> tested.registerParticipant(participant, "non-existent-webinar"))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("Webinar with name 'non-existent-webinar' does not exist");
    }
}

Pridáme nevyhnutnú kostru produkčných tried, aby sa test dal spustiť a my sme mohli overiť, že zlyháva.

public class WebinarService {
    public void registerParticipant(Participant toRegister, String webinarName) {
    }
}
public class Participant {
    private String email;
	// constructor, getter, setter
}

Overíme, že test zlyháva. Ak zlyháva podľa našich očakávaní, môžeme ísť ďalej.

Prechádzajúci test (hack)

Aby nám test prešiel, musíme dopísať nejakú funkcionalitu:

public class WebinarService {
	public void registerParticipant(Participant toRegister, String webinarName) {
		throw new IllegalArgumentException("Webinar with name 'non-existent-webinar' does not exist");
    }
}

To čo som napísal do kódu je dostatočné na to, aby existujúci test prešiel. Ak nerozumiete, prečo som napísal túto kravinu, tak si prosím prečítajte môj predchádzajúci príklad na TDD, kde to podrobne vysvetľujem.

Krok 2: Triangulácia

Existujúci test nedokáže rozhodnúť, či meno webinára je prevzatý z parametra, alebo napísaný natvrdo. Na to musíme pridať nový test. Kent Beck nazýva testovanie toho istého ale s inými parametrami ako triangulácia.

Zlyhávajúci test

    @Test
    public void shouldRefuseToRegisterParticipantForWrongWebinar_case2() {
        Participant participant = new Participant("my@email.com");

        assertThatThrownBy(() -> tested.registerParticipant(participant, "maybe-this-one"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Webinar with name 'maybe-this-one' does not exist");
    }

Overíme, že test zlyháva. Ak zlyháva podľa našich očakávaní, môžeme ísť ďalej.

Prechádzajúci test

Zmeníme spôsob ako sa formátuje text výnimky:

public class WebinarService {
    public void registerParticipant(Participant toRegister, String webinarName) {
        throw new IllegalArgumentException(String.format("Webinar with name '%s' does not exist", webinarName));
    }
}

Test prejde. Stále nemáme komponent s produkčnou kvalitou, ale viac toho existujúce testy nedokážu otestovať.

Krok 3: “Účastník sa zaregistruje na webinár”

Na úspešné zaregistrovanie účastníka budeme potrebovať aj webinár. Ale opäť len toľko, koľko potrebuje test.

Zlyhávajúci test

Napíšme si test, kde vytvoríme webinár, zaregistrujeme účastníka a overíme, že je v zozname zaregistrovaných:

    @Test
    public void shouldAllowToRegisterWebinarAndRegisterParticipant() {
        Webinar webinar = new Webinar("tdd");
        tested.registerWebinar(webinar);

        Participant participant = new Participant("my@email.com");
        tested.registerParticipant(participant, "tdd");

        List<Participant> registered = tested.getRegisteredParticipants();
        assertThat(registered)
                .isNotEmpty()
                .extracting("email")
                .containsExactly("my@email.com");
    }

Kvôli kompilácii potrebujeme pridať kostru do produkčných tried:

public class WebinarService {
    // registerParticipant(Participant toRegister, String webinarName) {
	// ...

    public void registerWebinar(Webinar toRegister) {
    }

    public List<Participant> getRegisteredParticipants() {
        return null;
    }
public class Webinar {
    private String name;
	// constructor, getter, setter
}

Viac informácií v triede Webinar, pre našu ukážku, nebudeme potrebovať, ale v praxi by sme ich potrebovali (nadpis, popis, meno prednášajúceho, čas, …).

(Posledný krát, už to viac nebudem opakovať.) Overíme, že test zlyháva. Ak zlyháva podľa našich očakávaní, môžeme ísť ďalej.

Prechádzajúci test (hack)

Pridal som overenie, či webinár existuje a či má správne meno. Ale pri registrácii webináru a účastníka si ešte stále vystačíme len s hackmi. Testy nevedia otestovať viac funkcionality, tak ju ani neprogramujeme. V podstate si potrebujeme uchovať registrovaný webinár, aby bol k dispozícii pri registrácii účastníka.

public class WebinarService {
    private Webinar registeredWebinar;
    private Participant registeredParticipant;

    public void registerParticipant(Participant toRegister, String webinarName) {
        if (registeredWebinar == null || !registeredWebinar.getName().equals(webinarName)) {
            throw new IllegalArgumentException(String.format("Webinar with name '%s' does not exist", webinarName));
        }

        registeredParticipant = toRegister;
    }
	
    public void registerWebinar(Webinar toRegister) {
        registeredWebinar = toRegister;
    }

    public List<Participant> getRegisteredParticipants() {
        return Collections.singletonList(registeredParticipant);
    }
}

Krok 4: „Na webinár chceme prijať viacerých účastníkov”

Vďaka tomuto kroku budeme nútení zmeniť implementáciu (hack) tak, aby si servis pamätal viacerých účastníkov.

Zlyhávajúci test

Po napísaní testu sa mi začal kód v teste duplikovať. Tak som vyextrahoval pár metód. Súčasne sa tým zjednoduší test a jeho čitateľnosť stúpne.

Mám rád ak metódy v teste, ktoré slúžia na prípravu údajov majú prefix given a tie, ktoré reprezentujú overovanú akciu majú prefix when1. Podobne, metódam určeným na overovanie funkcionality dávam zvyčajne prefix assert.

    @Test
    public void shouldAllowRegisteringMultipleParticipants() {
        String webinarName = "tdd";
        givenRegisteredWebinar(webinarName);

        whenRegisteringParticipant("my@email.com", webinarName);
        whenRegisteringParticipant("john@yahoo.com", webinarName);

        List<Participant> registered = tested.getRegisteredParticipants();
        assertMatchingEmailAddresses(registered, "my@email.com", "john@yahoo.com");
    }

    //////////////////////////////////////////////////////
    private void givenRegisteredWebinar(String webinarName) {
        Webinar webinar = new Webinar(webinarName);
        tested.registerWebinar(webinar);
    }

    private void whenRegisteringParticipant(String email, String webinarName) {
        Participant participant = new Participant(email);
        tested.registerParticipant(participant, webinarName);
    }

    private void assertMatchingEmailAddresses(List<Participant> registered, String... emails) {
        assertThat(registered)
                .isNotEmpty()
                .extracting("email")
                .containsExactly(emails);
    }

Test napísaný v predchádzajúcom kroku som zrefaktoroval nasledovne. Čiastočne používa vyextrahované metódy, ale to, čo je zmyslom testu — registrácia účastníka — som ponechal v pôvodnom stave. Nech je vidieť volanie testovanej metódy.

    @Test
    public void shouldAllowToRegisterWebinarAndRegisterParticipant() {
        givenRegisteredWebinar("tdd");

        Participant participant = new Participant("my@email.com");
        tested.registerParticipant(participant, "tdd");

        List<Participant> registered = tested.getRegisteredParticipants();
        assertMatchingEmailAddresses(registered, "my@email.com");
    }

Prechádzajúci test

V produkčnom kóde som zmenil registeredParticipant na kolekciu registeredParticipants.

    private List<Participant> registeredParticipants = new ArrayList<>();

    public void registerParticipant(Participant toRegister, String webinarName) {
		//...
        registeredParticipants.add(toRegister);
    }

    public List<Participant> getRegisteredParticipants() {
        return new ArrayList<>(registeredParticipants);
    }
}

Krok 5: „Chceme poskytovať viac webinárov naraz”

Aby sme sa zbavili hacku pri registrácii webinára, potrebujeme ďalší test.

Zlyhávajúci test

    @Test
    public void shouldAllowRegisteringMultipleWebinarsWithUniqueNames() {
        givenRegisteredWebinar("tdd");
        givenRegisteredWebinar("oop");

        whenRegisteringParticipant("my@email.com", "tdd");
        whenRegisteringParticipant("john@yahoo.com", "oop");

        List<Participant> registeredTdd = tested.getRegisteredParticipants("tdd");
        assertMatchingEmailAddresses(registeredTdd, "my@email.com");

        List<Participant> registeredOop = tested.getRegisteredParticipants("oop");
        assertMatchingEmailAddresses(registeredOop, "john@yahoo.com");

    }

Ešte potrebujeme zmeniť definíciu metódy servise:

public List<Participant> getRegisteredParticipants(String webinarName) {

Prechádzajúci test

Zmenil som registeredParticipants na Map<String, List<Participant>>.

public class WebinarService {
    private final List<Webinar> registeredWebinars = new ArrayList<>();
    private final Map<String, List<Participant>> registeredParticipants = new HashMap<>();

    public void registerParticipant(Participant toRegister, String webinarName) {
        if (!findWebinarWithName(webinarName).isPresent()) {
            throw new IllegalArgumentException(String.format("Webinar with name '%s' does not exist", webinarName));
        }

        List<Participant> participants = registeredParticipants.computeIfAbsent(webinarName, s -> new ArrayList<>());
        participants.add(toRegister);
    }

    public void registerWebinar(Webinar toRegister) {
        registeredWebinars.add(toRegister);
    }

    public List<Participant> getRegisteredParticipants(String webinarName) {
        return new ArrayList<>(registeredParticipants.get(webinarName));
    }

    private Optional<Webinar> findWebinarWithName(String webinarName) {
        return registeredWebinars.stream()
                .filter(webinar -> webinar.getName().equals(webinarName))
                .findFirst();
    }
}

Krok 6: „Nedovoliť 2 webináre s rovnakým menom”

Keďže používame meno webinára ako identifikátor, tak mená musia byť unikátne. To znamená, že nesmieme dovoliť registrovať nový webinár s tým istým menom.

Zlyhávajúci test

    @Test
    public void shouldRefuseRegisteringTwoWebinarsWithTheSameName() {
        givenRegisteredWebinar("tdd");

        assertThatThrownBy(() -> whenRegisteringWebinar("tdd"))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("Duplicate webinar with name 'tdd'");
    }

    private void whenRegisteringWebinar(String webinarName) {
        givenRegisteredWebinar(webinarName);
    }

Metóda whenRegisteringWebinar je alias existujúcej metódy givenRegisteredWebinar. Takéto aliasy zvyknem používať na zlepšenie čitateľnosti testu, lebo je rozdiel medzi oboma registráciami webinára. Prvýkrát to je príprava scenára, druhýkrát testovaná akcia.

Ak máte pocit, že sa kód takto stáva neprehľadným, tak nemusíte aliasy používať. Ja ich používam, lebo sa mi to zdá lepšie, ale každý má iný vkus.

Prechádzajúci test

    public void registerWebinar(Webinar toRegister) {
        String name = toRegister.getName();
        
        if (findWebinarWithName(name).isPresent()) {
            throw new IllegalArgumentException(String.format("Duplicate webinar with name '%s'", name));
        }
        registeredWebinars.add(toRegister);
    }

Krok 7: „Pokus o duplikátny webinár nezmaže predchádzajúce údaje”

Toto patrí do kontextu predchádzajúceho testu, ale chcem mať malé testy, ktoré testujú len jednu vec. Kratší test vyžaduje aj kratšiu implementáciu v produkčnom kóde.

Prechádzajúci test

Chceme overiť, že už zaregistrovaní účastníci webinára sa nestratia, keď je druhý pokus odmietnutý. Všimnite si, že test musí zachytiť výnimku, ale už ju neoveruje.

    @Test
    public void shouldNotDeleteExistingParticipantsAfterDuplicateRegisteringOfSameWebinar() {
        givenRegisteredWebinar("oop");
        givenRegisteredParticipant("palo@here.com", "oop");

        assertThatThrownBy(() -> whenRegisteringWebinar("oop"));

        List<Participant> registeredOop = tested.getRegisteredParticipants("oop");
        assertMatchingEmailAddresses(registeredOop, "palo@here.com");
    }

Aj tu pridám alias na existujúcu metódu:

    private void givenRegisteredParticipant(String email, String webinarName) {
        whenRegisteringParticipant(email, webinarName);
    }

Tento test automaticky prejde, lebo komponent už obsahuje správnu implementáciu.

Keď test hneď po napísaní prejde, musíme sa zamyslieť, či v ňom nie je chyba. Na druhej strane, nie je vylúčené, že požadovaná funkcionalita už existuje.

Test, čo hneď prejde nie je závada v TDD. Možno len predchádzajúci krok bol príliš veľký.

Krok 8: „Zaregistrovanému účastníkovi pošleme email na verifikáciu emailovej adresy”

Zlyhávajúci test

Funkcionalitu na posielanie emailov nebudeme implementovať. Takže vytvoríme jednoduché rozhranie, na posielanie emailov. Ako parameter bude emailová adresa a meno šablóny, ktorú treba poslať.

public interface EmailSender {
    void sendEmail(String email, String template);
}

Upravíme servis pridaním novej závislosti:

    private final EmailSender emailSender;

    public WebinarService(EmailSender emailSender) {
        this.emailSender = emailSender;
    }

a napíšeme test s použitím Mockito. Začiatok triedy bude vyzerať takto:

public class WebinarServiceTest {
    @InjectMocks
    private WebinarService tested;

    @Mock
    private EmailSender emailSenderMock;

    @Before
    public void setUp() {
        MockitoAnnotations.initMocks(this);
    }

Rozhodol som sa (nemám na to žiadny lepší dôvod ako: „aby sme mali čo testovať”), že súčasťou mena šablóny bude meno workshopu. Takže test môže vyzerať takto:

    @Test
    public void shouldSendConfirmationEmailToRegisteredParticipant() {
        givenRegisteredWebinar("oop");
        whenRegisteringParticipant("palo@here.com", "oop");

        verify(emailSenderMock).sendEmail("palo@here.com", "verify-email-oop");
    }

Prechádzajúci test

Na konci registračnej metódy pridáme posielanie emailu.

    public void registerParticipant(Participant toRegister, String webinarName) {
		// previous content ommitted

        emailSender.sendEmail(toRegister.getEmail(), "verify-email-" + webinarName);
    }

Krok 9: „Overovací email obsahuje náhodný token použitý na verifikáciu”

V predchádzajúcom kroku som si neuvedomil, že takýto mail by mal posielať náhodný token. Aby to bolo bezpečné.

Zlyhávajúci test

Začal som písať test, ale čoskoro som si uvedomil, že nemám ako posielať tokeny. Tak som nedokončený test označil ako ignorovaný.

    @Test
    @Ignore
    public void shouldSendEmailTwiceWhenUserRegisters2ndTimeWithDifferentToken() {
        givenRegisteredWebinar("oop");
        whenRegisteringParticipant("palo@here.com", "oop");
        whenRegisteringParticipant("palo@here.com", "oop");
    }

Pokračoval som úpravou EmailSender .

public interface EmailSender {
    void sendEmail(String email, String template, Map<String, String> parameters);
}
    public void registerParticipant(Participant toRegister, String webinarName) {
		// previous content ommitted

        emailSender.sendEmail(toRegister.getEmail(), "verify-email-" + webinarName, new HashMap<>());
    }

Existujúce testy stále prechádzajú. Môžem sa vrátiť ku ignorovanému testu:

    @Captor
    private ArgumentCaptor<Map<String, String>> emailParametersCaptor;

   @Test
    public void shouldSendEmailTwiceWhenUserRegisters2ndTimeWithDifferentToken() {
        givenRegisteredWebinar("oop");

        whenRegisteringParticipant("palo@here.com", "oop");
        String token1 = assertTokenWasSentToParticipant("palo@here.com", "oop");

        reset(emailSenderMock);

        whenRegisteringParticipant("palo@here.com", "oop");
        String token2 = assertTokenWasSentToParticipant("palo@here.com", "oop");

        assertNotEquals("Token should be different", token1, token2);
    }	

    private String assertTokenWasSentToParticipant(String email, String webinarName) {
        verify(emailSenderMock).sendEmail(eq(email), eq("verify-email-" + webinarName), emailParametersCaptor.capture());
        String token = emailParametersCaptor.getValue().get("token");
        assertNotNull("Token was not sent by email", token);
        return token;
    }

Prechádzajúci test

Do servisu pridáme generovanie náhodných čísiel a ich použitie ako token.

    private final SecureRandom secureRandom = new SecureRandom();
    public void registerParticipant(Participant toRegister, String webinarName) {
		// previous content ommitted

        Map<String, String> parameters = Collections.singletonMap("token", String.valueOf(secureRandom.nextLong()));
        emailSender.sendEmail(toRegister.getEmail(), "verify-email-" + webinarName, parameters);
	}

Všimnite si, že token nikde neuchovávam.
Kontrolná otázka: prečo nám TDD neumožňuje napísať kód na uchovávanie tokenu?

Krok 10: „Účastník sa nemôže prihlásiť 2x na ten istý workshop, ale môže to urobiť kvôli získaniu nového tokenu”

V predchádzajúcom kroku som v teste registroval toho istého účastníka dvakrát, aby som overil, že sa mu neposiela ten istý token. Ten test ale nehovorí nič o tom, ako sa má správať zoznam registrácií.

Zlyhávajúci test

Tento test zakáže duplikátny záznam o registrácii toho istého účastníka.

    @Test
    public void shouldNotRememberParticipantTwiceWhenResendingEmail() {
        givenRegisteredWebinar("java");
        givenRegisteredParticipant("suzy@mail.com", "java");

        whenRegisteringParticipant("suzy@mail.com", "java");

        List<Participant> registered = tested.getRegisteredParticipants("java");
        assertThat(registered).hasSize(1);
    }

Všimnite si, že kým ten predchádzajúci test používal dvakrát metódu whenRegisteringParticipant, tak v tomto teste som ju použil len raz. Namiesto prvého výskytu som použil jej alias givenRegisteredParticipant na rozlíšenie čo ktorý riadok robí. Prvý je na prípravu scenára, druhý obsahuje testovanú akciu.

V chybovej správe tohto testu som si všimol, že by sa hodilo mať v triede Participant implemenovaný toString, tak som ho tam dal vygenerovať.

Prechádzajúci test

    public void registerParticipant(Participant toRegister, String webinarName) {
        if (!findWebinarWithName(webinarName).isPresent()) {
            throw new IllegalArgumentException(String.format("Webinar with name '%s' does not exist", webinarName));
        }

        String email = toRegister.getEmail();

        List<Participant> participants = registeredParticipants.computeIfAbsent(webinarName, s -> new ArrayList<>());
        if (!isRegisteredParticipant(email, participants)) {
            participants.add(toRegister);
        }

        Map<String, String> parameters = Collections.singletonMap("token", String.valueOf(secureRandom.nextLong()));
        emailSender.sendEmail(email, "verify-email-" + webinarName, parameters);
    }

    private boolean isRegisteredParticipant(String email, List<Participant> participants) {
        return participants.stream().anyMatch(participant -> participant.getEmail().equals(email));
    }

Krok 11: „Účastník dostane ďakovný email za prihlásenie sa do webinára a potvrdenie adresy”

Keď účastník dostane email na potvrdenie adresy, klikne na link, ktorým sa na servise zavolá metóda confirmEmail. Účastník potom dostane ďakovný email za overenie adresy. Meno šablóny bude obsahovať meno webináru a to meno bude aj v parametri.

Zlyhávajúci test

    @Test
    public void shouldSendThankYouEmailAfterConfirmingEmailAddress() {
        givenRegisteredWebinar("tdd");
        givenRegisteredParticipant("peter@yahoo.com", "tdd");
        String token = assertTokenWasSentToParticipant("peter@yahoo.com", "tdd");

        tested.confirmEmail("peter@yahoo.com", token, "tdd");

        verify(emailSenderMock).sendEmail(eq("peter@yahoo.com"), eq("thank-you-tdd"), emailParametersCaptor.capture());
        assertEmailParameterValue("webinarName", "tdd");
    }

    private String givenTokenWasSentToParticipant() {
        verify(emailSenderMock).sendEmail(any(), any(), emailParametersCaptor.capture());
        return emailParametersCaptor.getValue().get("token");
    }

    private void assertEmailParameterValue(String name, String expectedValue) {
        String value = emailParametersCaptor.getValue().get(name);
        assertNotNull("Parameter " + name + " not found in email parameters", value);
        assertEquals("Invalid value of parameter " + name, expectedValue, value);
    }

Do servisu musíme pridať kostru metódy:

    public void confirmEmail(String email, String token, String webinarName) {
    }

Prechádzajúci test

    public void confirmEmail(String email, String token, String webinarName) {
        Map<String, String> parameters = Collections.singletonMap("webinarName", webinarName);
        emailSender.sendEmail(email, "thank-you-" + webinarName, parameters);
    }

Opäť minimálne množstvo kódu, aby prešiel test. Nedostatky odstránime pri ďalších testoch.

Krok 12: „Nedovoľ potvrdenie adresy so zlým menom webinára”

Bude nasledovať séria testov na odstránenie NullPointerException prípadne iných, ktoré vznikajú pri zlých vstupoch.

Rozhodol som sa, že všetky chyby z tejto metódy budú vracať rovnakú výnimku InvalidTokenException, bez špecifikácie detailov, aký problém nastal (bezpečnosť).

Zlyhávajúci test

    @Test
    public void shouldRefuseUnknownWebinarWhenConfirming() {
        givenRegisteredWebinar("tdd");
        givenRegisteredParticipant("peter@yahoo.com", "tdd");
        String token = assertTokenWasSentToParticipant("peter@yahoo.com", "tdd");

        assertThatThrownBy(() -> tested.confirmEmail("peter@yahoo.com", token, "BAD_WEBINAR"))
                .isInstanceOf(InvalidTokenException.class);

        verify(emailSenderMock, never()).sendEmail(any(), startsWith("thank-you-"), any());
    }
public class InvalidTokenException extends RuntimeException {
}

Prechádzajúci test

    public void confirmEmail(String email, String token, String webinarName) {
        List<Participant> participants = registeredParticipants.get(webinarName);
        if (participants == null) {
            throw new InvalidTokenException();
        }
        Map<String, String> parameters = Collections.singletonMap("webinarName", webinarName);
        emailSender.sendEmail(email, "thank-you-" + webinarName, parameters);
    }

Krok 13: „Nedovoľ potvrdenie adresy pre neregistrovaného účastníka”

Zlyhávajúci test

Overenie, že sa neposiela email som vyextrahoval do metódy assertThankYouMailIsNotSent.

    @Test
    public void shouldRefuseUnregisteredParticipantWhenConfirming() {
        givenRegisteredWebinar("tdd");
        givenRegisteredParticipant("anybody@mail.com", "tdd");

        assertThatThrownBy(() -> tested.confirmEmail("peter@yahoo.com", "token", "tdd"))
                .isInstanceOf(InvalidTokenException.class);

        assertThankYouMailIsNotSent();
    }

    private void assertThankYouMailIsNotSent() {
        verify(emailSenderMock, never()).sendEmail(any(), startsWith("thank-you-"), any());
    }

Prechádzajúci test

Pridáme hľadanie účastníka, ale len kvôli výnimke, ak neexistuje.

    public void confirmEmail(String email, String token, String webinarName) {
        List<Participant> participants = registeredParticipants.get(webinarName);
        if (participants == null) {
            throw new InvalidTokenException();
        }
        participants.stream()
                .filter(participant -> participant.getEmail().equals(email))
                .findFirst()
                .orElseThrow(InvalidTokenException::new);

        Map<String, String> parameters = Collections.singletonMap("webinarName", webinarName);
        emailSender.sendEmail(email, "thank-you-" + webinarName, parameters);
    }

Krok 14: „Odmietni nesprávny token”

Zlyhávajúci test

    @Test
    public void shouldRejectInvalidTokenConfirmation() {
        givenRegisteredWebinar("tdd");
        givenRegisteredParticipant("peter@yahoo.com", "tdd");

        assertThatThrownBy(() -> tested.confirmEmail("peter@yahoo.com", "BAD_TOKEN", "tdd"))
                .isInstanceOf(InvalidTokenException.class);

        assertThankYouMailIsNotSent();
    }

Všimnite si, že posledné 3 testy na výnimku sú skoro úplne rovnaké, menia sa len údaje. Napriek tomu som ich radšej zduplikoval, ako by som mal mať jeden riadok volajúci metódu z množstvom parametrov. Dôvod je ten, že keď test obsahuje dôležité kroky testovaného scenára, tak sa dá ľahko porovnať s jeho menom. Keby bol celý test len zavolanie metódy napr. performCheckOfRefusedConfirmation s piatimi parametrami, tak by vôbec nebolo jasné čo sa deje.

Trochu duplikovaného kódu, ktorý ukazuje čo sa v teste robí pomáha čitateľnosti. Testy nemusia byť 100% DRY.

Prechádzajúci test

Prišiel čas, kedy si musím začať uchovávať token. Doteraz som ho nikde nepotreboval. Teraz ho už musím porovnávať, ale nikde ho nemám. Môžem si ho doplniť do Participant, ale budem predstierať, že tú triedu nemám pod kontrolou, a teda ju nemôžem použiť. Mohol by som si spraviť v service ďalšiu kolekciu, ale tým by sa celý kód komplikoval.

Pre potreby tohto príkladu som sa rozhodol spraviť si novú internú triedu, ktorá bude wrappovať inštanciu Participant. Nebude ju vidieť z vonka a refaktoring existujúceho kódu nebude zdĺhavý. A testy ma postrážia… Navyše takto predvediem, že ponechávanie rozhodnutia o dizajnových detailoch na neskôr pri TDD funguje.

public class RegisteredParticipant {
    private Participant participant;
    private String token;

    public RegisteredParticipant(Participant participant, String token) {
        this.participant = participant;
        this.token = token;
    }

    public Participant getParticipant() {
        return participant;
    }

    public String getToken() {
        return token;
    }

    public String getEmail() {
        return participant.getEmail();
    }
}

A teraz ten refaktoring WebinarService, spolu s finálnou implementáciou:

    private final Map<String, List<RegisteredParticipant>> registeredParticipants = new HashMap<>();
    public void registerParticipant(Participant toRegister, String webinarName) {
		//...
	
        String email = toRegister.getEmail();
        String token = String.valueOf(secureRandom.nextLong());

        List<RegisteredParticipant> participants = registeredParticipants.computeIfAbsent(webinarName, s -> new ArrayList<>());
        if (!isRegisteredParticipant(email, participants)) {
            participants.add(new RegisteredParticipant(toRegister, token));
        }

		// sending mail ...
    }
    public void confirmEmail(String email, String token, String webinarName) {
        List<RegisteredParticipant> participants = registeredParticipants.get(webinarName);
        if (participants == null) {
            throw new InvalidTokenException();
        }
        RegisteredParticipant found = participants.stream()
                .filter(participant -> participant.getEmail().equals(email))
                .findFirst()
                .orElseThrow(InvalidTokenException::new);

        if (!found.getToken().equals(token)) {
            throw new InvalidTokenException();
        }

        Map<String, String> parameters = Collections.singletonMap("webinarName", webinarName);
        emailSender.sendEmail(email, "thank-you-" + webinarName, parameters);
    }

Všetky ostatné potrebné zmeny, kde treba použiť RegisteredParticipant mi ukázalo IDE.

    public List<Participant> getRegisteredParticipants(String webinarName) {
        return registeredParticipants.get(webinarName).stream()
                .map(RegisteredParticipant::getParticipant).collect(Collectors.toList());
    }

    private boolean isRegisteredParticipant(String email, List<RegisteredParticipant> participants) {
        return participants.stream().anyMatch(participant -> participant.getEmail().equals(email));
    }

Refaktoring

Teraz mi už testy prechádzajú. Ešte vyextrahujem metódu na vyhľadanie účastníka, aby bola metóda confirmEmail čitateľnejšia.

    public void confirmEmail(String email, String token, String webinarName) {
        RegisteredParticipant found = findParticipantByWebinarAndEmail(webinarName, email)
                .orElseThrow(InvalidTokenException::new);

        if (!found.getToken().equals(token)) {
            throw new InvalidTokenException();
        }

        Map<String, String> parameters = Collections.singletonMap("webinarName", webinarName);
        emailSender.sendEmail(email, "thank-you-" + webinarName, parameters);
    }

    private Optional<RegisteredParticipant> findParticipantByWebinarAndEmail(String webinarName, String email) {
        List<RegisteredParticipant> participants = registeredParticipants.get(webinarName);
        if (participants == null) {
            return Optional.empty();
        }
        return participants.stream()
                .filter(participant -> participant.getEmail().equals(email))
                .findFirst();
    }

Testy prechádzajú.

Krok 15: „Servis pošle email ‘pred začiatkom’ webinára všetkým potvrdeným účastníkom”

Nebudeme riešiť „kedy je PRED”, ani kto to zavolá. Chceme mať na servise jednoduchú metódu, čo pohľadá kto všetko má pre webinár potvrdenú emailovú adresu a tomu pošle email.

Zlyhávajúci test

Keď testujem prácu s nekompletnou kolekciou údajov, tak zvyčajne napíšem test tak, že niektorý prvok zo stredu bude vyfiltrovaný. Mohol by som pridať ešte aj test, že prvý a posledný prvok je vyfiltrovaný, ale mnohokrát to nerobím.

    @Test
    public void shouldSendEmailToAllParticipantsBeforeWebinarStarts() {
        String webinarName = "interestingWebinar";

        givenRegisteredWebinar(webinarName);
        givenRegisteredParticipant("a@a.com", webinarName);
        givenRegisteredParticipant("b@b.com", webinarName);
        givenRegisteredParticipant("c@c.com", webinarName);

        givenParticipantConfirmedEmailForWebinar("a@a.com", webinarName);
        givenParticipantConfirmedEmailForWebinar("c@c.com", webinarName);

        tested.sendEmailAboutWebinarStarting(webinarName);

        assertWebinarStartsSentTo(webinarName, "a@a.com");
        assertWebinarStartsWasNotSentTo("b@b.com");
        assertWebinarStartsSentTo(webinarName, "c@c.com");
    }

Pre zmenu (len aby bolo trochu variácie v tomto príklade) som sa rozhodol, že šablóna pre štart webinára bude spoločná pre všetky webináre. Meno webinára, z ktorého sa v šablóne odvodí link, si pošleme do EmailSender.

    private void assertWebinarStartsSentTo(String webinarName, String expectedEmailAddress) {
         verify(emailSenderMock).sendEmail(eq(expectedEmailAddress), eq("webinar-starts"), emailParametersCaptor.capture());
         Map<String, String> captured = emailParametersCaptor.getValue();
         assertEquals("Invalid webinarNameInEmail", webinarName, captured.get("webinarName"));
     }

     private void assertWebinarStartsWasNotSentTo(String expectedEmailAddress) {
         verify(emailSenderMock, never()).sendEmail(eq(expectedEmailAddress), eq("webinar-starts"), any());
     }

Ešte kostra do servisu, nech sa to dá skompilovať…

    public void sendEmailAboutWebinarStarting(String webinarName) {
    }

Prechádzajúci test

Posielanie emailu len potvrdeným účastníkom je dobrý dôvod na to, aby sme si túto informáciu uchovali.

public class RegisteredParticipant {
    // ...
    private boolean emailConfirmed;
    // ...

    public boolean isEmailConfirmed() {
        return emailConfirmed;
    }

    public void setEmailConfirmed(boolean emailConfirmed) {
        this.emailConfirmed = emailConfirmed;
    }
}
    public void confirmEmail(String email, String token, String webinarName) {
		// ...
        found.setEmailConfirmed(true);
    }
    public void sendEmailAboutWebinarStarting(String webinarName) {
        Map<String, String> parameters = Collections.singletonMap("webinarName", webinarName);
        registeredParticipants.get(webinarName).stream()
                .filter(RegisteredParticipant::isEmailConfirmed)
                .forEach(p -> emailSender.sendEmail(p.getEmail(), "webinar-starts", parameters));
    }

Prečo v tomto príklade na TDD robím také malé kroky s toľkými hackmi?

Pretože si to vyžaduje učebnicový prístup ku TDD. Je to totiž dôležitý krok aby sme nezabudli písať dostatočné množstvo testov, čo pokrývajú celú funkcionalitu.

Keby som robil veľké kroky, tak v zozname testov by som si nemusel všimnúť, že mi nejaký test chýba. Ak sa pri implementácii komponentu rozhodnem niečo hacknúť, tak si okamžite uvedomím, aký test musím pridať. Navyše si takto zabezpečím, že kroky budú dostatočne malé a striedanie písania testu a produkčného kódu bude svižné.

Aby som nezabudol na práve vymyslený test, môžem si napísať poznámku na papier.

Keď máme s TDD a dizajnovaním dostatočné skúsenosti, môžeme prestať odolávať pokušeniu o napísanie veľkého kusu kódu. Ale keď zistíme, že sa v tom zamotávame, tak treba opätovne spomaliť. Tým chcem povedať, že je to OK robiť aj veľké kroky. Ak je všetko pod kontrolou. Ale treba sa naučiť aj to, ako robiť malé kroky.

Mne sa pri tvorbe tohto príkladu tiež stalo, že som si až pri opätovnom prechode príkladom všimol miesto, kde som spravil príliš veľký kus kódu bez testu. Tak som to prerobil do súčasnej podoby.

Niekedy o veľkosti krokov rozhoduje aj poradie testov. Ak si vyberieme nesprávny test, na jeho uskutočnenie budeme potrebovať príliš veľký inkrement v komponente. Vtedy má zmysel sa zamyslieť, či by sme ho nemali označiť ako @Ignore a napísať nejaký iný test. A k ignorovanému sa vrátiť neskôr.

Záver

Verím, že tento príklad na TDD vám pomohol k objasneniu ako vyzerá práca s použitím TDD. Ukázali sme si TDD na väčšom príklade, kde sú aj spolupracujúce komponenty, ktorých rozhranie sa definuje podľa potreby. Keby napr. EmailService už existoval, postup by bol samozrejme trošku iný.

Taktiež sme si ukázali jednu veľmi užitočnú techniku, ktorú TDD uľahčuje svojou kvalitnou suitou testov — ponechanie rozhodnutia o detaile dizajnu až kým je ten detail nevyhnutný. Bez TDD ľudia bežne robia takéto rozhodnutia na začiatku a to nie je vždy ideálne. (Tým nechcem povedať, že všetky rozhodnutia by sa mali robiť čo najneskôr.)

Pripomienka na záver

Nezabudnite si stiahnuť zdrojáky s kompletnou históriou v Gite odtiaľto.


  1. Tento prístup som prevzal z Behavior-Driven Development, ktorý označuje 3 základné časti testu ako Given, When, Then.