Skip to content

Latest commit

 

History

History
1795 lines (1299 loc) · 80.1 KB

viikko4.md

File metadata and controls

1795 lines (1299 loc) · 80.1 KB

Kisko afterparty pe 16.12. klo 16-18

Suomen johtava Rails-talo Kisko järjestää kurssilaisille illanvieton pe 16.12. klo 16-18

Jos haluat mukaan, kysy ilmoittautumislinkkiä [email protected] tai Discordissa kurssikanavalla tai @mluukkai

Jatkamme sovelluksen rakentamista siitä, mihin jäimme viikon 3 lopussa. Allaoleva materiaali olettaa, että olet tehnyt kaikki edellisen viikon tehtävät. Jos et tehnyt kaikkia tehtäviä, voit täydentää ratkaisusi tehtävien palautusjärjestelmän kautta näkyvän esimerkivastauksen avulla.

Muutama huomio

Rubocop

Muista testata rubocopilla, että kaikki tulevaisuudessa tekemäsi koodisi noudattaa määriteltyjä tyylisääntöjä.

Jos käytät Visual studio codea, kannattaa asentaa rubocop-laajennus

Ongelmia lomakkeiden kanssa

Viikolla 2 muutimme oluiden luomislomaketta siten, että uuden oluen tyyli ja panimo valitaan pudotusvalikoista. Lomake siis muutettiin käyttämään tekstikentän sijaan select:iä:

<div>
  <%= form.label :style, style: "display: block" %>
  <%= form.select :style, options_for_select(@styles) %>
</div>

<div>
  <%= form.label :brewery_id, style: "display: block" %>
  <%= form.select :brewery_id, options_from_collection_for_select(@breweries, :id, :name) %>
</div>

eli pudotusvalikkojen valintavaihtoehdot välitetään lomakkeelle muuttujissa @styles ja @breweries, joille kontrollerin metodi new asettaa arvot:

def new
  @beer = Beer.new
  @breweries = Brewery.all
  @styles = ["Weizen", "Lager", "Pale ale", "IPA", "Porter", "Lowalcohol"]
end

