Prihlasovacia obrazovka: príklad návrhu triedy, unit testov a postupného zlepšovania

Vytvor login skrín!

“Vytvor login skrín! [prihlasovaciu obrazovku, pozn. red.],” povedal raz nemenovaný šéf nemenovanému zamestnancovi. Skvelá úloha!

Login screen je bránou ku každému systému, azda každý vývojár i používateľ má predstavu o jeho výzore a správaní, a zároveň jeho implementácia je pomerne jednoduchá.

Základný scenár použitia môže vyzerať nasledovne:

  1. Používateľ zadá prihlasovacie meno a heslo.
  2. Systém overí existenciu prihlasovacieho mena a správnosť hesla a prihlási používateľa.
  3. Ak používateľ neexistuje alebo heslo nie je správne, zobrazí sa chybové hlásenie.

Z hľadiska návrhu si vystačíme s jedinou triedou LoginService, ktorá bude mať metódu login(). Čo však s parametrami a návratovými hodnotami?

Najjednoduchšia možnosť ukazuje dva parametre: reťazcový login a rovnako reťazcové heslo. Metóda môže vracať true, ak sa prihlásenie podarilo.

Skúsme si naprogramovať najjednoduchší optimistický scenár: používateľ sa prihlási pomocou mena a hesla a uvidí na konzole potvrdenie alebo zamietnutie prihlásenia.

Zdá sa vám, že by sme mali uvažovať nad návrhom viac? Jedna zo zásad extrémneho programovania hovorí, že nebude to potrebné, najmä preto, že špiritizované veci sa naozaj nemusia použiť.

Prvá verzia spúšťacej triedy

Spúšťač

Napíšme kód do triedy LoginServiceRunner s metódou main(), ktorou si vieme našu triedu rovno vyskúšať.

public class LoginServiceRunner {
    public static void main(String[] args) {
        String login = "novotnyr";
        String password = "d3hkaspeao";

        LoginService loginService = new LoginService();
        boolean isLoggedIn = loginService.login(login, password);
        if(isLoggedIn) {
            System.out.println("Používateľ bol prihlásený.");
        } else {
            System.out.println("Používateľ nebol prihlásený.");
        }
    } 
}

…a samotná trieda

Tento kód však nie je skompilovateľný: veď neexistuje ani trieda LoginService, nieto ešte jej metódy. Doplňme ju:

package sk.upjs.ics.novotnyr.loginform;

public class LoginService {
    private static final String DEFAULT_LOGIN = "novotnyr";

    private static final String DEFAULT_PASSWORD = "d3hkaspeao";

    boolean login(String login, String password) {
        return DEFAULT_LOGIN.equals(login) && DEFAULT_PASSWORD.equals(password);
    }       
}

Je to najprimitívnejšia verzia podporujúca len jediného používateľa, ktorého login a heslo sú zadrôtované v kóde (a kvôli prehľadnosti v konštantách). Úspešné prihlásenie sa vykoná vtedy, ak sa zadaný login a heslo zhoduje s tým, čo je uvedené v triede.

Technická poznámka: všimnite si, že konštanty možno použiť na “yodovské” vyjadrenie porovnania, ktoré elegantne ošetrí aj prípady, keď je buď prihlasovacie meno alebo heslo null.

Keď sa zamyslíme nad cestou, ktorou sme prešli, nezdá sa byť zvláštna? Nemalo by to byť naopak? Najprv napísať triedu a potom doplniť spúšťací, či overujúci kód? Tento prístup, prevzatý zo zásady extrémneho programovania (nazývaný “testy pred kódom”) má mnoho výhod: sprehľadňuje návrh tried, automatizuje testovanie a šetrí čas v neskorších fázach projektu.

Ak sme dopracovali triedu, už nič nebráni spusteniu projektu a zisteniu, či prihlásenie uspelo alebo nie.

V ďalšej fáze skúsme zistiť, či vieme korektne ošetriť aj prípad neúspešného prihlásenia. Z programátorského hľadiska môžeme použiť megatrik: tzv. komentár:

String login = "novotnyr";
// String password = "d3hkaspeao";
String password = "zleheslo";

Toto však nie je ktovieaká zábava: hlavne komu by sa chcelo odkomentovávať a zakomentovávať varianty. (Ale existujú vývojári, ktorí to takto robia!)

