fbpx

Prečo sa netrápim s dizajnom komponentu dopredu?

Boli časy, keď ma dizajn aplikácie vedel pekne vytrápiť. Mohlo to byť aj nedostatkom skúseností, ale väčšiu rolu zohralo to, že som ho vymýšľal dopredu a bol som zahltený množstvom detailov.

Test-Driven Development tento proces zjednodušuje. Treba mať hrubú predstavu ako práve tvorený komponent zapadá do celkového obrazu, ale všetky drobné detaily sa vyriešia v priebehu vývoja. Práve testy riadia ten vývoj a ukazujú smer. Dôvod je ten, že pri každom jednom teste sa zaoberáme jedným (zvyčajne) malým detailom — tak aby ani nový test, ani testovaná funkcionalita nebola príliš veľká. A súčasne integrujeme komponent s jeho prvým klientom (testom), čo nám dáva kvalitnú spätnú väzbu.

Čo považujem za dizajn?

API

Verejné metódy, ktoré volá klient tohto komponentu, sú neodmysliteľnou súčasťou dizajnu. A nie len dizajnu. Definujú kontrakt medzi klientom a komponentom. Tým, že pri TDD najprv naprogramujeme test, tak musíme rozmýšľať o API — ako sa sa bude používať tento komponent? Mám jasnú predstavu, čo má vyvíjaný komponent robiť?

Test je vlastne prvý klient, ktorý používa vyvíjaný komponent. A keďže chceme aby testy boli jednoduché a čitateľné, tak hneď na začiatku venujeme pozornosť API, hoci len jednej alebo dvom metódam. Sústredíme sa na to ako sa bude používať, aby jeho používanie bolo jednoduché a spĺňalo všetky požiadavky. Rozmýšľame, aké sú očakávané vstupy, výstupy1, ale aj postupnosť volania. To, čo sa bude diať v komponente príde na rad za chvíľu a nemalo by nás to príliš zaťažovať pri definovaní rozhrania.

Ak by sme neskôr zistili, že sme niečo nedomysleli, tak sa to dá opraviť pomerne jednoducho. Existujúce testy nie sú vytesané do kameňa2.

Aké veci treba zvažovať pri návrhu API? Je toho viac, tak aspoň pár príkladov:

  • má komponent všetky potrebné informácie, ktoré bude v metóde potrebovať?
  • nemá metóda príliš veľa parametrov?
  • chcem, aby si komponent zapamätal použité parametre, lebo ich budem pri ďalšom volaním potrebovať? Alebo ich pošlem znova a dosiahnem väčšiu flexibilitu, hoci za cenu opakovania sa?
  • akým spôsobom sa dozviem výsledok volania metódy? Bude to vrátená hodnota, alebo zmena vnútorného stavu?
  • aká výnimka by bola najlepšia keď takýmto spôsobom poruším kontrakt?

Čo všetko komponent robí a ako si delí prácu s inými komponentami

Nechcem na tomto mieste vysvetľovať S.O.L.I.D. princípy, tak si z nich požičiam len jeden — Single Responsibility Principle. Ten hovorí, že každý komponent by mal robiť len jednu vec a mal by ju robiť dobre. Alebo inými slovami, že by mal existovať len jeden dôvod prečo ho musíme zmeniť.

Ako príklad majme komponent, ktorý počíta priemernú teplotu za rôzne dlhé časové obdobia. Ako jediný dôvod, prečo ho máme meniť by, napríklad, mala byť zmena výpočtu z aritmetického priemeru na medián. Ale určite by sme ho nemali potrebovať zmeniť ak sa zmení spôsob ukladania údajov — za to má byť zodpovedný iný komponent. Komponent by teda nemal robiť príliš veľa.

Keď pri vývoji prídeme na to, že komponent začína robiť príliš veľa (toto nám môže napovedať test, ktorý práve programujeme, tým že je komplikovaný), prípadne zistíme, že to čo potrebujeme aby sa udialo nepatrí do zodpovednosti vyvíjaného komponentu, vieme zadefinovať nový spolupracujúci komponent. Ten bude zodpovedný za to, čo sa nám nehodí do nášho komponentu. A v našom komponente zostane len rozhodovanie, aké parametre potrebujeme poslať a čo robiť s návratovou hodnotou od toho druhého.

