« mooc.fi

Sisällysluettelo

Tehtävät

Osa 3

Aloitamme taas edellisen viikon kertauksella ja tutustumme samalla ajan tallentamiseen tietokantaan. Tämän jälkeen tarkastelemme HTTP-protokollan tilattomuuden hyötyjä ja haittoja sekä tutustumme evästeiden käyttöön. Evästeiden avulla voidaan kiertää tilattomuuteen liittyviä rajoitteita. Tämän jälkeen tutustumme konfiguraatioprofiileihin, joiden avulla testaus- ja kehityskäytössä oleva sovellus voi käyttää erilaisia asetuksia kuin tuotantokäytössä oleva sovellus. Osion lopuksi tutustutaan web-sovellusten automaatiseen testaamiseen.

Toisessa kertaustehtävässä tehtävänäsi on täydentää kokeiden ja koekysymysten hallintaan tarkoitettua sovellusta. Sovellukseen on toteutettu valmiiksi rungot kokeiden ja koekysymysten lisäämiseen tarvittaviin kontrollereihin, jonka lisäksi sovelluksessa on osittain valmiina tarvitut Exam ja Question -entiteetit.

Lisää sovellukseen tarvittavat Repository-rajapinnat ja täydennä Exam- ja Question-entiteettejä niin, että yhteen kokeeseen monta kysymystä ja yksi kysymys voi liittyä useampaan kokeeseen. Toteuta myös kontrollereille tarvittavat metodit ja toiminnallisuudet -- saat näitä selville HTML-sivuja tarkastelemalla.

Muutama sana ajan tallentamisesta tietokantaan

Aikaa kuvaavat attribuutit tulee annotoida @Temporal-annotaatiolla, joka määrittelee mikä osa ajasta tallennetaan. Annotaatiolle annetaan parametrina TemporalType-tyyppinen arvo, joka kertoo tarkemman tallennusmuodon. Arvo TemporalType.DATE tallentaa päivämäärän (esim. 2012-09-15), TemporalType.TIME tallentaa kellonajan (esim. 18:00:00), ja arvo TemporalType.TIMESTAMP tallentaa päivän ja ajan (esim. 2012-09-15 18:00:00).

Annotaatiolla @Temporal merkityn attribuutin tulee olla joko tyyppiä java.util.Date tai tyyppiä java.util.Calendar. Alla on määritelty entiteettiluokka GroceryItem, joka kuvaa elintarviketta. Elintarvikkeella on myös parasta ennen-päivämäärä (bestBefore).

// pakkaus ja importit

@Entity
public class GroceryItem extends AbstractPersistable<Long> {

    private String name;
    @Temporal(TemporalType.DATE)
    private Date bestBefore;

    // getterit ja setterit
}

Aikamääreitä voi lähettää myös sovelluksesta palvelimelle. Tällöin kontrollerissa tulee määritellä tapa aikaa kuvaavan merkkijonon muuntamiseen. Exams and Questions -sovelluksessa oli käytössä @DateTimeFormat-annotaatio, joka toimii juurikin merkkijonon muuntamiseen.

