Miniprojektin sovellus toteutetaan kurssilta Tietokannat ja Web-ohjelmointi tutulla Flask-sovelluskehyksellä, ja sen tulee tallentaa tietonsa tietokantaan. Pohja käyttää SQLite-tietokantaa, joka ei vaadi erillisen tietokantapalvelimen pystyttämistä.

Flask-sovelluksen konfigurointi siten, että esim. GitHub Actionsien 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 SQLite-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.

Tietokanta

Sovellus käyttää SQLite-tietokantaa. SQLite-tietokanta on pelkkä tiedosto levyllä, joten sen käyttöön ei tarvita erillistä tietokantapalvelinta eikä pilvipalvelua.

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

DATABASE_URL=sqlite:///database.db
TEST_ENV=true
SECRET_KEY=satunnainen_merkkijono

DATABASE_URL-muuttuja määrittelee tietokantatiedoston nimen ja sijainnin. Yllä oleva arvo luo sovelluksen juureen tiedoston database.db. Tiedostoa ei tule lisätä versionhallintaan.

Sovelluksen käynnistäminen

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

uv run 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:

uv run python src/db_helper.py

Yksikkötestit suoritetaan komennolla:

uv run pytest src/tests

Robot-testit suoritetaan komennolla:

uv run 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 yksikkötestit 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 samantekevää, ja tallennustapa voitaisiin myös tarvittaessa muuttaa.

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

from config import db, app
from sqlalchemy import text
import os

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

def tables():
  """Returns all table names from the database"""
  sql = text(
    "SELECT name FROM sqlite_master "
    "WHERE type = 'table' AND name NOT LIKE 'sqlite_%'"
  )

  result = db.session.execute(sql)
  return [row[0] for row in result.fetchall()]

def setup_db():
  """
    Creating the database
    If database tables already exist, those are dropped before the creation
  """
  tables_in_db = tables()
  if len(tables_in_db) > 0:
    print(f"Tables exist, dropping: {', '.join(tables_in_db)}")
    for table in tables_in_db:
      sql = text(f"DROP TABLE {table}")
      db.session.execute(sql)
    db.session.commit()

  print("Creating database")

  # Read schema from schema.sql file
  schema_path = os.path.join(os.path.dirname(__file__), 'schema.sql')
  with open(schema_path, 'r') as f:
    schema_sql = f.read().strip()

  sql = text(schema_sql)
  db.session.execute(sql)
  db.session.commit()

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

Funktio setup_db luo tietokannan. Tietokannan skeema on määritelty tiedostossa schema.sql. Käytännössä tietokanta sisältää vain taulun todos. Jos tietokanta on jo olemassa kun funktiota setup_db kutsutaan, poistetaan kannan taulut ja luodaan kanta uudelleen. 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 verrattuna

#!/bin/bash

echo "Running tests"

# luodaan tietokanta
uv run python src/db_helper.py

echo "DB setup done"

# käynnistetään Flask-palvelin taustalle
uv 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 INTEGER PRIMARY KEY AUTOINCREMENT,
  content TEXT NOT NULL,
  done BOOLEAN DEFAULT FALSE
)

Test Setup määrittelee, että ennen jokaista testiä (Test Setup) 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=sqlite:///database.db
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.
  • Erillään 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  text=Create new todo
    Fill Text  [name="content"]  Buy milk
    Click  button >> text=Create
    Click  text=Create new todo
    Fill Text  [name="content"]  Clean house
    Click  button >> text=Create
    Click  //li[div[contains(text(), 'Buy milk')]]/form/button
    Get Text  body  *=  things still unfinished: 1
    Get Text  body  *=  Buy milk, done

Oikea nappi on nyt etsitty käyttäen XPath:a, joka on yksi Browser-kirjaston tukemista tavoista etsiä elementtejä Web-sivuilta. Kirjasto tunnistaa XPath-selektorin automaattisesti siitä, että se alkaa merkeillä //.

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 toiminnasta:

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 etsiä elementtejä testeissä voi lukea Browser-kirjaston 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 GitHub Actions 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 tarkoittavat suurta säästöä ajassa.