Näiden muutosten jälkeen oluen tietojen editointi ei yllättäen enää toimi. Seurauksena on virheilmoitus undefined method `map' for nil:NilClass, johon olet kenties jo kurssin aikana törmännyt:

kuva

Syynä tälle on se, että uuden oluen luominen ja oluen tietojen editointi käyttävät molemmat samaa lomakkeen generoivaa näkymätemplatea (app/views/beers/_form.html.erb) ja muutosten jälkeen näkymän toiminta edellyttää, että muuttuja @breweries sisältää panimoiden listan ja muuttuja @styles sisältää oluiden tyylit. Oluen tietojen muutossivulle mennään kontrollerimetodin edit suorituksen jälkeen, ja joudummekin muuttamaan kontrolleria seuraavasti korjataksemme virheen:

def edit
  @breweries = Brewery.all
  @styles = ["Weizen", "Lager", "Pale ale", "IPA", "Porter", "Lowalcohol"]
end

Täsmälleen samaan ongelmaan törmätään jos yritetään luoda olut, joka ei ole validi. Tällöin nimittäin kontrollerin metodi create yrittää renderöidä uudelleen lomakkeen generoivan näkymätemplaten. Metodissa on siis ennen renderöintiä asetettava arvo templaten tarvitsemille muuttujille @styles ja @breweries:

def create
  @beer = Beer.new(beer_params)

  respond_to do |format|
    if @beer.save
      format.html { redirect_to beers_path, notice: 'Beer was successfully created.' }
      format.json { render action: 'show', status: :created, location: @beer }
    else
      @breweries = Brewery.all
      @styles = ["Weizen", "Lager", "Pale ale", "IPA", "Porter"]

      format.html { render action: 'new' }
      format.json { render json: @beer.errors, status: :unprocessable_entity }
    end
  end
end

Onkin hyvin tyypillistä, että kontrollerimetodit new, create ja edit sisältävät paljon samaa, näkymätemplaten tarvitsemien muuttujien alustukseen käytettyä koodia. Onkin järkevää ekstraktoida yhteinen koodi omaan metodiin:

def set_breweries_and_styles_for_template
  @breweries = Brewery.all
  @styles = ["Weizen", "Lager", "Pale ale", "IPA", "Porter", "Lowalcohol"]
end

Metodia voidaan kutsua kontrollerin metodeista new, create ja edit:

def new
  @beer = Beer.new
  set_breweries_and_styles_for_template
end

tai ehkä vielä tyylikkäämpää on hoitaa asia before_action määreellä:

class BeersController < ApplicationController
  # ...
  before_action :set_breweries_and_styles_for_template, only: [:new, :edit, :create]

  # ...

tällöin muuttujien @styles ja @breweries arvot asettava metodi siis suoritetaan automaattisesti aina ennen metodien new, create ja edit suoritusta. Metodissa create muuttujien arvot asetetaan ehkä turhaan sillä niitä tarvitaan ainoastaan validoinnin epäonnistuessa. Kenties olisikin parempi käyttää eksplisiittistä kutsua createssa.

Ongelmia Herokun tai Fly.io:n kanssa

Moni kurssin osallistujista on törmännyt siihen, että paikallisesti loistavasti toimiva sovellus on aiheuttanut Herokussa pahaenteisen virheilmoituksen We're sorry, but something went wrong.

Heti ensimmäisenä kannattaa tarkistaa, että paikalliselta koneelta kaikki koodi on lisätty versionhallintaan, eli git status

Epätriviaalit ongelmat selviävät aina Herokun/Fly.io:n lokin avulla. Herokussa lokia päästään tutkimaan komentoriviltä komennolla heroku logs ja Fly.io:ta käytettäessä komennolla fly logs

Seuraavassa Herokulle tyypillisen ongelmatilanteen loki:

mbp-18:ratebeer-public mluukkai$ heroku logs
2022-08-28T18:53:05.867973+00:00 app[web.1]:                   ON a.attrelid = d.adrelid AND a.attnum = d.adnum
2022-08-28T18:53:05.867973+00:00 app[web.1]:
2022-08-28T18:53:05.867973+00:00 app[web.1]:                                           ^
2022-08-28T18:53:05.867973+00:00 app[web.1]:                WHERE a.attrelid = '"users"'::regclass
2022-08-28T18:53:05.874380+00:00 app[web.1]: Completed 500 Internal Server Error in 10ms
2022-08-28T18:53:05.878587+00:00 app[web.1]: :               SELECT a.attname, format_type(a.atttypid, a.atttypmod),
2022-08-28T18:53:05.878587+00:00 app[web.1]:                                           ^
2022-08-28T18:53:05.878587+00:00 app[web.1]:
2022-08-28T18:53:05.868310+00:00 app[web.1]:
2022-08-28T18:53:05.867973+00:00 app[web.1]:                      pg_get_expr(d.adbin, d.adrelid), a.attnotnull, a.atttypid, a.atttypmod
2022-08-28T18:53:05.867973+00:00 app[web.1]:                  AND a.attnum > 0 AND NOT a.attisdropped
2022-08-28T18:53:05.868310+00:00 app[web.1]:                ORDER BY a.attnum
2022-08-28T18:53:05.878587+00:00 app[web.1]:                WHERE a.attrelid = '"users"'::regclass
2022-08-28T18:53:05.867973+00:00 app[web.1]:                 FROM pg_attribute a LEFT JOIN pg_attrdef d
2022-08-28T18:53:05.882824+00:00 app[web.1]: LINE 5:                WHERE a.attrelid = '"users"'::regclass
2022-08-28T18:53:05.882824+00:00 app[web.1]:                                           ^
2022-08-28T18:53:05.878587+00:00 app[web.1]:                      pg_get_expr(d.adbin, d.adrelid), a.attnotnull, a.atttypid, a.atttypmod
2022-08-28T18:53:05.878587+00:00 app[web.1]:                   ON a.attrelid = d.adrelid AND a.attnum = d.adnum
2022-08-28T18:53:05.874380+00:00 app[web.1]: Completed 500 Internal Server Error in 10ms
2022-08-28T18:53:05.878587+00:00 app[web.1]: ActiveRecord::StatementInvalid (PG::UndefinedTable: ERROR:  relation "users" does not exist

lokia tarkasti lukemalla selviää että syynä on seuraava

ActiveRecord::StatementInvalid (PG::UndefinedTable: ERROR:  relation "users" does not exist

eli migraatiot ovat jääneet suorittamatta. Korjaus on helppo:

heroku run rails db:migrate

Fly.io suorittaa migraatiot automaattisesti tuotantoonviennin yhteydessä, joten todennäköisesti tämä virhe ei siellä ole vaivana.

Seuraavassa loki eräästä toisesta, myös Fly.io:n kanssa hyvin tyypillisestä virhetilanteesta:

2022-08-28T19:32:31.609344+00:00 app[web.1]:     6:   <% @ratings.each do |rating| %>
2022-08-28T19:32:31.609530+00:00 app[web.1]:
2022-08-28T19:32:31.609530+00:00 app[web.1]:
2022-08-28T19:32:31.609530+00:00 app[web.1]:   app/views/ratings/index.html.erb:6:in `_app_views_ratings_index_html_erb___254869282653960432_70194062879340'
2022-08-28T19:32:31.609530+00:00 app[web.1]:
2022-08-28T19:32:31.609530+00:00 app[web.1]: ActionView::Template::Error (undefined method `username' for nil:NilClass):
2022-08-28T19:32:31.609344+00:00 app[web.1]:   app/views/ratings/index.html.erb:7:in `block in _app_views_ratings_index_html_erb___254869282653960432_70194062879340'
2022-08-28T19:32:31.609530+00:00 app[web.1]:     7:       <li> <%= rating %> <%= link_to rating.user.username, rating.user %> </li>
2022-08-28T19:32:31.609530+00:00 app[web.1]:     4:
2022-08-28T19:32:31.609530+00:00 app[web.1]:     6:   <% @ratings.each do |rating| %>
2022-08-28T19:32:31.609530+00:00 app[web.1]:     5: <ul>
2022-08-28T19:32:31.609715+00:00 app[web.1]:    10:

Tarkka silmä huomaa lokin seasta että ongelma on ActionView::Template::Error (undefined method `username' for nil:NilClass) ja virhe syntyi tiedoston app/views/ratings/index.html.erb riviä 7 suoritettaessa. Virheen aiheuttanut rivi on

<li> <%= rating %> <%= link_to rating.user.username, rating.user %> </li>

vaikuttaa siis siltä, että tietokannassa on rating-olio, johon liittyvä user on nil. Kyseessä on siis jo viikolta 2 tuttu ongelma.

Ongelman perimmäinen syy on joko se, että jonkin ratingin user_id-kentän arvo on nil, tai että jonkin rating-olion user_id:n arvona on virheellinen id. Tilanteesta selvitään esim. tuhoamalla 'huonot' rating-oliot konsolista käsin. Herokussa konsoli avautuu komennolla heroku run console. Fly.io:n konsoliin pääset antamalla ensin komennon fly ssh console ja sen jälkeen komennon /app/bin/rails c

> bad_ratings = Rating.all.select{ |r| r.user.nil? or r.beer.nil? }
=> [#<Rating id: 1, score: 10, beer_id: 2, created_at: "2022-08-28 19:04:43", updated_at: "2022-08-28 19:04:43", user_id: nil>]
> bad_ratings.each{ |bad| bad.destroy }
=> [#<Rating id: 1, score: 10, beer_id: 2, created_at: "2022-08-28 19:04:43", updated_at: "2022-08-28 19:04:43", user_id: nil>]
> Rating.all.select{ |r| r.user.nil? or r.beer.nil? }
=> []
>

Ylläoleva hakee varalta kannasta myös ratingit, joihin ei liity mitään olemassaolevaa olutta.

Eli jos ja kun joudut Fly.io:n tai Herokun kanssa ongelmiin, selvitä analyyttisesti mistä on kyse, loki ja konsoli auttavat aina hädässä!

Migraation peruminen

Silloin tällöin (esim. jos luodaan vahingossa huono scaffold, ks. seuraava kohta) syntyy tilanteita, joissa edelliseksi suoritetettu migraatio on syytä perua. Tämä onnistuu komennolla

rails db:rollback

Huono scaffold

Jos haluat poistaa scaffold-generaattorin luomat tiedostot, onnistuu tämä komennolla

rails destroy scaffold resurssin_nimi

missä resurssin_nimi on scaffoldilla luomasi resurssin nimi. HUOM: jos suoritit jo huonoon scaffoldiin liittyvän migraation, tee ehdottomasti ennen scaffoldin tuhoamista rails db:rollback

Muuten kaikki allaoleva koodi ei toimi ilman muutoksia.

Testaaminen

Toistaiseksi olemme tehneet koodia, jonka toimintaa olemme testanneet ainoastaan selaimesta. Tämä on suuri virhe. Jokaisen eliniältään laajemmaksi tarkoitetun ohjelman on syytä sisältää riittävän kattavat automaattiset testit, muuten ajan mittaan käy niin että ohjelman laajentaminen tulee liian riskialttiiksi.

Käytämme testaukseen Rspec:iä ks. http://rspec.info/, https://github.com/rspec/rspec-rails ja http://betterspecs.org/

Otetaan käyttöön rspec-rails gem lisäämällä Gemfileen seuraava:

group :test do
  # ...
  gem 'rspec-rails', '~> 6.0.0.rc1'
end

Materiaaleja kirjottaessa ainoa tarjolla oleva versio rspec-rails 6:sta on .rc1 päätteinen. Rspec-projektin repositorio ohjeistaa käyttämään 6.0.0 versiota, joka saattaa toimia jälleen kurssin aikana.

Uusi gem otetaan käyttöön tutulla tavalla, eli antamalla komentoriviltä komento bundle install

rspec saadaan initialisoitua sovelluksen käyttöön antamalla komentoriviltä komento

rails generate rspec:install

Initialisointi luo sovellukselle hakemiston /spec jonka alihakemistoihin testit eli "spekit" sijoitetaan.

Railsin oletusarvoinen, mutta nykyään vähemmän käytetty testausframework sijoittaa testit hakemistoon /test. Ko. hakemisto on tarpeeton rspecin käyttöönoton jälkeen ja se voidaan poistaa.

Testejä (oikeastaan rspecin yhteydessä ei pitäisi puhua testeistä vaan speceistä tai spesifikaatioista, käytämme kuitenkin jatkossa sanaa testi) voidaan kirjoittaa usealla tasolla: yksikkötestejä modeleille tai kontrollereille, näkymätestejä, integraatiotestejä kontrollereille. Näiden lisäksi sovellusta voidaan testata käyttäen simuloitua selainta capybara-gemin https://github.com/jnicklas/capybara avulla.

Kirjoitamme jatkossa lähinnä yksikkötestejä modeleille sekä capybaran avulla simuloituja selaintason testejä.

Yksikkötestit

Tehdään kokeeksi muutama yksikkötesti luokalle User. Voimme luoda testipohjan käsin tai komentoriviltä rspec-generaattorilla

rails generate rspec:model user

Hakemistoon /spec/models tulee tiedosto user_spec.rb

require 'rails_helper'

RSpec.describe User, type: :model do
  pending "add some examples to (or delete) #{__FILE__}"
end

Kokeillaan ajaa testit komentoriviltä komennolla rspec spec (huom: saattaa olla, että joudut tässä vaiheessa käynnistämään terminaalin uudelleen!).

Testien suoritus etenee seuraavasti:

$ rspec spec
*

Pending: (Failures listed here are expected and do not affect your suite's status)

  1) User add some examples to (or delete) /Users/mluukkai/opetus/ratebeer/spec/models/user_spec.rb
     # Not yet implemented
     # ./spec/models/user_spec.rb:4


Finished in 0.00932 seconds (files took 3.31 seconds to load)
1 example, 0 failures, 1 pending

Komento rspec spec määrittelee, että suoritetaan kaikki testit, jotka löytyvät hakemiston spec alihakemistoista. Jos testejä on paljon, on myös mahdollista ajaa suppeampi joukko testejä:

rspec spec/models                # suoritetaan hakemiston model sisältävät testit
rspec spec/models/user_spec.rb   # suoritetaan user_spec.rb:n määrittelemät testi

Testien suorituksen voi myös automatisoida aina kun testi tai sitä koskeva koodi muuttuu. guard on tähän käytetty kirjasto ja siihen löytyy monia laajennoksia.

Aloitetaan testien tekeminen. Kirjoitetaan (tiedostoon user_spec.rb) aluksi testi joka testaa, että konstruktori asettaa käyttäjätunnuksen oikein:

require 'rails_helper'

RSpec.describe User, type: :model do
  it "has the username set correctly" do
    user = User.new username: "Pekka"

    expect(user.username).to eq("Pekka")
  end
end

Testi kirjoitetaan it-nimiselle metodille annettavan koodilohkon sisälle. Metodin ensimmäisenä parametrina on merkkijono, joka toimii testin nimenä. Muuten testi kirjoitetaan samaan tapan kuin esim. jUnitilla, eli ensin luodaan testattava data, sitten suoritetaan testattava toimenpide ja lopuksi varmistetaan että vastaus on odotettu.

Suoritetaan testi ja havaitaan sen menevän läpi:

$ rspec spec

Finished in 0.00553 seconds (files took 2.11 seconds to load)
1 example, 0 failures

Toisin kuin jUnit-testauskehyksessä, Rspecin yhteydessä ei käytetä assert-komentoja testin odotetun tuloksen määrittelemiseen. Käytössä on hieman erikoisemman näköinen syntaksi, kuten testin viimeisellä rivillä oleva:

expect(user.username).to eq("Pekka")

Äskeisessä testissä käytettiin komentoa new, joten olioa ei talletettu tietokantaan. Kokeillaan nyt olion tallettamista. Olemme määritelleet, että User-olioilla tulee olla salasana, jonka pituus on vähintään 4 ja että salasana sisältää sekä numeron että ison kirjaimen. Eli jos salasanaa ei aseteta, ei oliota tulisi tallettaa tietokantaan. Voimme kysyä oliolta metodilla valid? onko sille suoritettu validointi onnistuneesti eli käytännössä, onko olio talletettu tietokantaan.

Testataan että näin tapahtuu:

RSpec.describe User, type: :model do

  # aiemmin määritellyn testin koodi ...

  it "is not saved without a password" do
    user = User.create username: "Pekka"

    expect(user.valid?).to be(false)
    expect(User.count).to eq(0)
  end
end

Testi menee läpi.

Testin ensimmäinen tarkistus

expect(user.valid?).to be(false)

on kyllä ymmärrettävä, mutta kiitos rspec-magian, voimme ilmaista sen myös seuraavasti

expect(user).not_to be_valid

Tämän muodon toiminta perustuu sille, että oliolla user on totuusarvoinen metodi valid?.

Huomaamme, että käytämme testeissä kahta samuuden tarkastustapaa be(false) ja eq(0), mikä näillä on erona? Matcherin eli 'tarkastimen' be avulla voidaan varmistaa, että kyse on kahdesta samasta oliosta. Totuusarvojen vertailussa be onkin toimiva tarkistin. Esim. merkkijonojen vertailuun se ei toimi, kokeile muuttaa ensimmäisen testin vertailu muotoon:

expect(user.username).to be("Pekka")

nyt testi ei mene läpi:

1) User has the username set correctly
    Failure/Error: expect(user.username).to be("Pekka")

      expected #<String:70322613325340> => "Pekka"
          got #<String:70322613325560> => "Pekka"

      Compared using equal?, which compares object identity,
      but expected and actual are not the same object. Use
      `expect(actual).to eq(expected)` if you don't care about
      object identity in this example.

Kun riittää että vertailtavat oliot ovat sisällöltään samat, tuleekin käyttää tarkistinta eq, käytännössä useimmissa tilanteissa näin on kaikkien muiden paitsi totuusarvojen kanssa. Tosin totuusarvojenkin eq toimisi eli voisimme kirjoittaa myös

expect(user.valid?).to eq(false)

Tehdään sitten testi kunnollisella salasanalla:

it "is saved with a proper password" do
  user = User.create username: "Pekka", password: "Secret1", password_confirmation: "Secret1"

  expect(user.valid?).to be(true)
  expect(User.count).to eq(1)
end

Testin ensimmäinen "ekspektaatio" varmistaa, että luodun olion validointi onnistuu, eli että metodi valid? palauttaa true. Toinen ekspektaatio taas varmistaa, että tietokannassa olevien olioiden määrä on yksi.

Olisimme jälleen voineet käyttää käyttäjän validiteetin tarkastamiseen hieman luettavampaa muotoa

expect(user).to be_valid

On huomattavaa, että rspec nollaa tietokannan aina ennen jokaisen testin ajamista, eli jos teemme uuden testin, jossa tarvitaan Pekkaa, on se luotava uudelleen:

it "with a proper password and two ratings, has the correct average rating" do
  user = User.create username: "Pekka", password: "Secret1", password_confirmation: "Secret1"
  brewery = Brewery.new name: "test", year: 2000
  beer = Beer.new name: "testbeer", style: "teststyle", brewery: brewery
  rating = Rating.new score: 10, beer: beer
  rating2 = Rating.new score: 20, beer: beer

  user.ratings << rating
  user.ratings << rating2

  expect(user.ratings.count).to eq(2)
  expect(user.average_rating).to eq(15.0)
end

Kuten arvata saattaa, ei testin alustuksen (eli testattavan olion luomisen) toistaminen ole järkevää, ja yhteinen osa voidaan helposti eristää. Tämä tapahtuu esim. tekemällä samanlaisen alustuksen omaavalle osalle testeistä oma describe-lohko, jonka alkuun määritellään ennen jokaista testiä suoritettava let-komento, joka alustaa user-muuttujan uudelleen jokaista testiä ennen:

require 'rails_helper'

RSpec.describe User, type: :model do
  it "has the username set correctly" do
    user = User.new username: "Pekka"

    expect(user.username).to eq("Pekka")
  end

  it "is not saved without a password" do
    user = User.create username: "Pekka"

    expect(user).not_to be_valid
    expect(User.count).to eq(0)
  end

  describe "with a proper password" do
    let(:user){ User.create username: "Pekka", password: "Secret1", password_confirmation: "Secret1" }
    let(:test_brewery) { Brewery.new name: "test", year: 2000 }
    let(:test_beer) { Beer.create name: "testbeer", style: "teststyle", brewery: test_brewery }

    it "is saved" do
      expect(user).to be_valid
      expect(User.count).to eq(1)
    end

    it "and with two ratings, has the correct average rating" do
      rating = Rating.new score: 10, beer: test_beer
      rating2 = Rating.new score: 20, beer: test_beer

      user.ratings << rating
      user.ratings << rating2

      expect(user.ratings.count).to eq(2)
      expect(user.average_rating).to eq(15.0)
    end
  end
end

Muuttujien alustus tapahtuu hieman erikoisen let-metodin avulla, esim.

let(:user){ User.create username: "Pekka", password: "Secret1", password_confirmation: "Secret1" }

saa aikaan sen, että määrittelyn jälkeen muuttuja user viittaa let-metodin koodilohkossa luotuun User-olioon.

Siitä huolimatta, että muuttujan alustus on nyt vain yhdessä paikassa koodia, suoritetaan alustus uudelleen ennen jokaista metodia. Huom: metodi let suorittaa olion alustuksen vasta kun olioa tarvitaan oikeasti, tästä saattaa joissain tilanteissa olla yllättäviä seurauksia!

Erityisesti vanhemmissa Rspec-testeissä näkee tyyliä, jossa testeille yhteinen alustus tapahtuu before :each -lohkon avulla. Tällöin testien yhteiset muuttujat on määriteltävä instanssimuuttujiksi, eli tyyliin @user.

Testien ja describe-lohkojen nimien valinta ei ole ollut sattumanvaraista. Määrittelemällä testauksen tulos formaattiin "documentation" (parametri -fd), saadaan testin tulos ruudulle mukavassa muodossa:

$ rspec -fd spec

User
  has the username set correctly
  is not saved without a password
  with a proper password
    is saved
    and with two ratings, has the correct average rating

Finished in 0.12949 seconds (files took 1.95 seconds to load)
4 examples, 0 failures

Pyrkimyksenä onkin kirjoittaa testien nimet siten, että testit suorittamalla saadaan ohjelmasta mahdollisimman ihmisluettava "spesifikaatio".

Voit myös lisätä rivin -fd tiedostoon .rspec, jolloin projektin rspec-testit näytetään aina documentation formaatissa.

Tehtävä 1

Lisää luokalle User testit, jotka varmistavat, että liian lyhyen tai pelkästään pienistä kirjaimista muodostetun salasanan omaavan käyttäjän luominen create-metodilla ei tallenna oliota tietokantaan, ja että luodun olion validointi ei ole onnistunut

Muista aina nimetä testisi niin että ajamalla Rspec dokumentointiformaatissa, saat kieliopillisesti järkevältä kuulostavan "speksin".

Tehtävä 2

Luo Rspecin generaattorilla (tai käsin) testipohja luokalle Beer ja tee testit, jotka varmistavat, että

  • oluen luonti onnistuu ja olut tallettuu kantaan jos oluella on nimi, tyyli ja panimo asetettuna
  • oluen luonti ei onnistu (eli creatella ei synny validia oliota), jos sille ei anneta nimeä
  • oluen luonti ei onnistu, jos sille ei määritellä tyyliä

Jos jälkimmäinen testi ei mene läpi, laajenna koodiasi siten, että se läpäisee testin. Vinkki: oluelle täytyy asettaa panimon id, mutta entä jos panimoa ei ole olemassa?

Jos teet testitiedoston käsin, muista sijoittaa se hakemistoon spec/models

Testiympäristöt eli fixturet

Edellä käyttämämme tapa, jossa testien tarvitsemia oliorakenteita luodaan testeissä käsin, ei ole välttämättä kaikissa tapauksissa järkevä. Parempi tapa voi olla koota testiympäristön rakentaminen, eli testien alustamiseen tarvittava data omaan paikkaansa, "testifixtureen". Käytämme testien alustamiseen Railsin oletusarvoisen fixture-mekanismin sijaan FactoryBot-nimistä gemiä, kts. https://github.com/thoughtbot/factory_bot ja https://github.com/thoughtbot/factory_bot_rails

Lisätään Gemfileen seuraava

group :test do
  # ...
  gem 'factory_bot_rails'
end

ja päivitetään gemit komennolla bundle install

Tehdään fixtureja varten tiedosto spec/factories.rb ja kirjoitetaan sinne seuraava:

FactoryBot.define do
  factory :user do
    username { "Pekka" }
    password { "Foobar1" }
    password_confirmation { "Foobar1" }
  end
end

Tiedostossa määritellään "oliotehdas" luokan User olioiden luomiseen. Tehtaaseen ei tarvinnut määritellä erikseen tehtaan luomien olioiden luokkaa, sillä FactoryBot päättelee sen suoraan käytettävän fixtuurin nimestä user.

Määriteltyjä tehtaita voidaan pyytää luomaan olioita seuraavasti:

user = FactoryBot.create(:user)

FactoryBotin tehdasmetodin create kutsuminen luo olion automaattisesti testausympäristön tietokantaan.

Muutetaan nyt testimme käyttämään user-olioiden luomiseen FactoryBotiä:

describe "with a proper password" do
  let(:user) { FactoryBot.create(:user) } # tämä rivi muuttui
  let(:test_brewery) { Brewery.new name: "test", year: 2000 }
  let(:test_beer) { Beer.create name: "testbeer", style: "teststyle", brewery: test_brewery }

  it "is saved" do
    expect(user).to be_valid
    expect(User.count).to eq(1)
  end

  it "and with two ratings, has the correct average rating" do
    rating = Rating.new score: 10, beer: test_beer
    rating2 = Rating.new score: 20, beer: test_beer

    user.ratings << rating
    user.ratings << rating2

    expect(user.ratings.count).to eq(2)
    expect(user.average_rating).to eq(15.0)
  end
end

Muutos aiempaan on vielä melko pieni. Laajennetaan fixtureita vielä siten, että voimme luoda niiden avulla myös testien käyttämät rating-oliot. Muutetaan tiedostoa spec/factories.rb seuraavasti

FactoryBot.define do
  factory :user do
    username { "Pekka" }
    password { "Foobar1" }
    password_confirmation { "Foobar1" }
  end

  factory :brewery do
    name { "anonymous" }
    year { 1900 }
  end

  factory :beer do
    name { "anonymous" }
    style { "Lager" }
    brewery # olueeseen liittyvä panimo luodaan brewery-tehtaalla
  end

  factory :rating do
    score { 10 }
    beer # reittaukseen liittyvä olut luodaan beer-tehtaalla
    user # reittaukseen liittyvä user luodaan user-tehtaalla
  end
end

Reittausten luovan oliotehtaan :rating lisäksi tiedostossa määritellään panimoita ja oluita luovat fixturet.

Tehdas FactoryBot.create(:brewery) luo panimon, jonka nimi on 'anonymous' ja perustamisvuosi 1900.

Tehdas FactoryBot.create(:beer) luo oluen, jonka tyyli on 'Lager' ja nimi 'anonymous' ja oluelle luodaan panimo, johon olut liittyy. Vastaavasti tehdas FactoryBot.create(:rating) luo reittauksen, johon liittyvät tehtaan luomat olut ja käyttäjä. Lisäksi reittauksen arvoksi eli kenttään score asetetaan 10.

Testi voidaan muuttaa seuraavaan muotoon

describe "with a proper password" do
  let(:user) { FactoryBot.create(:user) }

  it "is saved" do
    expect(user).to be_valid
    expect(User.count).to eq(1)
  end

  it "and with two ratings, has the correct average rating" do
    FactoryBot.create(:rating, score: 10, user: user)
    FactoryBot.create(:rating, score: 20, user: user)

    expect(user.ratings.count).to eq(2)
    expect(user.average_rating).to eq(15.0)
  end
end

Testi siis luo kaksi reittausta, toisen pistemäärä on 10 ja toisen 20, jotka liitetään let-komennossa tehtaan avulla luodulle käyttäjälle:

FactoryBot.create(:rating, score: 10, user: user)
FactoryBot.create(:rating, score: 20, user: user)

Saman tehtaan avulla on siis mahdollista luoda useita olioita. Esimerkiksi seuraava

FactoryBot.create(:brewery)
FactoryBot.create(:brewery)
FactoryBot.create(:brewery)

loisi kolme eri panimo-olioa, jotka ovat kaikki samansisältöistä.

Tehtaalla luotavien olioiden sisältöä voidaan muokata parametrien avulla, esim.

FactoryBot.create(:brewery)
FactoryBot.create(:brewery, name: 'crapbrew')
FactoryBot.create(:brewery, name: 'homebrew', year: 2011)

loisi kolme panimoa, joista yksi saisi oletusarvoisen nimen anonymous ja perustamisvuoden 1900. Toinen panimo saisi oletusarvoisen perustamisvuoden mutta nimen crapbrew, kolmannen panimon nimi sekä perustusvuosi määrittyisi annettujen parametrien mukaan.

Myös tehtaalta user voitaisiin pyytää kahta eri olioa.

FactoryBot.create(:user)
FactoryBot.create(:user)

Tämä kuitenkin aiheuttaisi poikkeuksen, sillä User-olioiden validointi edellyttää, että username on yksikäsitteinen ja tehdas luo oletusarvoisesti aina "Pekka"-nimisen käyttäjän.

Seuraava kuitenkin olisi ok, eli luotaisiin kaksi erinimistä käyttäjää, oletusarvoisen nimen saava Pekka ja Vilma

FactoryBot.create(:user)
FactoryBot.create(:user, username: 'Vilma')

Lisää ohjeita FactoryBotin käyttöön osoitteessa https://www.rubydoc.info/gems/factory_bot/file/GETTING_STARTED.md

Käyttäjän lempiolut, -panimo ja -oluttyyli

Toteutetaan seuraavaksi test driven -tyylillä (tai behaviour driven niinkuin rspecin luojat sanoisivat) käyttäjälle metodit, joiden avulla saadaan selville käyttäjän lempiolut, lempipanimo ja lempioluttyyli käyttäjän tekemien reittausten perusteella.

Oikeaoppisessa TDD:ssä ei tehdä yhtään koodia ennen kuin minimaalinen testi sen pakottaa. Tehdäänkin ensin testi, jonka avulla vaaditaan että User-olioilla on metodi favorite_beer:

it "has method for determining the favorite_beer" do
  user = FactoryBot.create(:user)
  expect(user).to respond_to(:favorite_beer)
end

Testi ei mene läpi, eli lisätään luokalle User metodin runko:

class User < ApplicationRecord
  # ...

  def favorite_beer
  end
end

Testi menee nyt läpi. Lisätään seuraavaksi testi, joka varmistaa, että ilman reittauksia ei käyttäjllä ole mieliolutta, eli että metodi palauttaa nil:

it "without ratings does not have a favorite beer" do
  user = FactoryBot.create(:user)
  expect(user.favorite_beer).to eq(nil)
end

Testi menee läpi sillä Rubyssa metodit palauttavat oletusarvoisesti nil.

Refaktoroidaan testiä hieman lisäämällä juuri kirjoitetulle kahdelle testille oma describe-lohko

describe "favorite beer" do
  let(:user){ FactoryBot.create(:user) }

  it "has method for determining one" do
    expect(user).to respond_to(:favorite_beer)
  end

  it "without ratings does not have one" do
    expect(user.favorite_beer).to eq(nil)
  end
end

Lisätään sitten testi, joka varmistaa että jos reittauksia on vain yksi, osaa metodi palauttaa reitatun oluen.

it "is the only rated if only one rating" do
  beer = FactoryBot.create(:beer)
  rating = FactoryBot.create(:rating, score: 20, beer: beer, user: user)

  # jatkuu...
end

Alussa siis luodaan olut, sen jälkeen reittaus. Reittauksen create-metodille annetaan parametreiksi pistemäärä sekä olut- ja käyttäjäoliot (joista molemmat on luotu FactoryBotillä), joihin reittaus liitetään.

Luotu reittaus siis liittyy käyttäjään ja on käyttäjän ainoa reittaus. Testi siis lopulta odottaa, että reittaukseen liittyvä olut on käyttäjän lempiolut:

it "is the only rated if only one rating" do
  beer = FactoryBot.create(:beer)
  rating = FactoryBot.create(:rating, score: 20, beer: beer, user: user)

  expect(user.favorite_beer).to eq(beer)
end

Testi ei mene läpi, sillä metodimme ei vielä tee mitään ja sen paluuarvo on siis aina nil.

Tehdään TDD:n hengen mukaan ensin "huijattu ratkaisu", eli ei vielä yritetäkään tehdä lopullista toimivaa versiota:

class User < ApplicationRecord
  # ...

  def favorite_beer
    return nil if ratings.empty?   # palautetaan nil jos reittauksia ei ole

    ratings.first.beer             # palataan ensimmaiseen reittaukseen liittyvä olut
  end
end

Tehdään vielä testi, joka pakottaa meidät kunnollisen toteutuksen tekemiseen (ks. triangulation):

it "is the one with highest rating if several rated" do
  beer1 = FactoryBot.create(:beer)
  beer2 = FactoryBot.create(:beer)
  beer3 = FactoryBot.create(:beer)
  rating1 = FactoryBot.create(:rating, score: 20, beer: beer1, user: user)
  rating2 = FactoryBot.create(:rating, score: 25, beer: beer2, user: user)
  rating3 = FactoryBot.create(:rating, score: 9, beer: beer3, user: user)

  expect(user.favorite_beer).to eq(beer2)
end

Ensin luodaan kolme olutta ja sen jälkeen oluisiin sekä user-olioon liittyvät reittaukset.

Testi ei luonnollisesti mene vielä läpi, sillä metodin favorite_beer toteutus jätettiin aiemmin puutteelliseksi.

Muuta metodin toteutus nyt seuraavanlaiseksi:

def favorite_beer
  return nil if ratings.empty?

  ratings.sort_by{ |r| r.score }.last.beer
end

eli ensin järjestetään reittaukset scoren perusteella, otetaan reittauksista viimeinen eli korkeimman scoren omaava ja palautetaan siihen liittyvä olut.

Koska järjestäminen perustui suoraan reittauksen attribuuttiin score oltaisiin metodin viimeinen rivi voitu kirjottaa myös hieman kompaktimmassa muodossa

ratings.sort_by(&:score).last.beer

Miten metodi itseasiassa toimiikaan? Suoritetaan operaatio konsolista:

> u = User.first
> u.ratings.sort_by(&:score).last.beer
  Rating Load (1.4ms)  SELECT "ratings".* FROM "ratings" WHERE "ratings"."user_id" = ?  [["user_id", 1]]
  Beer Load (0.4ms)  SELECT  "beers".* FROM "beers" WHERE "beers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]

Seurauksena on 2 SQL-kyselyä, joista ensimmäinen

SELECT "ratings".* FROM "ratings" WHERE "ratings"."user_id" = ?  [["user_id", 1]]

hakee kaikki käyttäjään liittyvät reittaukset tietokannasta. Reittausten järjestäminen tapahtuu keskusmuistissa. Jos käyttäjään liittyvien reittausten määrä olisi erittäin suuri, kannattaisi operaatio optimoida siten, että se tehtäisiin suoraan tietokantatasolla.

Tutkimalla dokumentaatiota (http://guides.rubyonrails.org/active_record_querying.html#ordering ja http://guides.rubyonrails.org/active_record_querying.html#limit-and-offset) päädymme seuraavaan ratkaisuun:

def favorite_beer
  return nil if ratings.empty?
  ratings.order(score: :desc).limit(1).first.beer
end

Voimme konsolista käsin tarkastaa operaation tuloksena olevan SQL-kyselyn (huomaa, että metodi to_sql):

> u.ratings.order(score: :desc).limit(1).to_sql
=> "SELECT  \"ratings\".* FROM \"ratings\"  WHERE \"ratings\".\"user_id\" = ?  ORDER BY \"ratings\".\"score\" DESC LIMIT 1"

Suorituskyvyn optimoinnissa kannattaa kuitenkin pitää maltti mukana ja sovelluksen kehitysvaiheessa ei vielä välttämättä kannata jäädä optimoimaan jokaista operaatiota. Optimointia tehdessä kannattaa pitää mielessä: Premature optimization is the root of all evil

Testien apumetodit

Testissä tarvittavien oluiden rakentamisen tekevä koodi on hieman ikävä. Voisimme konfiguroida FactoryBotiin oluita, joihin liittyy reittauksia. Päätämme kuitenkin tehdä testitiedostoon reittauksellisen oluen luovan apumetodin create_beer_with_rating:

def create_beer_with_rating(object, score)
  beer = FactoryBot.create(:beer)
  FactoryBot.create(:rating, beer: beer, score: score, user: object[:user] )
  beer
end

Apumetodia käyttämällä saamme siistityksi testiä

it "is the one with highest rating if several rated" do
  create_beer_with_rating({ user: user }, 10 )
  create_beer_with_rating({ user: user }, 7 )
  best = create_beer_with_rating({ user: user }, 25 )

  expect(user.favorite_beer).to eq(best)
end

Reittauksen tehneen käyttäjän välittäminen apumetodille tapahtuu nyt hieman erikoisella tavalla, ruby-hashin avaimen arvona. Olisimme voineet määritellä, että käyttäjä välitetään normaalina parametrina, samoin kuin reittauksen pistemäärä:

def create_beer_with_rating(user, score)
  beer = FactoryBot.create(:beer)
  FactoryBot.create(:rating, beer: beer, score: score, user: user )
  beer
end

Käyttämämme tapa on kuitenkin tässä tapauksessa joustavampi, sillä se mahdollistaa tehtävissä 3 ja 4 tarvittavan metodin create_beer_with_rating laajennuksen siten, että aiemmin tehty testikoodi ei hajoa.

Apumetodeja siis voi (ja kannattaa) määritellä rspec-tiedostoihin. Jos apumetodia tarvitaan ainoastaan yhdessä testitiedostossa, voi sen sijoittaa esim. tiedoston loppuun.

Parannetaan vielä edellistä hiukan määrittelemällä toinenkin metodi create_beers_with_many_ratings, jonka avulla on mahdollista luoda useita reitattuja oluita. Metodi saa reittaukset taulukon tapaan käyttäytyvän vaihtuvamittaisen parametrilistan (ks. http://www.ruby-doc.org/docs/ProgrammingRuby/html/tut_methods.html, kohta "Variable-Length Argument Lists") avulla:

def create_beers_with_many_ratings(object, *scores)
  scores.each do |score|
    create_beer_with_rating(object, score)
  end
end

Kutsuttaessa metodia esim. seuraavasti

create_beers_with_many_ratings( {user: user}, 10, 15, 9)

tulee parametrin scores arvoksi kokoelma, jossa ovat luvut 10, 15 ja 9. Metodi luo (metodin create_beer_with_rating avulla) kolme olutta, joihin kuhunkin parametrina annetulla käyttäjällä on reittaus ja reittauksien pistemääriksi tulevat parametrin scores luvut.

Seuraavassa vielä koko mielioluen testaukseen liittyvä koodi:

require 'rails_helper'

RSpec.describe User, type: :model do

  # ..

  describe "favorite beer" do
    let(:user){ FactoryBot.create(:user) }

    it "has method for determining the favorite beer" do
      expect(user).to respond_to(:favorite_beer)
    end

    it "without ratings does not have a favorite beer" do
      expect(user.favorite_beer).to eq(nil)
    end

    it "is the only rated if only one rating" do
      beer = FactoryBot.create(:beer)
      rating = FactoryBot.create(:rating, score: 20, beer: beer, user: user)

      expect(user.favorite_beer).to eq(beer)
    end

    it "is the one with highest rating if several rated" do
      create_beers_with_many_ratings({user: user}, 10, 20, 15, 7, 9)
      best = create_beer_with_rating({ user: user }, 25 )

      expect(user.favorite_beer).to eq(best)
    end
  end
end # describe User

def create_beer_with_rating(object, score)
  beer = FactoryBot.create(:beer)
  FactoryBot.create(:rating, beer: beer, score: score, user: object[:user] )
  beer
end

def create_beers_with_many_ratings(object, *scores)
  scores.each do |score|
    create_beer_with_rating(object, score)
  end
end

FactoryBot-troubleshooting

Seuraavaan on koottu muutama aiempien vuosien aikana vastaantullut virhetilanne

vahingossa luotu oliotehdas

Kannattaa huomata, että jos määrittelet FactoryBot-gemin testiympäristön lisäksi kehitysympäristöön, eli

group :development, :test do
   gem 'factory_bot_rails'
    # ...
end

jos luot Railsin generaattorilla uusia resursseja, esim:

rails g scaffold bar name:string

syntyy nyt samalla myös oletusarvoinen oliotehdas:

FactoryBot.define do
  factory :bar do
    name "MyString"
  end

  # ...
end

Tämä saattaa aiheuttaa yllättäviä tilanteita (mm. jos määrittelet itse saman nimisen tehtaan, käytetään sen sijaan oletusarvoista tehdasta!), eli kannattanee määritellä gemi ainoastaan testausympäristöön luvun https://github.com/mluukkai/WebPalvelinohjelmointi2022/blob/main/web/viikko4.md#testiymp%C3%A4rist%C3%B6t-eli-fixturet ohjeen tapaan.

testitietokantaan jäävät oliot

Normaalisti rspec-tyhjentää tietokannan jokaisen testin suorituksen jälkeen. Tämä johtuu sitä, että oletusarvoisesti rspec suorittaa jokaisen testin transaktiossa, joka rollbackataan eli perutaan testin suorituksen jälkeen. Testit eivät siis todellisuudessa edes talleta mitään tietokantaan.

Joskus testeissä voi kuitenkin mennä kantaan pysyvästi olioita.

Oletetaan että testaisimme luokkaa Beer seuraavasti:

describe "when one beer exists" do
  beer = FactoryBot.create(:beer)

  it "is valid" do
    expect(beer).to be_valid
  end

  it "has the default style" do
    expect(beer.style).to eq("Lager")
  end
end

testin luoma Beer-olio menisi nyt pysyvästi testitietokantaan, sillä komento FactoryBot.create(:beer) ei ole minkään testin sisällä, eikä sitä siis suoriteta peruttavan transaktion aikana!

Testien ulkopuolelle, ei siis tule sijoittaa olioita luovaa koodia (poislukien testeistä kutsuttavat metodit). Olioiden luomisen on tapahduttava testikontekstissa, eli joko metodin it sisällä:

describe "when one beer exists" do
  it "is valid" do
    beer = FactoryBot.create(:beer)
    expect(beer).to be_valid
  end

  it "has the default style" do
    beer = FactoryBot.create(:beer)
    expect(beer.style).to eq("Lager")
  end
end

komennon let tai let! sisällä:

describe "when one beer exists" do
  let(:beer){FactoryBot.create(:beer)}

  it "is valid" do
    expect(beer).to be_valid
  end

  it "has the default style" do
    expect(beer.style).to eq("Lager")
  end
end

tai hieman myöhemmin esiteltävissä before-lohkoissa.

Saat poistettua testikantaan vahingossa menneet oluet käynnistämällä konsolin testiympäristössä komennolla rails c -e test.

validointi

Validoinneissa määritellyt uniikkiusehdot saattavat joskus tuottaa yllätyksiä. Käyttäjän käyttäjätunnus on määritelty uniikisi, joten testi

describe "the application" do
  it "does something with two users" do
    user1 = FactoryBot.create(:user)
    user2 = FactoryBot.create(:user)

  # ...
  end
end

aiheuttaisi virheilmoituksen

1) User the application does something with two users
    Failure/Error: user2 = FactoryBot.create(:user)

    ActiveRecord::RecordInvalid:
      Validation failed: Username has already been taken
    # ./spec/models/user_spec.rb:77:in `block (3 levels) in <main>'

sillä FactoryBot yrittää nyt luoda kaksi käyttäjäolioa määritelmän

factory :user do
  username { "Pekka" }
  password { "Foobar1" }
  password_confirmation { "Foobar1" }
end

perusteella, eli molemmille tulisi usernameksi 'Pekka'. Ongelma ratkeaisi antamalla toiselle luotavista oliosta joku muu nimi:

describe "the application" do
  it "does something with two users" do
    user1 = FactoryBot.create(:user)
    user2 = FactoryBot.create(:user, username: "Vilma")

  # ...
  end
end

Toinen vaihtoehto olisi määritellä FactoryBotin käyttämät usernamet ns. sekvenssien avulla, ks. https://www.rubydoc.info/gems/factory_bot/file/GETTING_STARTED.md#sequences

Tehdas muuttuisi seuraavaan muotoon:

FactoryBot.define do
  sequence :username do |n|
    "Pekka#{n}"
  end

  factory :user do
    username { generate :username }
    password { "Foobar1" }
    password_confirmation { "Foobar1" }
  end

  # ...
end

Nyt jokainen peräkkäisten tehtaan FactoryBot.create(:user) kutsujen luomien olioiden usernamet olisivat Pekka1, Pekka2, Pekka3 ...

Älä kuitenkaan muuta tehdasta tähän muotoon, muuten osa viikon testeistä ei toimi!

Testit ja debuggeri

Toivottavasti olet jo tässä vaiheessa kurssia rutinoitunut debuggerin käyttäjä. Koska testitkin ovat normaalia Ruby-koodia, on myös binding.pry käytettävissä sekä testikoodissa että testattavassa koodissa. Testausympäristön tietokannan tila saattaa joskus olla yllättävä, kuten edellä olevista esimerkeistä näimme. Ongelmatilanteissa kannattaa ehdottomasti pysäyttää testikoodi debuggerilla ja tutkia vastaako testattavien olioiden tila oletettua.

Yksittäisten testien suorittaminen

Rspecillä voi suorittaa myös yksittäisiä testejä tai describe-lohkoja, esim. seuraava suorittaisi ainoastaan tiedoston user_spec.rb riviltä 108 alkavan testin

rspec spec/models/user_spec.rb:108

Jos/kun törmäät testeissäsi ongelmatilanteita:

  • älä suorita kaikkia testejä, vaan rajaa suoritus ongelmallisiin testeihin
  • käytä debuggeria

Tehtävä 3

Tämä ja seuraava tehtävä voivat olla jossain määrin haastavia. Tehtävien teko ei ole viikon jatkamisen kannalta välttämätöntä eli älä juutu tähän kohtaan. Voit tehdä tehtävät myös viikon muiden tehtävien jälkeen.

Tee seuraavaksi TDD-tyylillä User-olioille metodi favorite_style, joka palauttaa tyylin, jonka oluet ovat saaneet käyttäjältä keskimäärin korkeimman reittauksen.

Lisää käyttäjän sivulle tieto käyttäjän mielityylistä.

Älä tee kaikkea yhteen metodiin (ellet ratkaise tehtävää tietokantatasolla ActiveRecordilla tai päädy muuten eleganttiin kompaktiin ratkaisuun), vaan määrittele tarvittaessa sopivia apumetodeja. Jos huomaat metodisi sisältävän yli 6 riviä koodia, teet asioita todennäköisesti joko liikaa tai liian kankeasti, joten refaktoroi koodiasi. Rubyn kokoelmissa on paljon tehtävään hyödyllisiä apumetodeja, ks. http://ruby-doc.org/core-2.5.1/Enumerable.html

Kannattaa ehdottomasti hyödyntää rails konsolia kun teet tehtävää

Tee tarvittaessa apumetodeja rspec-tiedostoon, jotta testisi pysyvät siisteinä. Jos apumetodeista tulee samantapaisia, ei kannata copypasteta vaan yleistää ne.

Tehtävä 4

Tee vielä TDD-tyylillä User-olioille metodi favorite_brewery, joka palauttaa panimon, jonka oluet ovat saaneet käyttäjältä keskimäärin korkeimman reittauksen.

Lisää käyttäjän sivulle tieto käyttäjän mielipanimosta.

Metodien favorite_brewery ja favorite_style tarvitsema toiminnallisuus on hyvin samankaltainen ja metodit ovatkin todennäköisesti enemmän tai vähemmän copy-pastea. Viikolla 5 tulee olemaan esimerkki koodin siistimisestä.

Capybara

Siirrymme seuraavaksi järjestelmätason testaukseen. Kirjoitamme siis automatisoituja testejä, jotka käyttävät sovellusta normaalin käyttäjän tapaan selaimen kautta. De facto -tapa Rails-sovellusten selaintason testaamiseen on Capybaran https://github.com/jnicklas/capybara käyttö. Itse testit kirjoitetaan edelleen Rspecillä, capybara tarjoaa siis rspec-testien käyttöön selaimen simuloinnin.

Capybara on oletusarvoisesti määriteltynä projektissa. Lisätään Gemfileen (test-scopeen) apukirjasto launchy eli test-scopen pitäisi näyttää suunilleen seuraavalta:

group :test do
  gem 'rspec-rails', '~> 6.0.0.rc1'
  gem 'factory_bot_rails'
  gem "capybara"
  gem "selenium-webdriver"
  gem "webdrivers"
  gem 'launchy'
end

Jotta gem saadaan käyttöön, suoritetaan tuttu komento bundle install.

Nyt olemme valmiina ensimmäiseen selaintason testiin.

Selaintason testit on tapana sijoittaa hakemistoon spec/features. Yksikkötestit organisoidaan useimmiten siten, että kutakin luokkaa testaavat testit tulevat omaan tiedostoonsa. Ei ole aina itsestään selvää, miten selaimen kautta suoritettavat käyttäjätason testit kannattaisi organisoida. Yksi vaihtoehto on käyttää kontrollerikohtaisia tiedostoja, toinen taas jakaa testit eri tiedostoihin järjestelmän eri toiminnallisuuksien mukaan.

Aloitetaan testien määrittely panimoihin liittyvästä toiminnallisuudesta, luodaan tiedosto spec/features/breweries_page_spec.rb:

require 'rails_helper'

describe "Breweries page" do
  it "should not have any before been created" do
    visit breweries_path
    expect(page).to have_content 'Listing breweries'
    expect(page).to have_content 'Number of breweries: 0'
  end
end

Testi aloittaa navigoimalla visit-metodia käyttäen panimoiden listalle. Kuten huomaamme, Railsin polkuapumetodit ovat myös Rspec-testien käytössä. Tämän jälkeen tarkastetaan sisältääkö renderöity sivu tekstin 'Listing breweries' ja tiedon siitä että panimoiden lukumäärä on 0 eli tekstin 'Number of breweries: 0'. Capybara asettaa sen sivun, jolla testi kulloinkin on muuttujaan page.

Testejä tehdessä tulee (erittäin) usein tilanteita, joissa olisi hyödyllistä nähdä page-muuttujan kuvaaman sivun html-muotoinen lähdekoodi. Tämä onnistuu lisäämällä testiin komento puts page.html

Toinen vaihoehto on lisätä testiin komento save_and_open_page, joka tallettaa ja avaa kyseisen sivun oletusselaimessa. Linuxissa joudut määrittelemään selaimen oletusselaimeksi BROWSER-ympäristömuuttujan avulla. Esim. osaston koneilla saat määriteltyä oletusselaimeksi chromiumin komennolla:

export BROWSER='/usr/bin/chromium-browser'

Määrittely on voimassa vain siinä shellissä jossa teet sen. Jos haluat määrittelystä pysyvän, lisää se tiedostoon ~/.bashrc

Jotta sekä puts page.html, sekä save_and_open_page komennot toimivat on ne sijoitettava ennen testin viimeistä riviä. Molemmat komennot voikin tässä testissä sijoittaa vaikka heti ensimmäiselle riville.

Suorita nyt testi tuttuun tapaan komennolla rspec spec. Jos haluat ajaa ainoastaan nyt määritellyn testin, muista että voit rajata suoritettavat testit antamalla komennon esim. muodossa

rspec spec/features/breweries_page_spec.rb

Testi ei todennäköisesti mene läpi. Selvitä mistä vika johtuu ja korjaa testi tai sivulla oleva teksti. Komennon save_and_open_page käyttö on suositeltavaa!

Lisätään testi, joka testaa tilannetta, jossa tietokannassa on 3 panimoa:

it "lists the existing breweries and their total number" do
  breweries = ["Koff", "Karjala", "Schlenkerla"]
  breweries.each do |brewery_name|
    FactoryBot.create(:brewery, name: brewery_name)
  end

  visit breweries_path

  expect(page).to have_content "Number of breweries: #{breweries.count}"

  breweries.each do |brewery_name|
    expect(page).to have_content brewery_name
  end
end

Lisätään vielä testi, joka tarkastaa, että panimoiden sivulta pääsee linkkiä klikkaamalla yksittäisen panimon sivulle. Hyödynnämme tässä capybaran metodia click_link, jonka avulla on mahdollista klikata sivulla olevaa linkkiä:

it "allows user to navigate to page of a Brewery" do
  breweries = ["Koff", "Karjala", "Schlenkerla"]
  year = 1896
  breweries.each do |brewery_name|
    FactoryBot.create(:brewery, name: brewery_name, year: year += 1)
  end

  visit breweries_path

  click_link "Koff"

  expect(page).to have_content "Koff"
  expect(page).to have_content "Established at 1897"
end

Testi menee läpi olettaen että sivulla käytetty kirjoitusasu on sama kuin testissä. Ongelmatilanteissa testiin kannattaa lisätä komento save_and_open_page ja varmistaa visuaalisesti testin avaaman sivun sisältö.

Kahdessa edellisessä testissä on sama alkuosa, eli aluksi luodaan kolme panimoa ja navigoidaan panimojen sivulle.

Seuraavassa vielä refaktoroitu lopputulos, jossa yhteisen alustuksen omaavat testit on siirretty omaan describe-lohkoon, jolle on määritelty before :each -lohko alustusta varten.

require 'rails_helper'

describe "Breweries page" do
  it "should not have any before been created" do
    visit breweries_path
    expect(page).to have_content 'Listing breweries'
    expect(page).to have_content 'Number of breweries: 0'

  end

  describe "when breweries exists" do
    before :each do
      # jotta muuttuja näkyisi it-lohkoissa, tulee sen nimen alkaa @-merkillä
      @breweries = ["Koff", "Karjala", "Schlenkerla"]
      year = 1896
      @breweries.each do |brewery_name|
        FactoryBot.create(:brewery, name: brewery_name, year: year += 1)
      end

      visit breweries_path
    end

    it "lists the breweries and their total number" do
      expect(page).to have_content "Number of breweries: #{@breweries.count}"
      @breweries.each do |brewery_name|
        expect(page).to have_content brewery_name
      end
    end

    it "allows user to navigate to page of a Brewery" do
      click_link "Koff"

      expect(page).to have_content "Koff"
      expect(page).to have_content "Established at 1897"
    end

  end
end

Huomaa, että describe-lohkon sisällä oleva before :each suoritetaan kertaalleen ennen jokaista describen alla määriteltyä testiä ja jokainen testi alkaa tilanteesta, missä tietokanta on tyhjä.

Kannattaa myös huomata, että jos before :each -lohkossa määriteltyihin muuttujiin on viitattava yksittäisistä testeistä, eli it-lohkoista, tulee muuttujien nimen alkaa @-merkillä.

Käyttäjän toiminnallisuuden testaaminen

Siirrytään käyttäjän toiminnallisuuteen, luodaan tätä varten tiedosto spec/features/users_page_spec.rb. Aloitetaan testillä, joka varmistaa, että käyttäjä pystyy kirjautumaan järjestelmään:

require 'rails_helper'

describe "User" do
  before :each do
    FactoryBot.create :user
  end

  describe "who has signed up" do
    it "can signin with right credentials" do
      visit signin_path
      fill_in('username', with: 'Pekka')
      fill_in('password', with: 'Foobar1')
      click_button('Log in')

      expect(page).to have_content 'Welcome back!'
      expect(page).to have_content 'Pekka'
    end
  end
end

Testi demonstroi lomakkeen kanssa käytävää interaktiota, komento fill_in etsii lomakkeesta id-kentän perusteella tekstikenttää, jolle se syöttää parametrina annetun arvon. click_button toimii kuten arvata saattaa, eli painaa sivulta etsittävää painiketta.

Huomaa, että testissä on before :each-lohko, joka luo ennen jokaista testiä FactoryBotiä käyttäen User-olion. Ilman olion luomista kirjautuminen ei onnistuisi, sillä tietokanta on jokaiseen testin suoritukseen lähdettäessä tyhjä.

Capybaran dokumentaation kohdasta the DSL ks. https://github.com/jnicklas/capybara#the-dsl löytyy lisää esimerkkejä mm. sivulla olevien elementtien etsimiseen ja esim. lomakkeiden käyttämiseen.

Tehdään vielä muutama testi käyttäjälle. Virheellisen salasanan syöttämisen pitäisi ohjata takaisin kirjaantumissivulle:

  describe "who has signed up" do
    # ...

    it "is redirected back to signin form if wrong credentials given" do
      visit signin_path
      fill_in('username', with: 'Pekka')
      fill_in('password', with: 'wrong')
      click_button('Log in')

      expect(current_path).to eq(signin_path)
      expect(page).to have_content 'Username and/or password mismatch'
    end
  end

Testi hyödyntää metodia current_path, joka palauttaa sen polun minne testin suoritus on metodin kutsuhetkellä päätynyt. Metodin avulla varmistetaan, että käyttäjä uudelleenohjataan takaisin kirjautumissivulle epäonnistuneen kirjautumisen jälkeen.

Ei ole aina täysin selvää missä määrin sovelluksen bisneslogiikkaa kannattaa testata selaintason testien kautta. Edellä tekemämme käyttäjä-olion suosikkioluen, panimon ja oluttyylin selvittävien logiikoiden testaaminen on ainakin viisainta tehdä yksikkötesteinä.

Käyttäjätason testein voidaan esim. varmistua, että sivuilla näkyy sama tilanne, joka tietokannassa on, eli esim. panimoiden sivun testissä tietokantaan generoitiin 3 panimoa ja sen jälkeen testattiin että ne kaikki renderöityvät panimoiden listalle.

Myös sivujen kautta tehtävät lisäykset ja poistot kannattaa testata. Esim. seuraavassa testataan, että uuden käyttäjän rekisteröityminen lisää järjestelmän käyttäjien lukumäärää yhdellä:

it "when signed up with good credentials, is added to the system" do
  visit signup_path
  fill_in('user_username', with: 'Brian')
  fill_in('user_password', with: 'Secret55')
  fill_in('user_password_confirmation', with: 'Secret55')

  expect{
    click_button('Create User')
  }.to change{User.count}.by(1)
end

Huomaa, että lomakkeen kentät määriteltiin fill_in-metodeissa hieman eri tavalla kuin kirjautumislomakkeessa. Kenttien id:t voi ja kannattaa aina tarkastaa katsomalla sivun lähdekoodia selaimen view page source -toiminnolla.

Testi siis odottaa, että Create user -painikkeen klikkaaminen muuttaa tietokantaan talletettujen käyttäjien määrää yhdellä. Syntaksi on hieno, mutta kestää hetki ennen kuin koko Rspecin ilmaisuvoimainen kieli alkaa tuntua tutulta.

Pienenä detaljina kannattaa huomioida, että metodille expect voi antaa parametrin kahdella eri tavalla. Jos metodilla testaa jotain arvoa, annetaan testattava arvo suluissa esim expect(current_path).to eq(signin_path). Jos sensijaan testataan jonkin operaation (esim. edellä click_button('Create User')) vaikutusta jonkun sovelluksen olion (User.count) arvoon, välitetään suoritettava operaatio koodilohkona expectille.

Lue aiheesta lisää Rspecin dokumentaatiosta https://relishapp.com/rspec/rspec-expectations/docs/built-in-matchers

Edellinen testi siis testasi, että selaimen tasolla tehty operaatio luo olion tietokantaan. Onko vielä tehtävä erikseen testi, joka testaa että luodulla käyttäjätunnuksella voi kirjautua järjestelmään? Kenties, edellinen testihän ei ota kantaa siihen tallentuiko käyttäjäolio tietokantaan oikein.

Potentiaalisia testauksen kohteita on kuitenkin niin paljon, että kattava testaus on mahdotonta ja testejä tulee pyrkiä ensisijaisesti kirjoittamaan niille asioille, jotka ovat riskialttiita hajoamaan.

Tehdään vielä testi oluen reittaamiselle. Tehdään testiä varten oma tiedosto spec/features/ratings_page_spec.rb

require 'rails_helper'

describe "Rating" do
  let!(:brewery) { FactoryBot.create :brewery, name: "Koff" }
  let!(:beer1) { FactoryBot.create :beer, name: "iso 3", brewery:brewery }
  let!(:beer2) { FactoryBot.create :beer, name: "Karhu", brewery:brewery }
  let!(:user) { FactoryBot.create :user }

  before :each do
    visit signin_path
    fill_in('username', with: 'Pekka')
    fill_in('password', with: 'Foobar1')
    click_button('Log in')
  end

  it "when given, is registered to the beer and user who is signed in" do
    visit new_rating_path
    select('iso 3', from: 'rating[beer_id]')
    fill_in('rating[score]', with: '15')

    expect{
      click_button "Create Rating"
    }.to change{Rating.count}.from(0).to(1)

    expect(user.ratings.count).to eq(1)
    expect(beer1.ratings.count).to eq(1)
    expect(beer1.average_rating).to eq(15.0)
  end
end

Testi rakentaa käyttämänsä panimon, kaksi olutta ja käyttäjän metodin let! aiemmin käyttämämme metodin let sijaan. Näin toimitaan siksi että huutomerkitön versio ei suorita operaatiota välittömästi vaan vasta siinä vaiheessa kun koodi viittaa olioon eksplisiittisesti. Olioon beer1 viitataan koodissa vasta lopun tarkastuksissa, eli jos olisimme luoneet sen metodilla let olisi reittauksen luomisvaiheessa tullut virhe, sillä olut ei olisi vielä ollut kannassa, eikä vastaavaa select-elementtiä olisi löytynyt.

Testin before-lohkossa on koodi, jonka avulla käyttäjä kirjautuu järjestelmään. On todennäköistä, että samaa koodilohkoa tarvitaan useissa eri testitiedostoissa. Useassa eri paikassa tarvittava testikoodi kannattaa eristää omaksi apumetodikseen ja sijoittaa moduuliin, jonka kaikki sitä tarvitsevat testitiedostot voivat sisällyttää itseensä. Luodaan moduli Helpershakemistoon spec sijoitettavaan tiedostoon helpers.rb ja siirretään kirjautumisesta vastaava koodi sinne:

module Helpers

  def sign_in(credentials)
    visit signin_path
    fill_in('username', with:credentials[:username])
    fill_in('password', with:credentials[:password])
    click_button('Log in')
  end
end

Metodi sign_in saa siis käyttäjätunnus/salasanaparin parametrikseen hashina.

Lisätään tiedostoon rails_helper.rb heti muiden require-komentojen jälkeen rivi

require 'helpers'

Voimme ottaa modulin määrittelemän metodi käyttöön testeissä komennolla include Helper:

require 'rails_helper'

include Helpers

describe "Rating" do
  let!(:brewery) { FactoryBot.create :brewery, name: "Koff" }
  let!(:beer1) { FactoryBot.create :beer, name: "iso 3", brewery:brewery }
  let!(:beer2) { FactoryBot.create :beer, name: "Karhu", brewery:brewery }
  let!(:user) { FactoryBot.create :user }

  before :each do
    sign_in(username: "Pekka", password: "Foobar1")
  end

ja

require 'rails_helper'

include Helpers

describe "User" do
  before :each do
    FactoryBot.create :user
  end

  describe "who has signed up" do
    it "can signin with right credentials" do
      sign_in(username: "Pekka", password: "Foobar1")

      expect(page).to have_content 'Welcome back!'
      expect(page).to have_content 'Pekka'
    end

    it "is redirected back to signin form if wrong credentials given" do
      sign_in(username: "Pekka", password: "wrong")

      expect(current_path).to eq(signin_path)
      expect(page).to have_content 'Username and/or password mismatch'
    end
  end

  it "when signed up with good credentials, is added to the system" do
    visit signup_path
    fill_in('user_username', with: 'Brian')
    fill_in('user_password', with: 'Secret55')
    fill_in('user_password_confirmation', with: 'Secret55')

    expect{
      click_button('Create User')
    }.to change{User.count}.by(1)
  end
end

Kirjautumisen toteutuksen siirtäminen apumetodiin siis kasvattaa myös testien luettavuutta, ja jos kirjautumissivun toiminnallisuus myöhemmin muuttuu, on testien ylläpito helppoa, koska muutoksia ei tarvita kuin yhteen kohtaan.

Saattaa olla järkevää siirtää myös aiemmin tiedostoon user_spec.rb määrittelemämme apumetodit create_beer_with_rating ja create_beers_with_many_ratings moduuliin Helpers, erityisesti jos jatkossa tulee tilanteita, joissa samaa toiminnallisuutta tarvitaan muissakin testeissä.

Tehtävä 5

Tee testi, joka varmistaa, että järjestelmään voidaan lisätä www-sivun kautta olut, jos oluen nimikenttä saa validin arvon (eli se on epätyhjä).

Tee myös testi, joka varmistaa, että selain näyttää asiaan kuuluvan virheilmoituksen jos oluen nimi ei ole validi, ja että tälläisessä tapauksessa tietokantaan ei talletu mitään.

Huomaa, että testin on luotava sovellukseen ainakin yksi panimo, jotta oluiden luominen olisi mahdollista.

HUOM: ohjelmassasi saattaa olla bugi tilanteessa, jossa yritetään luoda epävalidin nimen omaava olut. Kokeile toiminnallisuutta selaimesta. Syynä tälle on selitetty viikon alussa, kohdassa https://github.com/mluukkai/WebPalvelinohjelmointi2022/blob/main/web/viikko4.md#muutama-huomio. Korjaa vika koodistasi.

Muista ongelmatilanteissa komento save_and_open_page!

Tehtävä 6

Tee testi joka varmistaa, että tietokannassa olevat reittaukset ja niiden lukumäärä näytetään sivulla ratings. Jos lukumäärää ei toteutuksessani näytetä, korjaa puute.

Vihje: voit tehdä testin esim. siten, että luot aluksi FactoryBotillä reittauksia tietokantaan. Tämän jälkeen voit testata capybaralla sivun ratings sisältöä.

Muista ongelmatilanteissa komento save_and_open_page!

Tehtävä 7

Tee testi joka varmistaa, että käyttäjän reittaukset näytetään käyttäjän sivulla. Käyttäjän sivulla tulee siis näkyä kaikki käyttäjän omat muttei muiden käyttäjien tekemiä reittauksia.

Huomaa, että navigoidessasi käyttäjän user sivulle, joudut antamaan metodille visit polun määritteleväksi parametriksi user_path(user), eli yleensä käytetty lyhempi muoto (olio itse) ei capybaran kanssa toimi.

Tehtävä 8

Tee testi, joka varmistaa että käyttäjän poistaessa oma reittauksensa, se poistuu tietokannasta.

Jos sivulla on useita linkkejä joilla on sama nimi, ei click_link toimi. Joudut tälläisissä tilanteissa yksilöimään mikä linkeistä valitaan, ja se ei ole välttämättä ihan helppoa. Apua löytyy capybaran dokumentaatiosta ja täältä

Vaikka ratkaisu onkin lyhyt, ei tehtävä ole välttämättä helpoimmasta päästä. Jos jäät jumiin, kannattanee tehdä viikon muut tehtävät ensin tai/ja kysyä apua pajassa/Telegramissa.

Tehtävä 9

Jos teit tehtävät 3-4, laajenna käyttäjän sivua siten, että siellä näytetään käyttäjän lempioluttyyli sekä lempipanimo. Tee ominaisuudelle myös capybara-testit. Monimutkaista laskentaa testeissä ei kannata testata, sillä yksikkötestit varmistavat toiminnallisuuden jo riittävissä määrin.

Testauskattavuus

Testien rivikattavuus (line coverage) mittaa kuinka monta prosenttia ohjelman koodiriveistä tulee suoritettua testien suorituksen yhteydessä. Rails-sovelluksen testikattavuus on helppo mitata simplecov-gemin avulla, ks. https://github.com/colszowka/simplecov

Gem otetaan käyttöön lisäämällä Gemfilen test -scopeen rivi

gem 'simplecov', require: false

Huom normaalin bundle install-komennon sijaan saatat joutua antamaan tässä vaiheessa komennon bundle update, jotta kaikista gemeistä saatiin asennetuiksi yhteensopivat versiot.

Jotta simplecov saadaan käyttöön tulee tiedoston rails_helper.rb alkuun, kahdeksi ensimmäiseksi riviksi lisätä seuraavat:

require 'simplecov'
SimpleCov.start('rails')

Sitten ajetaan testit (ongelmatilanteessa ks. ylempi huomautus)

$ rspec spec
..................................

Finished in 1.25 seconds (files took 1.95 seconds to load)
34 examples, 0 failures

Coverage report generated for RSpec to /Users/mluukkai/opetus/ratebeer/coverage. 161 / 333 LOC (48.35%) covered.

Testien rivikattavuus on siis 48.35 prosenttia. Tarkempi raportti on nähtävissä avaamalla selaimella tiedosto coverage/index.html. Kuten kuva paljastaa, on suuria osia ohjelmasta, erityisesti kontrollereista vielä erittäin huonosti testattu:

kuva

Suurikaan rivikattavuus ei tietysti vielä takaa että testit testaavat järkeviä asioita. Helposti mitattavana metriikkana se on kuitenkin parempi kuin ei mitään ja näyttää ainakin ilmeisimmät puutteet testeissä.

Tehtävä 10

Ota simplecov käyttöön ohjelmassasi. Tutki raportista (klikkaamalla punaisella tai keltaisella merkittyjä luokkia) mitä rivejä koodissasi on vielä täysin testaamatta.

Jatkuva integraatio

Jatkuvalla integraatiolla (engl. continuous integration) tarkoitetaan käytännettä, jossa ohjelmistokehittäjät integroivat koodiin tekemänsä muutokset yhteiseen kehityshaaraan mahdollisimman usein. Periaatteena on pitää ohjelman kehitysversio koko ajan toimivana eliminoiden näin raskas erillinen integrointivaihe. Toimiakseen jatkuva integraatio edellyttää kattavaa automaattisten testien joukkoa. Yleensä jatkuvan integraation yhteydessä käytetään keskitettyä palvelinta, joka tarkkailee repositorioa, jolla kehitysversio sijaitsee. Kun kehittäjä integroi koodin kehitysversioon, integraatiopalvelin huomaa muutoksen, buildaa koodin ja ajaa testit. Jos testit eivät mene läpi, tiedottaa integraatiopalvelin tästä tavalla tai toisella asianomaisia.

Github tarjoaa kehittäjien käyttöön Github Actionsin, joka onkin saanut paljon jalansijaa muiden CI:tä tarjoavien palveluiden joukossa. Github Actionsin puolesta puhuu sen integraatio suoraan githubiin, sekä actionsien marketplace josta löytyy CI:hin lisättäviä actioneja. Näistä lisää myöhemmin.

Githubissa olevat projektit on helppo asettaa Github actionsin tarkkailtaviksi.

Tehtävä 11

Tämän ja parin seuraavan tehtävän tekeminen ei ole välttämätöntä viikon jatkamisen kannalta. Voit tehdä tämän tehtävän myös viikon muiden tehtävien jälkeen.

Mene oman projektisi repositorioon ja paina yläpalkista Actions-painiketta. Jos sinulla ei ole olemassaolevia actioneja github vie sinut suoraan sivulle, jossa ehdotetaan valmiita pohjia valittavaksi.

kuva

Valitaan Ruby on Rails painamalla Configure-nappia. Tämän seurauksena github vie sivulle jossa muokataan rubyonrails.yml nimistä tiedostoa. Tämä workflow tiedosto kertoo Github actionsille mitä CI:n tulee tehdä.

Tiedoston sisältö ei sellaisena kuitenkaan toimi, joten vaihdetaan sisällöksi aluksi seuraava:

# This workflow uses actions that are not certified by GitHub. They are
# provided by a third-party and are governed by separate terms of service,
# privacy policy, and support documentation.
#
# This workflow will install a prebuilt Ruby version, install dependencies, and
# run tests and linters.
name: "Ruby on Rails CI"
on:
  push:
    branches: [ "main" ] # Jos repositoriosi päähaara ei ole main, muuta nämä
  pull_request:
    branches: [ "main" ]
jobs:
  test:
    runs-on: ubuntu-22.04
    services:
      postgres:
        image: postgres:11-alpine
        ports:
          - "5432:5432"
        env:
          POSTGRES_DB: rails_test
          POSTGRES_USER: rails
          POSTGRES_PASSWORD: password
    env:
      RAILS_ENV: test
      DATABASE_URL: "postgres://rails:password@localhost:5432/rails_test"
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
      # Add or replace dependency steps here
      - name: Install Ruby and gems
        uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
      - name: Run tests
        run: bundle exec rspec

Erona defaultina tarjottavaan versioon tässä on se, että sekä Ubuntusta ja Rubyn setuppaavasta actionista käytetään uusimpia versiota, jotta Rubyn 3.1.2. versio toimii.

Vaihdettuasi sisällön valitse Start commit ja lisää tiedosto versionhallintaasi. GitHub Actions lähteekin suoraan käyntiin ja suorittaa testit.

Jos jokin testi ei toimi GitHub actionseissa korjaa se!

Tehtävä 12

Lisätään nyt myös Rubocop GitHub Actioniin. Käytetään tässä avuksemme marketplacesta valmiiksi löytyvää actionia, jonka voimme liittää omaamme.

Lisää rubyonrails.yml tiedostoon seuraava sisältö:

 lint:
   runs-on: ubuntu-22.04
   steps:
     - name: Checkout code
       uses: actions/checkout@v3
     - name: Install Ruby and gems
       uses: ruby/setup-ruby@v1
       with:
         bundler-cache: true
     - name: RuboCop Linter Action
       uses: andrewmcodes-archive/[email protected]
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

GITHUB_TOKEN rivillä käytetään Githubin tarjoamaa automaattista tokenia, jolla pystytään autentikoimaan githubin sovellukset.

Nyt Github Actionsin pitäisi suorittaa sekä testit, että Rubocop sovellukselle joka kerta kun GitHubiin lisätään muutoksia.

Continuous delivery

Jatkuvaa integraatiota vielä askeleen eteenpäin viety käytäntö on jatkuva toimittaminen eng. continuous delivery http://en.wikipedia.org/wiki/Continuous_delivery jonka yhtenä osatekijänä on jatkuva deployaus, eli idea, jonka mukaan sovelluksen uusin versio aina integroimisen yhteydessä myös deployataan eli käynnistetään tuotantoympäristön kaltaiseen ympäristöön tai parhaassa tapauksessa suoraan tuotantoon.

Eriyisesti Web-sovellusten yhteydessä jatkuva deployaaminen saattaa olla hyvinkin vaivaton operaatio.

Tehtävä 13

Tämän ja seuraavan tehtävän tekeminen ei ole välttämätöntä viikon jatkamisen kannalta. Voit tehdä tämän tehtävän myös viikon muiden tehtävien jälkeen.

Toteuta sovelluksellesi jatkuva automaattinen deployaaminen Fly.io:n tai Herokuun

Fly.io:n ohje seuraavasta https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/

Herokuun ohje seuraavasta https://devcenter.heroku.com/articles/github-integration

HUOM Käyttäessäsi Herokua, muista valita "Wait for CI to pass before deploy"

Voit testata toimiiko CI/CD-putkesi tekemällä jonkin muutoksen sovellukseesi ja lisäämllä muutoksen GitHubiin ja seuraamalla tuleeko muutos myös Fly.io:ssa/Herokussa olevaan sovellukseesi. Herokun tapauksessa näet Overview välilehdeltä "Latest Activity" syötteestä mitä putkessa tapahtuu.

Koodin laatumetriikat

Testauskattavuuden lisäksi myös koodin laatua kannattaa valvoa. SaaS-palveluna toimivan Codeclimaten https://codeclimate.com avulla voidaan generoida Rails-koodista erilaisia laatumetriikoita.

Tehtävä 14

Tämän tehtävän tekeminen ei ole välttämätöntä viikon jatkamisen kannalta. Voit tehdä tämän tehtävän myös viikon muiden tehtävien jälkeen.

Codeclimate on ilmainen opensource-projekteille. Kirjaudu sovelukseen ositteessa https://codeclimate.com/login/github/join ja lisää projektisi "Open source"-osista.

Codeclimate valittelee todennäköisesti koodissa olevasta samanlaisuudesta. Kyseessä on kuitenkin Rails scaffoldingin luoma hieman ruma koodi, joten sen voi jättää paikalleen.

Linkitä myös laatumetriikkaraportti repositorion README-tiedostoon:

Löydät linkin raporttiin seuraavasti

kuva

Nyt myös codeclimate aiheuttaa sovelluskehittäjälle sopivasti painetta pitää koodi koko ajan hyvälaatuisena!

Sovelluskehittäjän elämää helpottavien pilvipalveluiden määrä kasvaa kovaa vauhtia. Simplecov:in sijaan tai lisäksi testauskattavuuden raportoinnin voi delegoida Coveralls https://coveralls.io/ -nimiselle pilvipalvelulle. Jätämme sen kuitenkin tälläkertaa tekemättä.

Kirjautuneiden toiminnot

Jätetään testien teko hetkeksi ja palataan muutamaan aiempaan teemaan. Viikolla 2 rajoitimme http basic -autentikaation avulla sovellustamme siten, että ainoastaan admin-salasanan syöttämällä oli mahdollista poistaa panimoita. Viikolla 3 rajoitimme sovelluksen toiminnallisuutta siten, että reittausten poistaminen ei ole mahdollista kuin reittauksen tehneelle käyttäjälle. Sen sijaan esim. olutkerhojen ja oluiden luominen, poistaminen ja editointi on tällä hetkellä mahdollista jopa ilman kirjautumista.

Luovutaan http basic -autentikoinnin käytöstä ja muutetaan sovellusta siten, että oluita, panimoita ja olutkerhoja voivat luoda, muokata ja poistaa ainoastaan kirjautuneet käyttäjät.

Aloitetaan poistamalla http basic -autentikaatio. Eli poistetaan panimokontrollerista rivi

before_action :authenticate, only: [:destroy]

sekä metodi authenticate. Nyt kuka tahansa voi jälleen poistaa panimoita.

Aloitetaan suojauksen lisääminen.

Näkymistä on helppo poistaa oluiden, olutkerhojen ja panimoiden muokkaus -ja luontilinkit siinä tapauksessa, jos käyttäjä ei ole kirjautunut järjestelmään.

Esim. näkymästä views/beers/index.html.erb voidaan nyt poistaa kirjautumattomilta käyttäjiltä sivun lopussa oleva oluiden luomislinkki:

<% if not current_user.nil? %>
  <%= link_to "New beer", new_beer_path %>
<% end %>

Eli linkkielementti näytetään ainoastaan jos current_user ei ole nil. Voimme myös hyödyntää if:in kompaktimpaa muotoa:

<%= link_to('New Beer', new_beer_path) if not current_user.nil? %>

Nyt siis link_to metodi suoritetaan (eli linkin koodi renderöityy) ainoastaan jos if:in ehto on tosi. if not -muotoiset ehtolauseet eivät ole kovin hyvää Rubya, parempi olisikin käyttää unless-ehtolausetta:

<%= link_to('New Beer', new_beer_path) unless current_user.nil? %>

Eli renderöidään linkki ellei current_user ei ole nil.

Oikeastaan unless on nyt tarpeeton, Rubyssä nimittäin nil tulkitaan epätodeksi, eli kaikkien siistein muoto komennosta on

<%= link_to('New Beer', new_beer_path) if current_user %>

Poistamme lisäys-, poisto- ja editointilinkit pian, ensin kuitenkin tarkastellaan kontrolleritason suojausta, nimittäin vaikka kaikki linkit rajoitettuihin toimenpiteisiin poistettaisiin, ei mikään estä tekemästä suoraa HTTP-pyyntöä sovellukselle ja tekemästä näin kirjautumattomilta rajoitettua toimenpidettä.

On siis vielä tehtävä kontrolleritasolle varmistus, että jos kirjautumaton käyttäjä jostain syystä yrittää tehdä suoraan HTTP:llä kiellettyä toimenpidettä, ei toimenpidettä suoriteta.

Päätetään ohjata rajoitettua toimenpidettä yrittävä kirjautumaton käyttäjä kirjautumissivulle.

Määritellään luokkaan ApplicationController seuraava metodi:

def ensure_that_signed_in
  redirect_to signin_path, notice: 'you should be signed in' if current_user.nil?
end

Eli jos metodia kutsuttaessa käyttäjä ei ole kirjautunut, suoritetaan uudelleenohjaus kirjautumissivulle. Koska metodi on sijoitettu luokkaan ApplicationController jonka kaikki kontrollerit perivät, on se kaikkien kontrollereiden käytössä.

Lisätään metodi esifiltteriksi (ks. http://guides.rubyonrails.org/action_controller_overview.html#filters ja https://github.com/mluukkai/WebPalvelinohjelmointi2022/blob/main/web/viikko2.md#yksinkertainen-suojaus) olut- ja panimo- ja olutkerhokontrollerille kaikille metodeille paitsi index:ille ja show:lle:

class BeersController < ApplicationController
  before_action :ensure_that_signed_in, except: [:index, :show]

  #...
end

Esim. uutta olutta luotaessa, ennen metodin create suorittamista, Rails suorittaa esifiltterin ensure_that_signed_in, joka ohjaa kirjautumattoman käyttäjän kirjautumissivulle. Jos käyttäjä on kirjautunut järjestelmään, ei filtterimetodi tee mitään, ja uusi olut luodaan normaaliin tapaan.

Kokeile selaimella, että muutokset toimivat, eli että kirjautumaton käyttäjä ohjautuu kirjautumissivulle kaikilla esifiltterillä rajoitetuilla toiminnoilla mutta että kirjautuneet pääsevät sivuille ilman ongelmaa.

Tehtävä 15

Estä esifiltterien avulla kirjautumattomilta käyttäjiltä panimoiden ja olutseurojen suhteen muut toiminnot paitsi kaikkien listaus ja yksittäisen resurssin tietojen tarkastelu (eli metodit show ja index)

Kun olet varmistanut että toiminnallisuus on kunnossa, poista näkymistä ylimääräiset luomis-, poisto- ja editointilinkit kirjautumattomilta käyttäjiltä

Tehtävä 16

Tehtävää 15 ennen tekemiemme laajennustan takia muutama ohjelman testeistä menee rikki. Korjaa testit

Sovelluksen ulkoasun hienosäätö

Voit halutessasi tehdä hienosäätöä sovelluksen näkymiin, esim. poistaa resurssien poisto- ja editointilinkit listaussivulta. Nämä muutokset eivät ole välttämättömiä ja tulevat viikotkaan eivät muutoksiin nojaa.

Tehtävien palautus

Commitoi kaikki tekemäsi muutokset ja pushaa koodi GitHubiin. Deployaa myös uusin versio Fly.io:n tai Herokuun. Muista myös testata Rubocopilla, että koodisi noudattaa edelleen määriteltyjä tyylisääntöjä.

Tehtävät kirjataan palautetuksi osoitteeseen https://studies.cs.helsinki.fi/stats/courses/rails2022/