public String addExam(@RequestParam String subject,
            @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") Date examDate) {
...

Edellä oleva hahmo yyyy-MM-dd olettaa, että palvelimelle lähetetään aika merkkijonona, jossa on ensin vuosi (4 numeroa), sitten viiva, sitten kuukausi (2 numeroa), sitten viiva, ja lopulta päivä (2 numeroa). Tämä muoto liittyy RFC3339-spesifikaatioon, joka määrittelee muodon, mitä päivämäärissä pitäisi käyttää kun tietoa lähetetään palvelimelle. Spesifikaation takia voimme olettaa (tai toivoa), että esimerkiksi HTML:n date-elementtiin syötettävä päivämäärä lähetetään palvelimelle em. muodossa.

HTTP-protokollan tilattomuus ja evästeet

HTTP on tilaton protokolla. Tämä tarkoittaa sitä, että HTTP:n näkökulmasta jokainen pyyntö on erillinen kokonaisuus, joka ei liity aiempiin pyyntöihin. Suunnittelupäätöksen taustalla oli se, että internetissä sisältöä voidaan hakea useammasta sijainnista, jolloin tilan ylläpito olisi ollut turhan hidasta (Basic HTTP as defined in 1992). Päätös tilattomuudesta oli alunperin hyvä: suurin osa verkkoliikenteestä liittyy staattisen sisällön hakemiseen, palvelinten ei tarvitse varata resursseja käyttäjän tilan ylläpitämiseen, ja palvelinten ja selainohjelmistojen toteuttajien ei tarvinnut toteuttaa mekanismeja käyttäjien tilan ylläpitämiseen.

Käyttäjän tunnistamiseen pyyntöjen välillä on kuitenkin tarvetta. Esimerkiksi verkkokaupat ja muut käyttäjän kirjautumista vaativat palvelut tarvitsevat tavan käyttäjän tunnistamiseen. Klassinen -- mutta huono -- tapa kiertää HTTP:n tilattomuus on ollut säilyttää GET-muotoisessa osoitteessa parametreja, joiden perusteella asiakas voidaan tunnistaa palvelinsovelluksessa. Tämä ei kuitenkaan ole suositeltavaa, sillä osoitteessa olevia parametreja voi muokata käsin, ja ne saattavat jättää sovellukseen ylimääräisiä tietoturva-aukkoja (palaamme tietoturvaan myöhemmin).

HTTP-protokollan tilattomuus ei pakota palvelinohjelmistoja tilattomuuteen. Palvelimella tilaa pidetään yllä jollain tavalla tekniikalla, joka ei näy HTTP-protokollaan asti. Yleisin tekniikka tilattomuuden kiertämiseen on evästeiden käyttö.

HTTP ja evästeet

Merkittävä osa verkkosovelluksista sisältää käyttäjäkohtaista toiminnallisuutta, jonka toteuttamiseen sovelluksella täytyy olla jonkinlainen tieto käyttäjästä sekä mahdollisesti käyttäjän tilasta. HTTP/1.1 tarjoaa mahdollisuuden tilallisten verkkosovellusten toteuttamiseen evästeiden (cookies) avulla.

Asettamalla käyttäjän tekemän pyynnön vastaukseen eväste, tulee käyttäjän jatkossa pyyntöä tehdessä aina palauttaa kyseinen eväste pyynnön otsaketietoina. Tämä tapahtuu automaattisesti selaimen toimesta. Evästeitä käytetään istuntojen (session) ylläpitämiseen: istuntojen avulla pidetään kirjaa käyttäjästä useampien pyyntöjen yli.

Evästeet toteutetaan otsakkeiden avulla. Kun käyttäjä tekee pyynnön palvelimelle, ja palvelimella halutaan asettaa käyttäjälle eväste, palauttaa palvelun vastauksen mukana otsakkeen Set-Cookie, jossa määritellään käyttäjäkohtainen evästetunnus. Set-Cookie voi olla esimerkiksi seuraavan näköinen:

Set-Cookie: SESS57a5819a77579dfb1a1466ccceee22a0=0hr0aa2ogdfgkelogg; Max-Age=3600; Domain=".helsinki.fi"

Ylläoleva palvelimelta lähetetty vastaus ilmoittaa pyytää selainta tallettamaan evästeen. Selaimen tulee jatkossa lisätä eväste SESS57a5819a77579dfb1a1466ccceee22a0=0hr0aa2ogdfgkelogg jokaiseen helsinki.fi-osoitteeseen. Eväste on voimassa tunnin, eli selain ja palvelin voi unohtaa sen tunnin kuluttua sen asettamisesta. Tarkempi syntaksi evästeen asettamiselle on seuraava:

Set-Cookie: nimi=arvo [; Comment=kommentti] [; Max-Age=elinaika sekunteina]
                      [; Expires=parasta ennen paiva] [; Path=polku tai polunosa jossa eväste voimassa]
                      [; Domain=palvelimen osoite (URL) tai osoitteen osa jossa eväste voimassa]
                      [; Secure (jos määritelty, eväste lähetetään vain salatun yhteyden kanssa)]
                      [; Version=evästeen versio]

Evästeet tallennetaan selaimen sisäiseen evästerekisteriin, josta niitä haetaan aina kun käyttäjä tekee selaimella kyselyn. Evästeet lähetetään palvelimelle jokaisen viestin yhteydessä Cookie-otsakkeessa.

Cookie: SESS57a5819a77579dfb1a1466ccceee22a0=0hr0aa2ogdfgkelogg

Evästeiden nimet ja arvot ovat yleensä monimutkaisia ja satunnaisesti luotuja niiden yksilöllisyyden takaamiseksi. Samaan palvelinosoitteeseen voi liittyä useampia evästeitä. Yleisesti ottaen evästeet ovat sekä hyödyllisiä että haitallisia: niiden avulla voidaan luoda yksiöityjä käyttökokemuksia tarjoavia sovelluksia, mutta niitä voidaan käyttää myös käyttäjien seurantaan ympäri verkkoa.

Evästeet ja sessiot

Kun selain lähettää palvelimelle pyynnön yhteydessä evästeen, etsii palvelin evästeen perusteella käynnissä olevaa sessiota. Jos sessio löytyy, annetaan siihen liittyvät tiedot sovelluksen käyttöön. Jos sessiota taas ei löydy, voidaan selaimelle palauttaa uusi eväste ja aloittaa uusi sessio, jolloin session tiedot löytyvät jatkossa palvelimelta.

Javassa sessioiden käsittelyyn löytyy HttpSession-luokka, joka tarjoaa välineet sessio- ja käyttäjäkohtaisen tiedon tallentamiseen. Oleellisimmat luokan metodit ovat public void setAttribute(String name, Object value), joka tallentaa sessioon arvon, sekä public Object getAttribute(String name), jonka avulla kyseinen arvo löytyy.

Session saa yksinkertaisimmillaan käyttöön lisäämällä sen kontrollerimetodin parametriksi. Tällöin Spring liittää metodiin parametrin automaattisesti. Alla on kuvattuna sovellus, joka pitää sessiokohtaista kirjaa käyttäjien tekemistä pyynnöistä.

import javax.servlet.http.HttpSession;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class VisitCountController {
    
    @RequestMapping("*")
    @ResponseBody
    public String count(HttpSession session) {
        int visits = 0;
        if (session.getAttribute("count") != null) {
            visits = (int) session.getAttribute("count");
        }
        
        visits++;
        session.setAttribute("count", visits);
        
        return "Visits: " + visits;
    }
}

Kun käyttäjä tekee ensimmäistä kertaa pyynnön sovellukseen, palauttaa sovellus merkkijonon "Visits: 1". Vastauksen yhteydessä palautetaan myös eväste. Kun käyttäjä tekee seuraavan kerran pyynnön sovellukseen, lähettää selain pyynnön yhteydessä myös evästeen palvelimelle, jolloin palvelin osaa tunnistaa käyttäjän ja hakee oikean istunnon tiedot -- vastaukseksi palautuu lopulta merkkijono "Visits: 2".

Toteuta sovellus, joka palauttaa käyttäjälle merkkijonon "Hello there!" jos käyttäjä ei ole ennen vieraillut sovelluksessa. Jos käyttäjä on vieraillut sovelluksessa aiemmin, tulee sovelluksen palauttaa käyttäjälle merkkijono "Hello again!".

HttpSession-olioon pääsee käsiksi myös muualla sovelluksessa, ja sen voi injektoida esimerkiksi palveluun @Autowired-annotaation avulla. Edellinen kontrolleriin toteutettu toiminnallisuus voitaisiin tehdä myös palvelussa.

// importit

@Service
public class CountService {

    @Autowired
    private HttpSession session;

    public int incrementAndCount() {
        int count = 0;
        if (session.getAttribute("count") != null) {
            count = (int) session.getAttribute("count");
        }
        
        count++;
        session.setAttribute("count", count);
        return count;
    }
}

Nyt kontrollerin koodi olisi kevyempi:

// importit

@Controller
public class VisitCountController {

    @Autowired
    private CountService countService;   

    @RequestMapping("*")
    @ResponseBody
    public String count() {
        return "Visits: " + countService.incrementAndCount();
    }
}

Reload Heroes -sovellus pitää kirjaa käyttäjän tekemistä sivun uudelleenlatauksista. Kun käyttäjä saapuu sovellukseen ensimmäistä kertaa, hänelle luodaan satunnainen käyttäjätunnus ja hänen vierailujen määrä asetetaan yhteen. Jokaisen uudelleenvierailun yhteydessä käyttäjän vierailujen määrä kasvaa yhdellä.

Täydennä luokan ReloadStatusService metodit seuraavasti:

  • Metodi public List<ReloadStatus> getTopList() palauttaa viisi eniten uudelleenlatauksia tehnyttä käyttäjää suuruusjärjestyksessä. Listan ensimmäisellä sijalla on eniten uudelleenlatauksia tehnyt henkilö, toisella sijalla toiseksi eniten jne.
  • Metodi public ReloadStatus reload() palauttaa pyynnön tehneeseen henkilöön liittyvän ReloadStatus-olion. Jos käyttäjä ei ole tehnyt yhtäkään pyyntöä aiemmin, tulee käyttäjälle luoda uusi tunnus sekä alustaa uudelleenlatausten määrä yhteen. Jos taas käyttäjä on tehnyt pyyntöjä aiemmin, tulee käyttäjän tekemien pyyntöjen määrää kasvattaa yhdellä. Tieto pyyntöjen määrästä tulee myös tallentaa tietokantaan.

Kun sovellus on valmis, toimii se kuten osoitteessa https://still-beyond-90359.herokuapp.com oleva sovellus.

Springin annotaatio @Autowired luo oletuksena yhden ilmentymän luokasta, joka asetetaan @Autowired-annotaatiolla määriteltyyn luokkaan. Palveluiden ja komponenttien luomista voidaan kontrolloida erillisen @Scope-annotaation avulla, mikä mahdollistaa ilmentymien luonnin esimerkiksi sessiokohtaisesti. Seuraavassa on esimerkki ostoskorista, joka on sessiokohtainen ja jokaiselle käyttäjälle oma.

// importit 

@Component
@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ShoppingCart implements Serializable {

    private Map<Item, Integer> items;

    public ShoppingCart() {
        this.items = new TreeMap<>();
    }

    public Map<Item, Integer> getItems() {
        return items;
    }

    public void setItems(Map<Item, Integer> items) {
        this.items = items;
    }

    public boolean isEmpty() {
        return items == null || items.isEmpty();
    }
}

Ylläolevasta komponentista luotavat ilmentymät ovat elossa vain käyttäjän session ajan, eli sen aikaa kun käyttäjän eväste on elossa. Ylläolevasta ostoskorista saa lisättyä ilmentymän sovellukseen aivan kuten muistakin komponenteista, eli @Autowired-annotaatiolla.

Tässä tehtävässä toteutetaan verkkokauppaan ostoskoritoiminnallisuus.

Ostoskori

Luo pakkaukseen wad.domain luokka ShoppingCart, joka tarjoaa seuraavat toiminnallisuudet.

  • Metodi getItems() palauttaa Map<Item, Long>-tyyppisen olion, joka sisältää ostoskorissa olevien tuotteiden tuotekohtaisen lukumäärän.
  • Metodi addToCart(Item item) lisää ostoskoriin yhden kappaleen Item-tyyppistä esinettä.
  • Metodi removeFromCart(Item item) poistaa ostoskorista yhden kappaleen Item-tyyppistä esinettä. Jos lukumäärä laskee nollaan, getItems()-metodin ei tule sisältää enää kyseistä tuotetta.

Kontrolleri ostoskorille

Tee ostoskorista sessiokohtainen, eli eri käyttäjien tulee saada eri ostoskori käyttöönsä. Annotaatiosta Scope on tässä hyötyä.

Luo projektiin sopiva kontrolleri, joka tarjoaa seuraavat osoitteet ja toiminnallisuudet.

  • GET /cart asettaa model-olion "items"-nimiseen attribuuttiin ostoskorin sisällön (aiempi getItems()). Pyynnön vastauksena käyttäjälle näytetään sivu, joka luodaan polussa /src/main/resources/templates/cart.html olevasta näkymästä.
  • POST /cart/items/{id} lisää ostoskoriin yhden {id}-tunnuksella tietokannasta löytyvän Item-olion. Pyyntö ohjataan osoitteeseen /cart.
  • DELETE /cart/items/{id} poistaa ostoskorista yhden {id}-tunnuksella tietokannasta löytyvän Item-olion. Pyyntö ohjataan osoitteeseen /cart.

Tilauksen tekeminen

Muokkaa luokkaa wad.service.OrderService siten, että tilaus tallennetaan tietokantaan. Tutustu luokkiin Order, OrderItem ja UserDetails ennen toteutusta. Varmista että esimerkiksi OrderItem viittaa oikeaan tietokantatauluun.

Kun tilaus on tehty, tyhjennä ostoskori.

Käyttäjän tunnistaminen ja kirjautuminen

Käyttäjän tunnistautumis- ja kirjautumistoiminnallisuus rakennetaan tyypillisesti myös evästeiden avulla. Jos käyttäjällä ei ole evästettä, mikä liittyy kirjautuneen käyttäjän sessioon, hänet ohjataan kirjautumissivulle. Kirjautumisen yhteydessä käyttäjään liittyvään evästeeseen lisätään tieto siitä, että käyttäjä on kirjautunut -- tämän jälkeen sovellus tietää, että käyttäjä on kirjautunut.

Kirjautumissivuja ja -palveluita on kirjoitettu useita, ja sellainen löytyy lähes jokaisesta web-sovelluskehyksestä. Myös Spring-sovelluskehyksessä löytyy oma projekti kirjautumistoiminnallisuuden toteuttamiseen. Käytämme seuraavaksi Spring Security -projektia. Sen saa käyttöön lisäämällä Spring Boot -projektin pom.xml-tiedostoon seuraavan riippuvuuden.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Komponentti tuo käyttöömme mm. filtterin, joka tarkastelee pyyntöjä ennen kuin ne ohjataan kontrollerimetodeille. Jos käyttäjän tulee olla kirjautunut päästäkseen haluamaansa osoitteeseen, ohjaa filtteri pyynnön tarvittaessa erilliselle kirjautumisivulle.

Kirjautumista varten tulee luoda erillinen konfiguraatiotiedosto, jossa määritellään sovellukseen liittyvät salattavat sivut. Oletuskonfiguraatiolla pääsy estetään käytännössä kaikkiin sovelluksen resursseihin. Luodaan oma konfiguraatiotiedosto SecurityConfiguration, joka sisältää sovelluksemme tietoturvakonfiguraation. Huom! Kun konfiguraatiotiedostoja alkaa olla useampia, voit asettaa ne asettaa esimerkiksi erillisen pakkauksen wad.config sisään.

// pakkaus 

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // Ei päästetä käyttäjää mihinkään sovelluksen resurssiin ilman
        // kirjautumista. Tarjotaan kuitenkin lomake kirjautumiseen, mihin
        // pääsee vapaasti. Tämän lisäksi uloskirjautumiseen tarjotaan
        // mahdollisuus kaikille. 
        http.authorizeRequests()
                .anyRequest().authenticated().and()
                .formLogin().permitAll().and()
                .logout().permitAll();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        // käyttäjällä jack, jonka salasana on bauer, on rooli USER
        auth.inMemoryAuthentication()
                .withUser("jack").password("bauer").roles("USER");
    }
}