Takto sme dostali príležitosť navrhnúť ideálne rozhranie na spoluprácu týchto dvoch komponentov. Dá sa povedať, že sme rozhranie objavili počas vývoja, namiesto toho aby sme ho nadizajnovali dopredu.

Ako to prebieha?

Úplne za začiatku vývoja komponentu by sme mali mať predstavu o tom, čo má robiť. Ak postupujeme zhora dole (ako bolo popísané v predchádzajúcej sekcii o objavení rozhrania), tak už vieme čo treba dosiahnuť a máme dané rozhranie. Ak sme v situácii, že rozhranie ešte nie je dané, tak ho teraz postupne definujeme — tak ako budeme postupne vytvárať nové testy.

Často sa stáva, vďaka naučeným postupom a našim skúsenostiam, že máme jasnú predstavu, aký dizajn bude komponent mať a ako bude dosahovať stanovené ciele. Je lepšie trochu pribrzdiť a nechať sa viesť testami. Je možné, že testy nás dovedú ku jednoduchšiemu a pritom dostatočnému riešeniu.

Takže na začiatok vývoja si vyberieme nejakú jednoduchú situáciu, napríklad triviálny výpočet priemernej teploty pre jednu hodnotu3. A stojíme pred rozhodnutím — odkiaľ zoberiem vstupné údaje? Môžeme sa rozhodnúť, že v našom kontexte je najlepší spôsob postupne volať metódy addMeasurement(3.14)calculateAverage(). Je to OK, ak je to čo potrebujeme. Dokonca by som povedal, že ako rozhodnutie pre prvý test je to vhodné aj vtedy, ak by sa dizajn mal hneď pri druhom teste zmeniť.

Je dosť možné, že vstupné údaje sú už niekde zozbierané a komponent ich len použije vo výpočte. Takže pri druhom teste, kde chceme počítať priemer z dvoch hodnôt, máme viac možností:

  • predpokladať, že klient bude v cykle volať metódu addMeasurement(value) spomenutú pred chvíľou
  • poslať zoznam do calculateAverage(temps)
  • poslať zdroj dát do metódy calculateAverage(source)
  • prípadne si metóda sama bude vyberať údaje z nejakého zdroja, ale malo by to byť abstraktné rozhranie, aby nemusela riešiť, či to je kolekcia, súbor, databázová tabuľka alebo REST volanie.

Ak by sme sa rozhodli, že metóda calculateAverage bude riešiť aj filtrovanie dát od-do, tak aj test by bol komplikovanejší. A metóda by (s veľkou pravdepodobnosťou) robila príliš veľa.

Pri treťom teste môžeme napríklad riešiť to, ako vynulovať výpočet a začať ďalší. Môže to byť napríklad rozhodnutie, že na konci metódy calculateAverage() sa existujúce údaje vynulujú, a tak pripravia komponent na ďalší výpočet. Veľa zavisí aj od toho, odkiaľ berieme nasledujúce hodnoty a či komponent nepotrebuje oznámiť zdroju, že výpočet skončil a nabudúce očakáva novú sekvenciu.

Pri programovaní testu sa nám stane aj to, že práve testovaná funkcionalita nepatrí k podstatnej časti biznis logiky vyvíjaného komponentu. Ako príklad nech slúži už spomínaný prístup k uloženým údajom — toto naozaj nie je podstatné pri výpočte priemernej teploty. Vtedy môžme vymyslieť nové rozhranie a v teste ho nahradiť mockom. Týmto sme presunuli túto funkcionalitu do iného komponentu, ktorému sa budeme venovať neskôr, keď budeme mať istotu, že vieme ako má vyzerať jeho rozhranie. A pokračujeme vo vývoji biznis logiky nášho komponentu, tam kde sme ho prerušili.

Ja osobne preferujem pri TDD prístup zhora dole — takto mám daný kontext, v ktorom sa bude komponent používať a moje rozhodovanie sa zjednoduší. Taktiež sa tým znižuje riziko prístupu z dola hore, že hotový komponent nebude použiteľný klientom v stave, v akom sme ho dodali4.

