Miniprojektin sovellus toteutetaan kurssilta Tietokannat ja Web-ohjelmointi tutulla Flask-sovelluskehyksellä, ja sen tulee tallentaa tietonsa PostgreSQL-tietokantaan.

Flask-sovelluksen konfigurointi siten, että esim. GitHub Actionien avulla tapahtuva automatisoitu testaus on jossain määrin haastavaa, ja tämän takia kurssille on luotu pohja https://github.com/ohjelmistotuotanto-hy/miniprojekti-boilerplate joka auttaa alkuun.

Pohja sisältää yksinkertaisen Todo- eli työlistasovelluksen joka tallettaa tiedot PostgreSQL-tietokantaan, muutaman Robot-testin, unittest-kirjastolla tehdyn yksikkötestin sekä testit suorittavan GitHub Actions workflown.

Jos haluatte välttyä konfiguraation aiheuttamasta tuskasta, on suositeltavaa, että teette oman sovelluksenne pohjaa mukaillen, lue myös Protips!

Viikon 3 laskarit on syytä tehdä ennen kuin aloitat miniprojektin koodaamisen!

Sovelluksen toiminnallisuus

Sovelluksen tarjoama toiminnallisuus on hyvin vähäinen. Sovellus näyttää Todot ja niiden tilan, sekä mahdollistaa tekemättömän työn merkkaamisen tehdyksi:

Käyttäjä voi myös luoda uusia Todoja:

Muuta toiminnallisuutta sovellus ei valitettavasti tarjoa.

Sovelluksen käynnistäminen

Sovellus siis tarvitsee toimiakseen PostgreSQL-tietokannan. Kannattaa käyttää jotain internetpalveluna tarjottavaa tietokantaa. Eräs hyvä ja ilmainen vaihtoehto on https://aiven.io.

Sovelluksen juureen tulee luoda ympäristömuuttujat määrittelevä tiedosto nimeltään .env, jonka sisältö on seuraava

DATABASE_URL=postgresql://xxx
TEST_ENV=true
SECRET_KEY=satunnainen_merkkijono

Tietokannan osoitteen määrittelevä DATABASE_URL on aiven.io:sta löytyvä Service URI:

HUOM urlin alun on oltava muodossa postgresql:// EI muodossa postgres:// kuten aiven.io:ssa .

Sovellus käynnistetään Poetry-virtuaaliympäristössä komennolla

python src/index.py

Huomaa että ennen kuin käynnistät sovelluksen ensimmäisen kerran, tulee suorittaa komento, joka luo sovelluksen käyttämän tietokantataulun:

python src/db_helper.py

Yksikkötestit suoritetaan komennolla:

pytest src/tests

Robot-testit suoritetaan komennolla:

robot src/story_tests

Sovelluksen rakenne

Rakenteeltaan sovellus on samankaltainen kuin viikon 3 laskareiden WebLogin-sovellus. Sovellus jakautuu seuraaviin tiedostoihin

├── index.py
├── app.py
├── config.py
├── util.py
├── db_helper.py
├── entities
│   └── todo.py
├── repositories
│   └── todo_repository.py
├── templates
│   ├── index.html
│   ├── layout.html
│   └── new_todo.html
├── story_tests
│   ├── resource.robot
│   └── todos.robot
└── tests
│   └── validate_todo_test.py
  • index.py on sovelluksen käynnistävä tiedosto
  • app.py sisältää reittien käsittelijät
  • config.py sisältää mm. Flask-sovelluksen sekä tietokannan konfiguroinnit
  • util.py sisältää apufunktion, jonka avulla validoidaan lisättävät Todot
  • db_helper.py sisältää muutaman tietokantaoperaation, joita käytetään sovelluksen testiympäristön pystytyksessä
  • hakemisto entities sisältää sovelluksen tietosisältöä kuvaavat luokat
  • hakemisto repositories sisältää tietosisällön tietokantaan tallettamisesta vastaavat luokat
  • hakemisto templates sisältää näkymäpohjat
  • Robot-testit on sijoitettu tiedostoon story_tests ja unittestit tiedostoon tests

Tietokannan käyttö

Todo-olioihin liittyvät tietokantaoperaatiot on siis kapseloitu tiedostoon repositories/todo_repository.py. Esim. kaikki Todot kannasta hakeva funktio näyttää seuraavalta:

from entities.todo import Todo

def get_todos():
    result = db.session.execute(text("SELECT id, content, done FROM todos"))
    todos = result.fetchall()
    return [Todo(todo[0], todo[1], todo[2]) for todo in todos] 