Tietoturvakonfiguraatio koostuu kahdesta osasta. Ensimmäisessä osassa configure(HttpSecurity http) määritellään sovelluksen osoitteet joihin on pääsy kielletty tai pääsy sallittu. Toisessa osassa public void configureGlobal(AuthenticationManagerBuilder auth) taas määritellään -- tässä tapauksessa -- käytössä olevat käyttäjätunnukset ja salasanat.

Kun määritellään osoitteita, joihin käyttäjä pääsee käsiksi, on hyvä varmistaa, että määrittelyssä on mukana lause anyRequest().authenticated() -- tämä käytännössä johtaa tilanteeseen, missä kaikki osoitteet, joita ei ole erikseen määritelty, vaatii kirjautumista. Voimme määritellä osoitteita, jotka eivät vaadi kirjautumista seuraavasti:

    // ..
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/free").permitAll()
                .antMatchers("/access").permitAll()
                .antMatchers("/to/*").permitAll()
                .anyRequest().authenticated().and()
                .formLogin().permitAll().and()
                .logout().permitAll();
    }
    // ..

Ylläolevassa esimerkissä osoitteisiin /free ja /access ei tarvitse kirjautumista. Tämän lisäksi kaikki osoitteet polun /to/ alla on kaikkien käytettävissä. Loput osoitteet on kaikilta kielletty. Komento formLogin().permitAll() määrittelee sivun käyttöön kirjautumissivun, johon annetaan kaikille pääsy, jonka lisäksi komento logout().permitAll() antaa kaikille pääsyn uloskirjautumistoiminnallisuuteen.