Použime na to radšej inteligentnú knižnicu pre jednotkové testovanie, napríklad JUnit.

Knižnica pre jednotkové testy

Jednotkový test je, stručne povedané, trieda, ktorá testuje kód inej triedy (tou jednotkou z názvu je práve trieda). Jednotlivé testovacie prípady sú uvedené v samostatných metódach:

Jednotkový test potom môže vyzerať nasledovne:

import org.junit.Test;
import static org.junit.Assert.*;

public class LoginServiceTest {

    @Test
    public void testUspesnyLogin() {
        String login = "novotnyr";
        String password = "d3hkaspeao";

        LoginService loginService = new LoginService();
        boolean isLoggedIn = loginService.login(login, password);
        assertTrue(isLoggedIn);
    }
}

Technická poznámka: jednotkový test vytvoríme v NetBeanse cez File | New File, v strome vyberieme Unit Tests a následne zvolíme možnosť pre JUnit 4.0. Uvedieme názov triedy, skontrolujeme balíček (nepoužijeme štandardný prázdny), a zrušíme začiarknutie pre generovaný kód.

Ak vytvárame triedu v NetBeanse pomocou finty s Alt+Enter, teda “opravou” kompilačných chýb, uistime sa, že novovytvorená trieda bude v sekcii Source Packages a nie v Test Packages, predídeme tým mnohým problémom.

Skúsme teraz spustiť jednotkový test v NetBeanse: kliknime pravým do kódu metódy a spusťme test cez Run Focused Test Method.

Jednotkový test uspeje bez problémov, a môžeme byť šťastní. Lenže…

Úvahy nad kódom

…máme aj neúspešné vetvy v scenári. Čo ak používateľov zadaný login neexistuje? Čo ak je heslo nesprávne? Zrejme nestačí len indikovať, že prihlásenie prebehlo alebo nie, ale chceli by sme vedieť prečo prihlásenie zlyhalo, aby sme vedeli naviesť používateľa. (A nie, univerzálna hláška “Neúspešný login” nestačí.)

Napríklad v servletoch je autentifikácia riešená metódou:

void login(java.lang.String username, java.lang.String password) throws ServletException

Ak login neuspeje, vyhodí sa výnimka ServletException, a ak uspeje, nevráti sa nič.

Veľmi pekný a kompromisný variant je inšpirovaný projektom Spring Security: namiesto dvoch parametrov metóda prijme objekt typu User (používateľ) s prihlasovacími údajmi a v prípade úspešného prihlásenia to vráti tzv. principal, teda objekt reprezentujúci prihláseného používateľa. Neraz sa pre jednoduchosť obidva objekty (teda na vstupe i výstupe) stotožňujú, teda používajú sa rovnaké dátové typy.

Použitie objektu typu User je zároveň omnoho robustnejšie než dvojica loginu a hesla. Ak si totiž neskôr ktosi uzmyslí, že sa chce prihlasovať povedzme e-mailom alebo loginom a heslom (alebo telefónnym číslom a heslom, ako je to na mobilnom Facebooku), nemusíme prekopávať celú hlavičku metódy (teda dodávať šialené kombinácie prihlasovacích možností), ale stačí dodať nové inštančné premenné do triedy User.

Stále sme však nevyriešili prípad, ak sa prihlásenie nepodarí: v takom prípade môžeme jednoducho vyhodiť vlastnú výnimku: napr. LoginException, ktorá bude indikovať stav, keď sa prihlásenie nepodarilo kvôli nesprávnym credentials, teda prihlasovacím údajom.

Upravený test vo svetle nových okolností teda môže vyzerať takto:

@Test
public void testUspesnyLogin() {
    User user = new User("novotnyr");
    user.setPassword("d3hkaspeao");

    LoginService loginService = new LoginService();
    User principal = loginService.login(user);
    assertNotNull(principal);
}

Samozrejme, zatiaľ si test nevieme skompilovať — chýbajú nám totiž triedy pre používateľa/principala User. Tá môže byť jednoduchá:

public class User {
    private String login;

    private String password;

    /*.. gettre a settre, konštruktory */
}