Dobrý dizajn na prvý krát?

Dúfam, že z predchádzajúceho popisu vyplýva, že tak ako sa celý kód tvorí iteratívne, tak iteratívne prebieha aj práca na dizajne.

Čo keď zistíme, že náš komponent sa neuberá správnym smerom? Keď robíme malé kroky, nie je problém vrátiť sa o niekoľko krokov späť alebo zrefaktorovať niektorú časť.

Mne osobne by bolo podozrivé, keby som pri tvorbe komponentu pomocou TDD ani raz neprehodnotil a nezmenil žiaden detail. Prosto, keby celý dizajn bol od začiatku správny. Prečo? Lebo počas vývoja si uvedomím detaily, ktoré mi pred tým ušli, alebo ani neboli známe.

Je dôležité si uvedomiť, že dizajnové rozhodnutia sa dajú urobiť buď dopredu na papieri alebo priebežne v testoch.

Pri tom prvom spôsobe sme zahltení množstvom detailov a nemáme žiadnu spätnú väzbu, či to robíme správne.

TDD upriamuje našu pozornosť na zlomok celkového problému a ponúka ad-hoc nápady na zlepšenia. Spätná väzba je pri TDD samozrejmosť.

Záver

Keď pristupujeme ku TDD správnym spôsobom (po jednom teste, krátke testy, čo najrýchlejšie dosiahnuť zelený test), tak zvyčajne dostaneme komponent s primeraným flexibilným dizajnom.

Keď využijeme príležitosť, ktorú nám test ponúka a rozmýšľame o jednoduchom a elegantnom rozhraní komponentu, tak aj budúcim klientom sa bude ľahko používať.

Keď počúvame, čo nám test hovorívyhýbame sa práve teraz nepodstatným detailom tak testovaný kód nerobí príliš veľa, lebo tie detaily deleguje na spolupracujúce komponenty. A tie spolupracujúce komponenty dostali ideálne rozhranie pre potreby nášho komponentu. Je ľahké ku tomuto rozhraniu vymyslieť neskôr inú (v čase dizajnovania nepredvídanú) implementáciu. Takže sa náš komponent stal znovu použiteľným, lebo jeho celkové správanie sa nie je definované len kódom vnútri, ale aj tým, s akými implementáciami je prepojený.

A toto všetko sú znaky dobrého dizajnu. Môžeme byť s výsledkom našej práce spokojní. A presne o to ide.


  1. Vstupy a výstupy nie sú len priame — kde klient posiela parametre a preberá návratovú hodnotu, ale aj nepriame — komponent zavolá nejaký spolupracujúci komponent s parametrami a použije vrátenú hodnotu.
  2. Tu predpokladám, že na nedostatok prídeme skôr ako sa API stane „zverejnené” a my nemáme prístup ku všetkým klientom, aby sme ich zrefaktorovali. Ak sa to stane, tak to zvyčajne nie je chyba TDD, ale nedostatočnej špecifikácie alebo nášho nepochopenia špecifikácie.
  3. Prípadne môžeme začať testom či komponent reaguje správne, ak žiadna hodnota nie je k dispozícii. Niekedy začínam s takýmto testom a niekedy si ho nechám na neskôr. Nemám to pevne stanovené. Ale nikdy nezačínam kompletným riešením chybových stavov. To je nuda — chcem čo najskôr vidieť nejaké zmysluplné výsledky.
  4. Toto sa mi raz dávno „podarilo”. Vytvoril som super komponent, čo robil to, čo som predpokladal, že bude potrebné. Potom som spravil a dôkladne otestoval klienta, ktorý ho bude používať. Pridal som ešte jednu rovnako kvalitnú vrstvu a tam som zistil, že aplikácia to celé nemôže použiť, lebo nemá údaje, ktoré som predpokladal, že bude mať, a ja ich teda v kóde môžem bez obáv požadovať.

1 komentár k “Prečo sa netrápim s dizajnom komponentu dopredu?”

Nie je možné pridávať komentáre.