Tehtävässä on sovellus viestien näyttämiseen. Tehtävänäsi on lisätä siihen salaustoiminnallisuus -- kenenkään muun kuin käyttäjän "maxwell_smart" ei tule päästä viesteihin käsiksi. Aseta Maxwellin salasanaksi "kenkapuhelin".

Käyttäjätunnukset tallennetaan tyypillisesti tietokantaan, mistä ne voi tarvittaessa hakea. Salasanoja ei tule tallentaa sellaisenaan, sillä ne voivat joskus päätyä vääriin käsiin. Palaamme salasanojen tallentamismuotoon myöhemmin, nyt tutustumme vain siihen liittyvään tekniikkaan.

Käyttäjätunnuksen ja salasanan noutamista varten tarvitsemme käyttäjälle entiteetin sekä sopivan repository-toteutuksen. Tarvitsemme lisäksi oman UserDetailsService-rajapinnan toteutuksen, jota käytetään käyttäjän hakemiseen tietokannasta. Allaolevassa esimerkissä rajapinta on toteutettu siten, että tietokannasta haetaan käyttäjää. Jos käyttäjä löytyy, luomme siitä User-olion, jonka palvelu palauttaa.

// importit

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private AccountRepository accountRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Account account = accountRepository.findByUsername(username);
        if (account == null) {
            throw new UsernameNotFoundException("No such user: " + username);
        }

        return new org.springframework.security.core.userdetails.User(
                account.getUsername(),
                account.getPassword(),
                true,
                true,
                true,
                true,
                Arrays.asList(new SimpleGrantedAuthority("USER")));
    }
}

Kun oma UserDetailsService-luokka on toteutettu, voimme ottaa sen käyttöön SecurityConfiguration-luokassa.

// ..

@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // mahdollistetaan h2-konsolin käyttö
        http.csrf().disable();
        http.headers().frameOptions().sameOrigin();
        
        http.authorizeRequests()
                .antMatchers("/h2-console/*").permitAll()
                .anyRequest().authenticated();
        http.formLogin()
                .permitAll();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Edellisessä esimerkissä salasanojen tallentamisessa käytetään BCrypt-algoritmia, joka rakentaa merkkijonomuotoisesta salasanasta hajautusarvon. Tällöin tietokantaan tallennettujen salasanojen vuoto ei ole täysi kriisi, vaikka ei siltikään toivottavaa.

Tehtävän ohjelmakoodiin on toteutettu käyttäjät tunnistava sovellus, joka tallentaa käyttäjien salasanat tietokantaan. Tutustu sovelluksen ohjelmakoodiin ja lisää DefaultController-luokassa olevaa ohjelmakoodia mukaillen sovellukseen toinen käyttäjä, jonka salasana on myös "smart".

Käy tämän jälkeen tarkastelemassa sovelluksen tietokantaa osoitteessa http://localhost:8080/h2-console (aseta JDBC URL -kentän arvoksi jdbc:h2:mem:testdb). Vaikka lisäämäsi käyttäjän salasana on myös "smart" pitäisi tietokannassa olevien hajautusarvojen olla erilaiset.

Kun käyttäjä on kirjautuneena, saa häneen liittyvän käyttäjätunnuksen ns. tietoturvakontekstista.

Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();

Lakiteknisiä asioita evästeisiin liittyen

Euroopan komissio on säätänyt yksityisyydensuojaan liittyvän lain, joka määrää kertomaan käyttäjille evästeiden käytöstä. Käytännössä käyttäjältä tulee pyytää lupaa minkä tahansa sisällön tallentamiseen hänen koneelleen (ePrivacy directive, Article 5, kohta (3)). Myöhemmin säädetty tarkennus tarkentaa määritelmää myös evästeiden käytön kohdalla.

Lisätietoa mm. osoitteessa http://eur-lex.europa.eu/LexUriServ/LexUriServ.do?uri=OJ:L:2009:337:0011:0036:EN:PDF sekä http://ec.europa.eu/justice/data-protection/article-29/documentation/opinion-recommendation/files/2012/wp194_en.pdf.

Konfiguraatioprofiilit

Ohjelmistotuotannossa on tyypillistä, että jokaisella ohjelmistokehittäjällä on oma ympäristö, missä sovellusta voi kehittää ja testata. Sovelluksen siirtäminen koneelta toiselle ei vaadi muutoksia ohjelman lähdekoodiin, vaan muutokset ovat heti myös muiden kokeiltavissa. Samanlaista joustavuutta odotetaan myös silloin kun sovelluksesta julkaistaan uusi versio käyttäjille.

Sovelluksen julkaisun eli esimerkiksi tuotantopalvelimelle siirtämisen ei tule vaatia muutoksia sovelluksen lähdekoodiin. Kun sovellus on julkisessa käytössä, sillä on tyypillisesti ainakin usein eri tietokantaosoite kuin sovelluskehitysvaiheessa, mahdollisesti eri tietokannanhallintajärjestelmä, sekä todennäköisiä erilaisia salasanoihin ja ohjelman tuottamiin tulostuksiin (logeihin) liittyviä asetuksia.

Tarvitsemme siis tavan olennaisten asetusten määrittelyyn ympäristökohtaisesti.

Konfiguraatioprofiilit Spring-sovelluskehyksessä

Spring-projekteissa konfiguraatiotiedostot sijaitsevat tyypillisesti kansiossa src/main/resources/. Spring etsii kansiosta tiedostoa nimeltä application.properties, johon ohjelmistokehittäjä voi määritellä sovelluksen käynnistyksen yhteydessä käytettäviä asetuksia. Asetustiedosto voi sisältää esimerkiksi tietokantaan liittyviä asetuksia:

spring.datasource.driverClassName=tietokanta-ajuri
spring.datasource.url=jdbc-osoite

Lista tyypillisistä asetuksista löytyy osoitteesta http://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html.

Käytettävän profiilin ja konfiguraatiotiedoston vaihtaminen toteutetaan tyypillisesti niin, että käytetyllä koneella on määriteltynä ympäristömuuttuja (SPRING_PROFILES_ACTIVE), joka kertoo käytettävän profiilin. Ympäristömuuttujan voi antaa myös sovellukselle parametrina sovellusta käynnistettäessä (java ... -Dspring.profiles.active=arvo ...).