Technická poznámka: na vytvorenie getterov a setterov a konštruktorov môžeme v NetBeanse využiť skratku Alt+Insert, ktorou ich všetky nagenerujeme.

Samotná služba LoginService sa teraz musí tiež upraviť:

package sk.upjs.ics.novotnyr.loginform;

import javax.security.auth.login.LoginException;
import sk.upjs.ics.novotnyr.loginform.User;

public class LoginService {

    private static final String DEFAULT_LOGIN = "novotnyr";
    private static final String DEFAULT_PASSWORD = "d3hkaspeao";

    public User login(User user) throws LoginException {
        if (!DEFAULT_LOGIN.equals(user.getLogin())
                || !DEFAULT_PASSWORD.equals(user.getPassword())) {
            throw new LoginException("Nesprávny login alebo heslo pre používateľa " + user.getLogin());
        }

        return user;
    }
}

Opäť overíme existenciu prihlasovacieho mena a hesla a ak nie sú správne, vyhodíme výnimku: v tomto prípade využijeme javácky zabudovaný balíček adresujúci bezpečnosť a vyhodíme výnimku javax.security.auth.login.LoginException;

Skúsme teraz napodobniť neúspešné prihlásenie, pre ktoré vytvoríme samostatný jednotkový test.

@Test(expected=LoginException.class)
public void testNeuspesnyLogin() {
    User user = new User("novotnyr");
    user.setPassword("illegal_password");

    LoginService loginService = new LoginService();     
    loginService.login(user);
}

Test v tomto prípade uspeje len vtedy, ak sa vyhodí výnimka LoginException, čo dosiahneme parametrom anotácie expected, teda názvom očakávanej výnimky.

Upratovanie v testoch

Zatiaľ sme napísali dva testy: jeden pre úspešné prihlásenie a druhý pre prípad, ked zadáme zlý login/heslo. Pozrime sa na ne vedľa seba:

@Test
public void testUspesnyLogin() {
    User user = new User("novotnyr");
    user.setPassword("d3hkaspeao");
    
    LoginService loginService = new LoginService();
    User principal = loginService.login(user);
    assertNotNull(principal);
}
@Test
public void testUspesnyLogin() {
    User user = new User("novotnyr");
    user.setPassword("illegal_password");
    
    LoginService loginService = new LoginService();     
    loginService.login(user);
}

V oboch prípadoch vytvárame jedného používateľa User a jednu inštanciu LoginService, aby sme vôbec pripravili základné objekty, ktoré sa v testoch budú používať.

Ak zistíme, že niektoré pasáže testov sa pravidelne opakujú, čo sa týka najmä inicializačnej fázy, môžeme ich vytiahnuť do samostatnej metódy, ktorá sa vykoná pred každým testom.

Metóda @Before

Metóda, ktorá má anotáciu @Before sa vykoná pred každým jednotkovým testom. Objekty, ktoré sa chcú využiť vo viacerých testoch, v nej môžeme nainicializovať a popchať do inštančných premenných triedy LoginServiceTest obaľujúcej jednotkové testy.

Oba testy môžu následne využívať nainicializované inštančné premenné s objektami, čím sa skrátia a sprehľadnia:

public class LoginServiceTest {
    private LoginService loginService;
    private User user;

    @Before
    public void setUp() {
        loginService = new LoginService();
        user = new User("novotnyr");
        user.setPassword("d3hkaspeao");
    }

    @Test
    public void testUspesnyLogin() throws LoginException {
        User principal = loginService.login(user);
        assertNotNull(principal);
    }

    @Test(expected=LoginException.class)
    public void testNeuspesnyLogin() throws LoginException {
        user.setPassword("____________");
        loginService.login(user);
    }    
}

Ďalšie vylepšenie kódu

Kód môžeme následne ešte vylepšiť: namiesto zadrôtovaného jediného loginu a hesla môžeme v triede LoginService využiť zoznam používateľov, teda zaviesť inštančnú premennú List<User>, ktorú vyplníme v konštruktore. Následne primerane upravíme kód metódy login() a spustením jednotkového testu overíme funkcionalitu. Táto zmena funkčnosti triedy bez zmeny vonkajšieho správania sa nazýva refactoring.

Pridaj komentár

Vaša e-mailová adresa nebude zverejnená. Vyžadované polia sú označené *