Funktio siis palauttaa SQL:stä hakemansa rivit luokan Todo olioina. Luokka on määritelty tiedostossa entities/todo.py

class Todo:
    def __init__(self, id, content, done):
        self.id = id
        self.content = content
        self.done = done

    def __str__(self):
        is_done = "Done" if self.done else "not done"
        return f"{self.content}, {is_done}"

Muut osat ohjelmaa (esim. app.py) eivät siis käsittele suoraan tietokantarivejä, vaan näistä muodostettuja olioita. Näin muun ohjelman kannalta se, mihin oliot lopulta tallennetaan on samatekevää, ja tallennustapa voitaisiin myös tarvittaessa muuttaa.

Tiedosto db_helpers.py sisältää muutaman lähinnä testien tarvitseman apufunktion:

table_name = "todos"

def table_exists(name):
  sql_table_existence = text(
    "SELECT EXISTS ("
    "  SELECT 1"
    "  FROM information_schema.tables"
    f" WHERE table_name = '{name}'"
    ")"
  )

  result = db.session.execute(sql_table_existence)
  return result.fetchall()[0][0]

def reset_db():
  print(f"Clearing contents from table {table_name}")
  sql = text(f"DELETE FROM {table_name}")
  db.session.execute(sql)
  db.session.commit()

def setup_db():
  if table_exists(table_name):
    print(f"Table {table_name} exists, dropping")
    sql = text(f"DROP TABLE {table_name}")
    db.session.execute(sql)
    db.session.commit()

  print(f"Creating table {table_name}")
  sql = text(
    f"CREATE TABLE {table_name} ("
    "  id SERIAL PRIMARY KEY, "
    "  content TEXT NOT NULL,"
    "  done BOOLEAN DEFAULT FALSE"
    ")"
  )

  db.session.execute(sql)
  db.session.commit()

if __name__ == "__main__":
    with app.app_context():
      setup_db()

Funktio setup_db, alustaa tietokannan, eli luo sinne taulun ´todos´. Funktio reset_db tyhjentää tietokantataulun sisällön. Jos tiedosto suoritetaan “pääohjelmana”, se luo tietokannan.

Testien alustus ja suorittaminen

Robot-testit on konfiguroitu Viikon 3 tehtävän 4 tapaan, eli testit suoritetaan skriptin run_robot_tests.sh avulla. Skriptissä on nyt pieni ero aiempaan

#!/bin/bash

echo "Running tests"

# luodaan tietokanta
poetry run python src/db_helper.py

echo "DB setup done"

# käynnistetään Flask-palvelin taustalle
poetry run python3 src/index.py &

echo "started Flask server"

# jatkuu samalla tavalla kuin viikon 3 tehtävässä 4

Ennen sovelluksen käynnistämistä skripti suorittaa komennon python src/db_helper.py, joka luo GitHub Actioniin liitettyyn tietokantaan sovelluksen tarvitseman taulun todos. Käytännössä tämä tapahtuu suorittamalla seuraava SQL-komento:

CREATE TABLE todos (
  id SERIAL PRIMARY KEY, 
  content TEXT NOT NULL,
  done BOOLEAN DEFAULT FALSE
)

Test Setup määrittelee, että ennen jokaista testiä suoritetaan avainsana Reset Todos:

*** Settings ***
Resource  resource.robot
Suite Setup      Open And Configure Browser
Suite Teardown   Close Browser
Test Setup       Reset Todos

# ...

*** Keywords ***
Reset Todos
    Go To  ${RESET_URL}

Komento tekee HTTP-pyynnön osoitteeseen reset_db, jonka käsittelijä kutsuu tietokannan tyhjentämisestä huolehtivaa funktiota reset_db:

from db_helper import reset_db

# ...

# testausta varten oleva reitti
if test_env:
    @app.route("/reset_db")
    def reset_database():
        reset_db()
        return jsonify({ 'message': "db reset" })

Kyseinen reitinkäsittelijä on tarkoitettu ainoastaan testien käyttöön. Reitinkäsittelijää ei luoda ollenkaan jos tiedostossa .env määritellään ympäristömuuttujan TEST_ENV arvoksi false:

DATABASE_URL=postgresql://xxx
TEST_ENV=false
SECRET_KEY=satunnainen_merkkijono

Tämä asetus on syytä tehdä kun sovellus viedään todelliseen tuotantoympäristöön.

Testeistä