Jos käytössä on aktiivista profiilia kuvaava ympäristömuuttuja, etsii Spring oletuskonfiguraatiotiedoston (application.properties) lisäksi myös aktiiviseen profiiliin liittyvää konfiguraatiotiedostoa. Jos aktiivisena profiilina on production, etsitään myös konfiguraatiotiedostoa application-production.properties. Konfiguraatioprofiili voisi esimerkiksi sisältää tietoa käytettävästä tietokanta-ajurista sekä tietokannan osoitteesta.

Ohjelmakoodissa määriteltävät profiilit

Osa Springin konfiguraatiosta tapahtuu ohjelmallisesti. Esimerkiksi tietoturvaan liittyvät asetukset, esimerkiksi aiemmin näkemämme SecurityConfiguration-luokka, määritellään usein ohjelmallisesti. Haluamme kuitenkin luoda tilanteen, missä tuotannossa on eri asetukset kuin kehityksessä.

Tämä onnistuu @Profile-annotaation avulla, jonka kautta voimme asettaa tietyt luokat tai metodit käyttöön vain kun @Profile-annotaatiossa määritelty profiili on käytössä. Esimerkiksi aiemmin luomamme SecurityConfiguration-luokka voidaan määritellä tuotantokäyttöön seuraavasti:

// importit

@Profile("production")
@Configuration
@EnableWebSecurity
public class ProductionSecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated();
        http.formLogin()
                .permitAll();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Voimme luoda erillisen tietoturvaprofiilin, jota käytetään oletuksena sovelluskehityksessä. Oletusprofiili määritellään merkkijonolla default.

// importit

@Profile("default")
@Configuration
@EnableWebSecurity
public class DefaultSecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // sallitaan h2-konsolin käyttö
        http.csrf().disable();
        http.headers().frameOptions().sameOrigin();
        
        http.authorizeRequests()
                .antMatchers("/h2-console/*").permitAll()
                .anyRequest().authenticated();
        http.formLogin()
                .permitAll();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("jack").password("bauer").roles("USER");
    }
}

Nyt tuotantoympäristössä käyttäjät noudetaan tietokannasta, mutta kehitysympäristössä on erillinen testikäyttäjä. Jos profiilia ei ole erikseen määritelty, käytetään oletusprofiilia (default).

Sovelluksen siirtäminen pilvipalveluun

Tutustutaan seuraavaksi sovelluksen siirtämiseen Heroku-pilvipalveluun. Heroku on palvelu, joka tarjoaa rajoitetun (ja ilmaisen) sijoituspaikan vähän resursseja kuluttaville sovelluksille. Toisin kuin aiemmin toteuttamiemme sovellusten tietokanta, Herokun käyttämä tietokannanhallintajärjestelmä on erillinen sovelluksesta, jolloin tietokantaan tallennetut tiedot pysyvät tietokannassa vaikka sovellus sammuisi välillä.

Seuraa ensin osoitteessa https://devcenter.heroku.com/articles/deploying-spring-boot-apps-to-heroku olevaa opasta Spring Boot -sovelluksen käytöstä Herokussa ja luo ensimmäinen pilvessä sijaitseva Heroku-sovelluksesi.

Jotta saisimme oman tietokantaa käyttävän sovelluksen Herokuun, tarvitsemme muutaman lisäaskeleen. Heroku käyttää PostgreSQL-tietokannanhallintajärjestelmää, joten tarvitsemme sen ajurin sekä erillisen tietokantayhteyksiä hallitsevan apukirjaston. Nämä saa käyttöön lisäämällä projektin pom.xml-tiedostoon seuraavat riippuvuudet.

<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-dbcp2</artifactId>
</dependency>

Nyt käytössämme on tietokanta-ajuri. Lisätään pom.xml-tiedostoon vielä liitännäinen, joka luo projektista käynnistyvät jar-tiedoston.

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <executions>
                <execution>
                    <goals>
                        <goal>repackage</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

Luodaan seuraavaksi konfiguraatiotiedosto, jolla määrittelemme sovelluksen käyttöön PostgreSQL-kielen sekä pyydämme tietokantakyselyitä näkyville. Seuraava sisältö tulee tiedostoon src/main/resources/application-production.properties.

spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.generate-ddl=true
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=update

Luodaan tämän jälkeen profiili tuotantokäyttöä varten. Profiili noudattaa Herokun opasta osoitteessa https://devcenter.heroku.com/articles/connecting-to-relational-databases-on-heroku-with-java, mutta on käytössä vain profiililla production. Tämän avulla sovellus muuntaa Herokun antaman tietokantaosoitteen sovelluksen käyttöön.

// pakkaus

import java.net.URI;
import java.net.URISyntaxException;
import org.apache.commons.dbcp2.BasicDataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

@Configuration
@Profile("production")
public class ProductionProfile {

    @Bean
    public BasicDataSource dataSource() throws URISyntaxException {
        URI dbUri = new URI(System.getenv("DATABASE_URL"));

        String username = dbUri.getUserInfo().split(":")[0];
        String password = dbUri.getUserInfo().split(":")[1];
        String dbUrl = "jdbc:postgresql://" + dbUri.getHost() + ':' + dbUri.getPort() + dbUri.getPath();

        BasicDataSource basicDataSource = new BasicDataSource();
        basicDataSource.setUrl(dbUrl);
        basicDataSource.setUsername(username);
        basicDataSource.setPassword(password);

        return basicDataSource;
    }
}

Luodaan lopulta vielä erillinen Procfile-tiedosto, jonka perusteella Heroku osaa käynnistää sovelluksen. Procfile-tiedoston sisältö on seuraava:

web: java $JAVA_OPTS -Dspring.profiles.active=production -Dserver.port=$PORT -jar target/*.jar

Tämän jälkeen sovelluksen siirtäminen tuotantoon onnistuu alkuperäisiä Herokun ohjeita noudattamalla.

Käytännössä siis Heroku määrittelee sovellukselle käynnistysparametrit sekä portin, jonka lisäksi määrittelemme aktiiviseksi profiiliksi tuotantoprofiilin. Kun sovellus siirretään herokuun, se käyttää Herokun tietokantaa. Toisaalta, kun sovellusta kehitetään paikallisesti, käytössä on testitietokanta -- ihan näppärää.

Voit kokeilla ReloadHeroes-sovellusta osoitteessa https://still-beyond-90359.herokuapp.com/.

Sovellusten testaaminen

Kuten ohjelmistotuotannossa yleensä, myös palvelinohjelmistoja rakennettaessa sovellusten testaaminen hyödyntää sekä kehitystyötä että tulevaa ylläpitotyötä. Testaaminen voidaan karkeasti jakaa kolmeen osaan: yksikkötestaukseen, integraatiotestaukseen ja järjestelmätestaukseen. Tämän lisäksi on mm. myös käytettävyys- ja tietoturvatestaus, joita emme tässä käsittele tarkemmin.

Yksikkötestauksessa testataan sovellukseen kuuluvia pienimpiä yksittäisiä komponentteja ja varmistetaan että niiden tarjoamat rajapinnat toimivat tarkoitetulla tavalla. Integraatiotestauksessa testataan että komponentit toimivat yhdessä kuten niiden pitäisi, ja järjestelmätestauksessa varmistetaan, että järjestelmä toimii vaatimusten mukaan järjestelmän käyttäjille tarjotun rajapinnan (esim. selain) kautta.

Yksikkötestaus

Yksikkötestauksella tarkoitetaan lähdekoodiin kuuluvien yksittäisten osien testausta. Termi yksikkö viittaa ohjelman pienimpiin mahdollisiin testattaviin toiminnallisuuksiin, kuten olion tarjoamiin metodeihin. Seuratessamme single responsibility principleä, jokaisella oliolla ja metodilla on yksi selkeä vastuu, jota voi myös testata. Testaus tapahtuu yleensä testausohjelmistokehyksen avulla, jolloin luodut testit voidaan suorittaa automaattisesti. Yleisin Javalla käytettävä testauskehys on JUnit, jonka saa käyttöön lisäämällä siihen liittyvän riippuvuuden pom.xml-tiedostoon.

<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.12</version>
    <scope>test</scope>
</dependency>

Yksittäisen riippuvuuden määre scope kertoo milloin riippuvuutta tarvitaan. Määrittelemällä scope-elementin arvoksi test on riippuvuudet käytössä vain testejä ajettaessa. Uusia testiluokkia voi luoda NetBeansissa valitsemalla New -> Other -> JUnit -> JUnit Test. Tämän jälkeen NetBeans kysyy testiluokalle nimeä ja pakkausta. Huomaa että lähdekoodit ja testikoodit päätyvät erillisiin kansioihin -- juurin näin sen pitääkin olla. Kun testiluokka on luotu, on projektin rakenne kutakuinkin seuraavanlainen.

.
|-- pom.xml
`-- src
    |-- main
    |   |-- java
    |   |   `-- wad
    |   |       `-- ... oman projektin koodit
    |   |-- resources
    |           `-- ... resurssit, mm. konfiguraatio ja thymeleafin templatet
    |           
    `-- test
        `-- java
            `-- wad
                `-- ... testikoodit!
                

Tehtäväpohjissa JUnit-testikirjasto on valmiina mukana. Yksikkötestauksesta JUnit-kirjaston avulla löytyy pieni opas kurssin Ohjelmistotekniikan menetelmät sivuilta.

Integraatiotestaus

Spring tarjoaa spring-test-komponentin, jonka avulla JUnit-kirjasto saa @Autowired-annotaatiot toimimaan. Tämän kautta pääsemme tilanteeseen, missä voimme injektoida testimetodille esimerkiksi kokonaisen palvelun, sekä testata sen tarjoamien metodien toimintaa. Testattava palvelu voi hyödyntää muita komponentteja, jolloin testauksen kohteena on kokonaisuuden toiminta yhdessä.

Spring test-komponentista on myös Spring Boot -projekti, jonka voimme ottaa käyttöömme lisäämällä seuraavan riippuvuuden pom.xml-tiedostoon. Käytetyn riippuvuuden versio liittyy Spring Boot -projektin versioon, eikä sitä tarvitse määritellä tarkemmin.

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>

Yksittäisten palvelujen testaamisessa tarvitsemme testiluokkien alkuun kaksi annotaatiota. Annotaatio @RunWith(SpringRunner.class) kertoo että käytämme Springiä yksikkötestien ajamiseen ja annotaatio @SpringBootTest lataa sovelluksen osat käyttöön. Testiluokka, johon injektoidaan automaattisesti MyService-palvelu, näyttää seuraavalta.

@RunWith(SpringRunner.class)
@SpringBootTest
public class ApplicationTest {

    @Autowired
    private MyService myService;

    // ... testit jne
}

Käynnistämällä Springin osana testejä, saamme käyttöömme oliokontekstin, jonka avulla voimme asettaa testattavat oliot testiluokkiin testaamista varten. Testattavien olioiden riippuvuudet asetetaan myös automaattisesti, eli jos MyService sisältää muita komponentteja, on ne myös automaattisesti asetettu.

Voimme ylläolevalla lähestymistavalla testata myös sitä, että sovelluksemme eri osat toimivat yhteen toivotusti. Oletetaan, että käytössämme on luokka PersonService, joka tarjoaa metodin save, jonka pitäisi tallentaa parametrina annettava Person-olio tietokantaan. Tämän lisäksi käytössämme on PersonRepository, jolla on metodi findByName, minkä avulla voimme hakea tietokannasta henkilön sen nimen perusteella. Kummatkin toteutukset voidaan injektoida suoraan testiluokkaan, testi itsessään ensin tallentaa henkilön PersonService-olion avulla, ja tarkistaa sen jälkeen PersonRepository-oliolta että kyseinen henkilö on olemassa.

// importit

@RunWith(SpringRunner.class)
@SpringBootTest
public class ApplicationTest {

    @Autowired
    private PersonService personService;

    @Autowired
    private PersonRepository personRepository;

    @Test
    public void testSavePerson() {
        Person person = new Person();
        person.setName("Jack Bauer");
        person.setAge(42);

        personService.save(person);

        Person retrieved = personService.findByName("Jack Bauer");
        assertNotNull(retrieved);
        assertEquals(42, retrieved.getAge());
    }

    // ja muita testejä
}

Ylläolevassa testissä olevat metodit assertNotNull ja assertEquals ovat JUnit-kirjaston tarjoamia toiminnallisuuksia.

Järjestelmätestaus

Järjestelmätestauksessa on tarkoitus varmistaa, että järjestelmä toimii toivotulla tavalla. Järjestelmää testataan saman rajapinnan kautta, kuin mitä sen loppukäyttäjät käyttävät. Järjestelmätestaukseen on monenlaisia työkaluja, joista käsittelemme tässä kahta. Tutustumme ensin integraatiotestauksessa käytetyn spring-test-komponenttiin järjestelmätason testaustoiminnallisuuteen, jonka jälkeen tutustumme harjoitustehtävän kautta Selenium ja FluentLenium -kirjastoihin.

Springin tarjoama spring-test tarjoaa tuen järjestelmätestaamiseen. Annotaatiolla @SpringBootTest testeillä on käytössä myös web-sovelluksen konteksti, jonka avulla voidaan luoda MockMvc-olio. MockMvc-oliolla pystymme tekemään pyyntöjä sovelluksen tarjoamiin osoitteisiin, tarkistelemaan pyyntöjen onnistumista, sekä tarkastelemaan vastauksena saatua dataa.

Alla oleva esimerkki käynnistää sovelluksen ja tekee kolme GET-pyyntöä osoitteeseen /messages. Ensimmäinen pyyntö liittyy testiin, missä varmistetaan että vastaus on sisältää statuskoodin 200 eli "OK", toinen pyyntö liittyy testiin joka varmistaa että vastauksen tyyppi on JSON-muotoista dataa, ja kolmas pyyntö tarkistaa että vastauksessa on merkkijono "Awesome". Alun setUp-metodi luo MockMvc-olion injektoidun palveinkontekstin perusteella.

// muut importit

// mm. mockMvc:n get- ja post-metodit
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@RunWith(SpringRunner.class)
@SpringBootTest
public class MessagesTest {

    @Autowired
    private WebApplicationContext webAppContext;

    private MockMvc mockMvc;

    @Before
    public void setUp() {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext).build();
    }

    @Test
    public void statusOk() throws Exception {
        mockMvc.perform(get("/messages"))
                .andExpect(status().isOk());
    }


    @Test
    public void responseTypeApplicationJson() throws Exception {
        mockMvc.perform(get("/messages"))
                .andExpect(content().contentType(MediaType.APPLICATION_JSON));
    }

    @Test
    public void responseContainsTextAwesome() throws Exception {
        MvcResult res = mockMvc.perform(get("/messages"))
                .andReturn();

        String content = res.getResponse().getContentAsString();
        Assert.assertTrue(content.contains("Awesome"));
    }
}

Voit myös testata modeliin asetettujen attribuuttien olemassaoloa ja oikeellisuutta. Olemassaolon voi tarkistaa model()-metodin kautta, ja MvcResult-olion kautta pääsee käsiksi modelin sisältöön.

    @Test
    public void modelHasAttributeMessages() throws Exception {
        mockMvc.perform(get("/messages"))
                .andExpect(model().attributeExists("messages"));
    }

    @Test
    public void messagesCorrect() throws Exception {
        MvcResult res = mockMvc.perform(get(API_URI))
                .andReturn();

        // oletetaan, että kontrolleri asettaa listan Message-tyyppisiä olioita
        // modeliin

        List<Message> messages = (List) res.getModelAndView().getModel().get("messages");

        // tarkista lista
    }

MockMvc:n avulla voi testata käytännössä suurinta osaa palvelinsovellusten toiminnallisuudesta, mutta samalla se tarjoaa pääsyn samaan rajapintaan kuin mitä selain käsitteelee.

Muistamme edellisestä osiosta tehtävän, missä tehtiin sovellus lentokoneiden ja lentokenttien hallintaan. Tässä tehtävässä harjoitellaan hieman sekä integraatio- että järjestelmätestausta.

Huom! Tässä tehtävässä ei ole automaattisia testejä, joilla testattaisiin kirjoittamiasi testejä. Palauttaessasi tehtävän olet tarkistanut, että kirjoittamasi testit toimivat kuten tehtävänannossa on kuvattu.

AirportServiceTest

Sovellusessa on luokka AirportService, mikä sijaitsee pakkauksessa wad.service. Sille ei kuitenkaan ole yhtäkään testiä :(

Lisää testikansioon (Test Packages) pakkaus wad.service, ja luo sinne luokka AirportServiceTest.

Lisää luokalle tarvittavat annotaatiot sekä oliomuuttujat, ja toteuta luokalle testimetodit, joiden avulla testataan että haluttu lentokone todellakin lisätään lentokentälle. Haluat ainakin tietää että:

  • Kun lentokone on lisätty lentokentälle, tietokannasta samalla tunnuksella haettavalla lentokoneella on asetettu lentokenttä, ja se on juuri se lentokenttä mihin kone on lisätty.
  • Kun lentokone on lisätty lentokentälle, lentokentältä löytyy myös kyseinen kone.
  • Kun lentokone on lisätty yhdelle lentokentälle, se ei ole muilla lentokentillä.
  • Lentokoneen lisääminen samalle lentokentälle useasti ei johda siihen, että lentokenttä sisältää saman koneen monta kertaa.

Aina kun lisäät yksittäisen testin, voit ajaa testit klikkaamalla projektia oikealla hiirennapilla ja valitsemalla "Test".

AircraftControllerTest

Luo testikansioon pakkaus wad.controller ja lisää sinne luokka AircraftControllerTest. Lisää luokkaan tarvittavat määrittelyt, jotta voit käyttää MockMvc-komponenttia testeissä.

Tee seuraavat testit:

  • Kun osoitteeseen /aircrafts tehdään GET-pyyntö, vastauksen status on 200 (ok) ja vastauksen model-oliossa on parametrit aircrafts ja airports.
  • Kun osoitteeseen /aircrafts tehdään POST-pyyntö, jonka parametriksi annetaan name-kenttä, jonka arvona on "HA-LOL", pyynnön vastaukseksi tulee uudelleenohjaus. Tee tämän jälkeen erillinen kysely tietokantaan esim. AircraftRepository:n avulla, ja varmista, että tietokannasta löytyy lentokone, jonka nimi on HA-LOL.
  • Kun osoitteeseen /aircrafts tehdään POST-pyyntö, jonka parametriksi annetaan name-kenttä, jonka arvona on "XP-55", pyynnön vastaukseksi tulee uudelleenohjaus. Tee tämän jälkeen GET-pyyntö osoitteeseen /aircrafts, ja tarkista että pyynnön vastauksena saatavan model-olion sisältämässä "aircrafts"-listassa on juuri luotu lentokone.

Tässäkin tehtävässä, aina kun lisäät yksittäisen testin, voit ajaa testit klikkaamalla projektia oikealla hiirennapilla ja valitsemalla "Test".

MockMvc:n lisäksi järjestelmätestaukseen käytetään melko paljon käyttöliittymän testaamiseen tarkoitettua Seleniumia ja siihen liittyviä lisäosia kuten FluentLenium. Käytännössä edellämainitut ovat web-selaimen toimintojen automatisointiin tarkoitettuja välineitä, jotka antavat sovelluskehittäjälle mahdollisuuden käydä läpi sovelluksen käyttöliittymää ohjelmallisesti.

Lisätään FluentLenium-kirjaston vaatimat riippuvuudet, oletetaan että testit kirjoitetaan JUnit-testikirjaston avulla (FluentLenium tarjoaa myös muita vaihtoehtoja).

<dependency>
    <groupId>org.fluentlenium</groupId>
    <artifactId>fluentlenium-core</artifactId>
    <version>0.13.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.fluentlenium</groupId>
    <artifactId>fluentlenium-assertj</artifactId>
    <version>0.13.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.seleniumhq.selenium</groupId>
    <artifactId>htmlunit-driver</artifactId>
</dependency>

FluentLenium testien kirjoittaminen

Ajatellaan loppukäyttäjän haluamaa toiminnallisuutta "Käyttäjä voi ilmoittautua oppitunnille". Järjestelmä tarjoaa sivun, jonka ensimmäinen linkki vie ilmoittautumissivulle. Ilmoittautumissivulla tulee olla tietty otsikko -- varmistamme, että olemme oikealla sivulla. Tämän lisäksi ilmoiuttautumissivulla on lomakekenttä, jonka attribuutin id arvo on "name". Jos kentällä on attribuutti id, voidaan se valita kirjoittamalla "#kentannimi". Täytetään kenttään arvo "Bob" ja lähetetään lomake. Tämän jälkeen sivulla tulee olla teksti "Ilmoittautuminen onnistui!".

// importit

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ElokuvatietokantaTest extends FluentTest {

    public WebDriver webDriver = new HtmlUnitDriver();

    @Override
    public WebDriver getDefaultDriver() {
        return webDriver;
    }

    @LocalServerPort
    private Integer port;

    @Test
    public void canSignUp() {
        goTo("http://localhost:" + port);

        click(find("a").first());
        assertEquals("Ilmoittautuminen", title());

        fill(find("#name")).with("Bob");
        submit(find("form").first());

        assertTrue(pageSource().contains("Ilmoittautuminen onnistui!"));
    }
// ...
                

Yllä annotaatio @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) käynnistää palvelimen integraatiotestausta satunnaisessa portissa, joka saadaan muuttujaan port annotaation @LocalServerPort avulla. Luomme tämän lisäksi ajurin näkymien läpikäyntiin (rivi private WebDriver webDriver = new HtmlUnitDriver();, joka tarjotaan testiluokkamme peritylle toiminnallisuudelle (FluentTest) metodin getDefaultDriver() kautta.

Yllä menemme ensin paikalliseen osoitteeseen http://localhost:portti, missä portin numero on satunnaisesti valittu -- surffaamme siis haluttuun osoitteeseen. Haemme tämän jälkeen ensimmäisen linkin, eli a-elementin sivulta, ja klikkaamme sitä. Tämän jälkeen tarkistamme, että sivun otsake on Ilmoittautuminen. Tätä seuraa kentän, jonka id on "name" täyttäminen "Bob"-merkkijonolla, jonka jälkeen lomake lähetetään. Kun lomake on lähetetty, haetaan sivun lähdekoodista tekstiä "Ilmoittautuminen onnistui!". Jos tekstiä ei löydy, testi epäonnistuu.

FluentLenium-kirjastoon liittyvää dokumentaatiota löytyy osoitteesta http://www.fluentlenium.org/, jonka lisäksi googlesta löytyy apua seuraavaan tehtävään.

Muistamme toisesta osiosta myös tehtävän, missä tehtiin sovellus elokuvien ja näyttelijöiden hallintaan. Tässä tehtävässä harjoitellaan hieman järjestelmätestausta FluentLeniumin avulla. Tehtävässä ei ole automaattisia testejä, sillä sinun tehtävänä on toteuttaa ne.

Näyttelijän lisääminen ja poistaminen

Luo testikansioon wad.selenium testiluokka ActorTest, johon asetat Selenium-testaamiseen tarvittavat komponentit.

Toteuta testi, jolla varmistetaan että käyttäjän lisääminen ja poistaminen onnistuu. Testin tulee toimia seuraavasti:

  1. Menee näyttelijäsivulle
  2. Tarkistaa ettei sivulla ole tekstiä "Van Damme"
  3. Etsii kentän jonka id on "name", asettaa kenttään tekstin "Van Damme", ja lähettää lomakkeeseen liittyvän lomakkeen.
  4. Tarkistaa että sivulla on teksti "Van Damme"
  5. Klikkaa "Van Damme"en liittyvää poista-nappia
  6. Tarkistaa että sivulla ei ole tekstiä "Van Damme"

Toteuta seuraavaksi testi, joka tekee seuraavat askeleet:

  1. Menee näyttelijäsivulle
  2. Tarkistaa ettei sivulla ole tekstiä "Van Damme"
  3. Tarkistaa ettei sivulla ole tekstiä "Chuck Norris"
  4. Etsii kentän jonka id on "name", asettaa kenttään tekstin "Chuck Norris", ja lähettää lomakkeeseen liittyvän lomakkeen.
  5. Tarkistaa ettei sivulla ole tekstiä "Van Damme"
  6. Tarkistaa että sivulla on teksti "Chuck Norris"
  7. Etsii kentän jonka id on "name", asettaa kenttään tekstin "Van Damme", ja lähettää lomakkeeseen liittyvän lomakkeen.
  8. Tarkistaa että sivulla on teksti "Van Damme"
  9. Tarkistaa että sivulla on teksti "Chuck Norris"
  10. Klikkaa "Van Damme"en liittyvää poista-nappia
  11. Klikkaa henkilön "Chuck Norris" poista-nappia
  12. Tarkistaa ettei sivulla ole tekstiä "Van Damme"
  13. Tarkistaa että sivulla on teksti "Chuck Norris"

Elokuvan lisääminen ja näyttelijän lisääminen elokuvaan

Luo testikansioon wad.selenium testiluokka MovieTest, johon asetat Selenium-testaamiseen tarvittavat komponentit.

Toteuta seuraavat askeleet

  1. Mene elokuvasivulle
  2. Tarkista että sivulla ei ole tekstiä "Bloodsport"
  3. Tarkista että sivulla ei ole tekstiä "Van Damme"
  4. Etsi kenttä jonka id on "name" ja lisää siihen arvo "Bloodsport"
  5. Etsi kenttä jonka id on "lengthInMinutes" ja lisää siihen arvo "92"
  6. Lähetä kenttään liittyvä lomake
  7. Tarkista että sivulla on teksti "Bloodsport"
  8. Tarkista että sivulla ei ole tekstiä "Van Damme"
  9. Mene näyttelijäsivulle
  10. Tarkista ettei sivulla ole tekstiä "Van Damme"
  11. Etsi kenttä jonka id on "name", aseta kenttään teksti "Van Damme", ja lähetä lomake.
  12. Tarkistaa että sivulla on teksti "Van Damme"
  13. Etsi linkki, jossa on teksti "Van Damme" ja klikkaa siitä.
  14. Etsi nappi, jonka id on "add-to-movie", ja klikkaa sitä.
  15. Mene elokuvasivulle
  16. Tarkista että sivulla on teksti "Bloodsport"
  17. Tarkista että sivulla on teksti "Van Damme"

Suorita taas testit klikkaamalla projektia oikealla hiirennäppäimellä ja valitsemalla Test.

Konfiguraatioprofiilit ja testaaminen

Testien ajamisessa voidaan käyttää myös konfiguraatioprofiileja. Kun sovellukselle on määritelty erilaisia profiileja, esimerkiksi kirjautumiseen liittyvät konfiguraatiot, voidaan tietty profiili aktivoida testeissä. Testin aktivointi tapahtuu annotaation ActiveProfiles avulla.

Alla olevassa esimerkissä testiluokan testit suoritetaan siten, että käytössä on profiiliin "test" liittyvä konfiguraatio, eli se konfiguraatio, joka on määritelty annotaatiolla @Profile("test") (tai @Profile(values = {"test", "muita"}) jos halutaan että samaa konfiguraatiota käytetään useammassa profiilissa.

@RunWith(SpringRunner.class)
@SpringBootTest
@ActiveProfiles("dev")
public class ApplicationTest {
// ...