Sovelluksen automatisoitu testaus on tehty suurimmaksi osaksi Robotilla. Robot-testien lisäksi sovelluksessa on muutama unittest-kirjastolla toteutettu yksikkötesti, joiden avulla testataan tiedostossa util.py määriteltyä apufunktiota validate_todo, joka tarkistaa onko luotavan Todon sisältö oikean kokoinen (5-100 merkkiä pitkä). Sovellukseen olisi voitu periaatteessa tehdä enemmänkin yksikkötestejä mutta koska suurin osa sovelluksen koodista on tietokannan tai HTTP-pyyntöjen käsittelyä, on testit kätevämpi tehdä käyttäjän toimintaa simuloivilla Robot-testeillä.

Miniprojekteissa saattaa tulla esiin hieman enemmän toiminnallisuutta (esim. BibTexin generointi) jonka testaaminen kannattaa hoitaa yksikkötesteillä.

Kantavana ajatuksena on siis, että Robot Frameworkia ja yksikkötestejä käytetään kumpaakin siihen, mikä on mielekästä. Jotta tämänkaltaisessa sovelluksessa ylipäätään olisi järkevästi yksikkötestein testattavissa olevaa koodia, tulee toimia seuraavasti:

  • Eristä koodissa oman vastuun omaavat osat muista osista riippumattomiksi.
  • Erityisesti älä sotke tietokanta- ja käyttöliittymäoperaatioita muuhun koodiin.
  • Erilleen tietokannasta ja käyttöliittymästä kapseloidut osat kannattaa mahdollisesti testata yksikkötesteillä.
  • Esimerkiksi tietokannasta huolehtivia luokkia tuskin kannattaa tämän kokoluokan ohjelmistossa yksikkötestata, sillä Robot Framework -kattavuus hoitaa ne.

Robot-testien toimintaperiaate on samankaltainen kuin viikon 3 tehtävissä.

Yksi testeistä on hieman mielenkiintoisempi. Testi luo kaksi Todoa ja klikkaa toiseen liittyvää nappia. Tilanne on siis seuraava:

Painettavan napin etsiminen ei nyt onnistu pelkästään napin tekstin perusteella sillä saman tekstin omaavia nappeja on kaksi. Testi näyttää seuraavalta

After adding two todos and marking one done, there is one unfinished
    Go To  ${HOME_URL}
    Click Link  Create new todo
    Input Text  content  Buy milk
    Click Button  Create
    Click Link  Create new todo
    Input Text  content  Clean house
    Click Button  Create
    Click Button  //li[div[contains(text(), 'Buy milk')]]/form/button
    Page Should Contain  things still unfinished: 1
    Page Should Contain  Buy milk, done

Oikea nappi on nyt etsitty käyttäen XPath:a, joka on yksi Robotin tukemista tavoista etsiä elementtejä Web-sivuilta.

Selvitin ratkaisun ChatGPT:n avulla. Annoin promptiksi näkymäpohjan ja kysymyksen miten Robot-testissä painetaan tiettyyn Todon liittyvää nappia. Tekoäly antoi ystävällisesti oikean vastauksen ja selityksen XPath-komennon toiminnosta:

The XPath //li[div[contains(text(), 'Specific Todo Content')]]/form/button is used to locate the button within the form for the specific todo item that is not done. Adjust the XPath as necessary to match the actual structure of your HTML.

Here is a more detailed breakdown of the XPath:

//li[div[contains(text(), 'Specific Todo Content')]]
   Selects the <li> element that contains a <div> with the specified text.
/form/button
   Selects the <button> element within the <form> inside the selected <li>.

Lisää tavoista elementtien etsimiseen testeissä voi lukea Robotin dokumentaatiosta.

Protips

Miniprojekti kannattaa rakentaa joko copypasteamalla boilerplaten konfiguraatiot tai kopioimalla projekti itselleen ja muokkaamalla sitä sopivasti.

Kannattaa edetä todella pienin askelin ja pushata koodia useasti GitHubiin.

Sovellus tulee pitää KOKO AJAN sellaisessa tilassa että kaikki testit menevät GitHub Actioneissa läpi. Jos GHA menee punaiselle, älkää missään tapauksessa lisätkö koodia ennen kuin ongelmat on korjattu.

Jos päädyt koodaamaan esim. 2 tuntia ja yrität sen jälkeen saada testit toimimaan, tulee todennäköisesti kulumaan vähintään 4 tuntia debuggaukseen. Jos taas koodaat vain 5 minuuttia, ja tarkistat sen jälkeen menevätkö testit läpi, debuggaukseen kuluva aika on ehkä vain minuutin. Eli pienet askeleet tarkoittaa suurta säästöä ajassa.