3 juni 2014

Bygg ett brädspel i Google Apps Script. Del 2: Spelregler och testdriven utveckling

Vi fortsätter där vi slutade i del 1. Öppna kalkylarket med spelplanen så går vi vidare.

Provocera fram det första felet

Vi är nöjda så här långt med med spelplanens färg och form. Nu ersätter vi våra tio rader i kolumn E med anrop till en rättningsfunktion som ger ett svar av typen BBBW beroende på vår gissning och vad som är det hemliga svaret. Vi kallar rättningsfunktionen för grade() och för att kunna göra sitt jobb behöver den veta vår gissning på fyra färger och också det rätta svaret som gissningen ska jämföras med.

Från den sammanslagna cellen E1/E2 anropar vi grade() genom formeln =grade(A1;B1;C1;D1)
Cellerna A1–D1 innehåller den första gissningen.


Nu får vi många upplagor av felet #NAME?. Det är ett framsteg, för det visar att grade() faktiskt anropas men går snett – vi har ju inte skrivit den än. Vi kan sammanfatta det så här:
  • TDD-princip: Skriv testkoden först
Vad gör vi nu? Något är sönder så det kliar i fingrarna att fixa det :) En stor fördel är att vi faktiskt inte kan missa att något är fel även om vi blir avbrutna eller behöver lämna över till någon annan att jobba vidare med vårt projekt. Felet syns ju direkt.

Det enklaste vi kan göra är att fejka grade() så att den alltid ger samma svar, t.ex. BWWB.
  • TDD-princip: Fejka koden så länge du kan
Hittills har vi inte haft någon programkod mer än vad som ingår i kalkylarket i form av villkorlig formattering och anropet till strängfunktionen MID() men nu är det dags att börja koda spelregler och vi börjar alltså med vår fejkade rättningsfunktion, grade().

Vi lägger till en kodmodul

I kalkylarket väljer vi Verktyg > Skriptredigerare ... Välj att skapa en ny skriptfil för kalkylark. Spara projektet under lämpligt namn, t.ex. Kod för Mastermind. Till vänster visas fliken Kod.gs och till höger ser man vilken kod den innehåller. Ta bort exempelkoden.

I kodmodulen Kod.gs lägger vi till litet kod för vår grade()-funktion. Programmeringsspråket är Google Apps Script, nära släkting till JavaScript.

function grade(g1, g2, g3, g4) { 
  return 'BWWB';
} // grade

Här ser vi litet av det som är speciellt för testdriven utveckling. Vi skriver testet först och får en gratis påminnelse om att något behöver rättas eller göras klart.

En förenklad bild av traditionell programutveckling är att testningen kommer efter koden:
  1. Skriv kod, t.ex. funktionen grade()
  2. Anropa funktionen
  3. Testa den nya funktionen
  4. Funkar den inte? Felsök den och gå till steg 3.
  5. Börja om på steg 1 med nästa funktion
Testdriven utveckling betonar stegen litet annorlunda:
  1. Skriv ett test, i vårt fall anropet till grade()
  2. Kolla om testet går igenom eller inte
  3. Om testet går fel, skriv minsta möjliga kod för att få det att fungera
  4. Behöver produktions- eller testkoden snyggas upp? Gör det i så fall genom att t.ex. ta bort upprepningar eller flytta om kod så att den är lättare att läsa
  5. Börja om på steg 1 genom att lägga till fler tester
Följer vi testdriven utveckling har vi alltså antingen 1) ett test som inte passerar eller 2) fungerande kod med automatiska tester. Det finns alltså ingen kod som »hänger i luften« i väntan på ett eller flera test. Det betyder också att TDD påverkar vårt programmerande på en ganska detaljerad nivå. Ska det funka måste testerna gå att köra rimligt snabbt och de måste till stor del vara automatiska.

Hur startar man ett nytt spel?

Vi vill kunna starta ett nytt spel med en meny. Lägg till kod för menyn, onOpen(), i Kod.gs på lämpligt ställe, kanske före grade(). Det behövs något i följande stil:

'use strict';
function onOpen() {
  // Add menu items
  SpreadsheetApp.getUi()
    .createMenu('Mastermind')
    .addItem('New Game', 'menuNewGame')
    .addToUi();
} // onOpen

För att lägga till en meny behöver Google Apps Script funktionen onOpen() som anropas när arket öppnas. Där lägger vi vår kod för menyn. Menyn heter Mastermind, det hittills enda menyvalet är New Game och när det väljs så vill vi att funktionen menuNewGame() ska anropas. Funktionerna onOpengetUicreateMenuaddItem och addToUi finns beskrivna i hjälpen för Google Apps Script som man hittar i kodfönstret via Hjälp > Referens till API.

Spara koden. Gå sedan tillbaks till kalkylarket och ladda om med F5 för att den nya koden ska läsas in. Testa menyn Mastermind > New Game och det blir fel som väntat, dvs. allt väl! Vi ser menyn och vi vet också att menuNewGame() faktiskt anropas. Det är ett lagom myrsteg mot målet.

Vad händer nu? Vi fejkar så klart menuNewGame(). För att få bort felmeddelandet räcker det att vi skriver minsta möjliga, alltså en tom funktion.

function menuNewGame() {
} // menuNewGame

Nytt spel = tom spelplan?

Spara koden och ladda om spelplanen med F5. När vi nu väljer menyn Mastermind > New Game får vi inget fel men å andra sidan händer det inget bra heller. Vi vill ju helst ha en tom spelplan att börja med. Vi behöver beskriva vår önskan i ett testfall och ha ett sätt att köra testet.

Varför inte lägga till ett menyval som kör testerna åt oss? Vi kan ju gömma den delen av menyn sedan när vanligt folk ska använda spelet. Två nya rader kod hjälper oss:

function onOpen() {
   SpreadsheetApp.getUi()
    .createMenu('Mastermind')
    .addItem('New Game', 'menuNewGame')
    .addSeparator()
    .addItem('Run Tests', 'menuRunTests')
    .addToUi();
} // onOpen

Spara koden och ladda om spelplanen med F5. Försök att köra testerna från menyn, alltså Mastermind > Run Tests. Det blir fel, helt enligt plan.

Funktionen menuRunTests blir vår testmotor. Den ansvarar för att testerna körs och blir ett slags »önskelista« som beskriver spelreglerna och spelarens förväntningar.

För ordningens skulle lägger vi testkoden i en egen modul. Gå till Verktyg > Skriptredigerare och lägg till en ny skriptfil via Arkiv > Nytt > Skriptfil. Döp den nya skriptfilen till Test.gs. Vi har nu två skriptfiler, Kod.gs och Test.gs.

Lägg in en tom funktion menuRunTests() i Test.gs:

function menuRunTests() {
} // menuRunTests

Spara koden och ladda om spelplanen med F5. Välj MasterMind > Run Tests. Vi får inget felmeddelande som förra gången men inget mer händer. Vi behöver formulera vår önskan om en tom spelplan i början av ett nytt spel.

Jag har inte hittat något bra sätt att anropa menyer kodmässigt så vi försöker med följande:

function menuRunTests() {
  menuNewGame(); // Istället för menyanrop 
  expectEmptyGrid();
} // menuRunTests

Önskemålet att få en tom spelplan blir ett test som vi kallar expectEmptyGrid().
Spara koden och ladda om spelplanen med F5. Välj Mastermind > Run Tests. Fel igen :)

Det är expectEmptyGrid() som hindrar oss att komma vidare. Om vi fejkar testkoden lurar vi bara oss själva eftersom den ska uttrycka ett testfall, dvs. att vi förväntar oss en tom spelplan. Så det är bara att försöka koda testet, t.ex. så här. Lägg koden i Test.gs under funktionen menuRunTests().

function expectEmptyGrid() {
  if (! thisSheet().getRange('A1:D20').isBlank()) 
    throw 'expectEmptyGrid: range is not empty upon start';
} // expectEmptyGrid

Testet expectEmptyGrid() anropar throw för att protestera med ett felmeddelande om spelplanen inte är tom, i vårt fall matrisen A1:D20 i arket. Funktionerna getRange() och isBlank() hittar vi på Google Apps Script hjäpsida. 

thisSheet() som används i expectEmptyGrid() är en hjälpfunktion som letar upp spelplanen åt oss bland flikarna i kalkylarket. Den gör vår kod litet mer läslig.

function thisSheet() {
  var spreadsheet = SpreadsheetApp.getActive();
  return spreadsheet.getSheetByName('Mastermind');
} // thisSheet

Spara koden och ladda om spelplanen med F5. Välj Mastermind > Run Tests. Kolla att testet verkar rätt genom att köra det med en tom och sedan en delvis ifylld spelplan.
  • TDD-princip: Kolla att testerna är rimliga genom att ändra eller vända på indata, vilket i vårt fall är en tom respektive ifylld spelplan.

Gör testkörningen mer synlig

När allt går väl är det litet tråkigt: testkörningen ger inget synligt resultat. Därför lägger vi till ett meddelande i slutet av testmotorn i menuRunTests(). Meddelandet visar att vi kom till slutet av testkörningen utan fel.

function menuRunTests() {
  menuNewGame();  
  expectEmptyGrid();
  checkpoint('So succses. Wow');
} // menuRunTests

och lägger till hjälpfunktionen checkpoint() som visar meddelandet:

function checkpoint(s) {
  SpreadsheetApp.getActiveSpreadsheet().toast(s);
} // checkpoint

Vi lägger också till en rad i expectEmptyGrid() så att vi får ett livstecken när testkörningen börjar:

function expectEmptyGrid() {
  checkpoint('expectEmptyGrid');
  if (! thisSheet().getRange('A1:D20').isBlank()) 
    throw 'expectEmptyGrid: range is not empty upon start';
} // expectEmptyGrid

Det bidde ett litet ramverk

Nu har vi en bra mall för kommande testfall:
  1. Annonsera att testet börjar genom att anropa checkpoint() med testets namn
  2. Protestera med throw när något går fel i testet
  3. Låt testmotorn menuRunTests() tala om när vi passerat sista testet.
Det finns färdiga testramverk, t.ex. Jasmine, som innehåller mycket mer avancerat teststöd men nu har vi ett litet ramverk själva som räcker för vår övning.

Städdags på spelplanen 

Spara koden, gå tillbaks kalkylarket och ladda om med F5. Välj Mastermind > Run Tests. Om spelplanen inte var tom fallerar testet, så vi vet precis vad som behöver göras: menuNewGame() behöver göra sitt jobb och städa spelplanen.

Finns det något sätt att fejka menuNewGame()? Vi bestämmer oss för att det inte är värt besväret. Det är dags att skriva koden som städar spelplanen på riktigt. Vi letar litet i hjälptexterna kring objektet kalkylark (Spreadsheet) och ser att funktionen clearContent() kan fungera. Så här kan koden se ut, i Kod.gs:

function menuNewGame() {
  thisSheet().getRange('A1:D20').clearContent();
} // menuNewGame

thisSheet().getRange() ger oss ett objekt som representerar en del av kalkylarket.

Spara koden, gå tillbaks kalkylarket och ladda om med F5. Välj Mastermind > Run Tests. Testkörningen går bra.

Det är dags att provköra spelet. Vi konstaterar snabbt att betyget på gissningarna alltid är detsamma vilket är litet konstigt för en ovan användare. Vi drar oss till minnes att vi har fejkat koden som sätter betyg. Det finns inga testfall som talar om hur betygssättningen ska fungera så det är bara att sätta igång och få till ett felande test.

Vi följer vår tidigare mall. I testmotorn menuRunTests() lägger vi därför till ett anrop till expectCorrectGrading(). Mastermind > Run Tests fallerar som det ska.

Minsta möjliga sätt att komma vidare är att lägga till en tom funktion som heter expectCorrectGrading(). Så nu ser vår testkod ut så här:

function menuRunTests() { 
  menuNewGame();  
  expectEmptyGrid();
  expectCorrectGrading();  
  checkpoint('So succses. Wow');
} // menuRunTests

function expectEmptyGrid() {
  checkpoint('expectEmptyGrid');
  if (! thisSheet().getRange('A1:D20').isBlank()) 
    throw 'expectEmptyGrid: range is not empty upon start';
} // expectEmptyGrid

function expectCorrectGrading() {
  checkpoint('expectCorrectGrading');
} // expectCorrectGrading

Hur kommer vi vidare på ett enkelt sätt? Vad betyget är beror ju på hemligheten ... Vi fejkar grade() så långt vi kan och tänker oss att hemligheten är 1211 och ger betygsexempel baserat på det:
  1. grade(1;2;1;1) ska ge 'BBBB'
  2. grade(1;2;3;4) ska ge 'BB' 
  3. grade(5;5;5;5) ska ge ''
  4. grade(2;1;2;2) ska ge 'W'
Säg att vi lägger till ett testfall i taget. Vi börjar med 1 och får något i stil med

function expectCorrectGrading() {
  checkpoint('expectCorrectGrading');
  var g = grade('1', '2', '1', '1');
  if (g != 'BBBB' )
    throw "expectCorrectGrading: expected 'BBBB', got '" + g + "'";
} // expectCorrectGrading

Här anropar vi grade() med gissningen '1211'. Om svaret är fel rapporterar vi det.

Hur kommer vi vidare? Vi kan fejka grade() så att den ger svaret 'BBBB'. Då kommer testfall 1 att passera.

När vi lägger till testfall 2, 3 och 4 kan vi fortsätta att fejka grade() genom att notera vad rätta svaret ska vara och returnera det. Koden för grade() blir längre och längre och efter en stunds harvande inser vi att det är dags att skriva en riktig grade(). För att göra sitt jobb måste grade() känna till hemligheten. Vi anropar funktionen readSecret() för att få reda på den. Så här kan grade() se ut i Kod.gs:

function grade(g1, g2, g3, g4) { 
  // Sätt betyg med ett 'B' för rätt färg på rätt plats och 'W' för rätt färg på fel plats. Alla rätt är alltså 'BBBB'
  var out = '';
  var g = [g1, g2, g3, g4]; // Lättare att hantera en numrerad lista än fyra separata variabler
  var s = readSecret().split('');
  // Svarta: rätt färg på rätt plats
  for (var i = 0; i < 4; i++) {
    if (g[i] == s[i] ) {
      out += 'B';
      g[i] = s[i] = ' ';
    }
  }
  // Räkna vita genom att leta efter kvarvarande gissningar i s
  for (var i = 0; i < 4; i++) {
    if (g[i] != ' ') {
       n = s.indexOf(g[i]);
       if ( n >= 0 ) {
         out += 'W';
         s[n] = ' ';
       }
     }
  }
  return (out + '    ').slice(0,4); // fyll på med mellanslag så att vi alltid får fyra tecken i svaret
} // grade

Kör testerna. De fallerar eftersom readSecret() saknas. Vi fejkar readSecret() så att den alltid ger '1211' som hemlighet så här:

function readSecret() {
  return '1211';
} // readSecret

Kör testerna. Allt bör gå bra nu. Testa spelet igen.

Vi inser att grade() inte ska ge något svar förrän vi har fyllt i alla fyra gissningarna. Så vi lägger till ett testfall av typen att grade('1'; '2'; ''; '') ska ge ett blankt betyg.

Kör testerna. grade() behöver rättas till. Den här koden kontrollerar att gissningen består av fyra siffror. Lägg till den i början av grade().

  var pattern = new RegExp('[1-6]{4}');
  if ( !pattern.test(g.join('')) ) 
    return '    ';

Kör testerna igen. Nu ser det rätt ut, men det är litet tråkigt att ha samma hemlighet varje gång :)

Så vi skriver ett nytt testfall. Anropa
expectDifferentSecrets(), kör testerna och konstatera att något är sönder.

Koden för expectDifferentSecrets() kan se ut så här. Koden testar dels att hemligheten verkligen sparas, dels att det blir minst nio olika unika gissningar av tio.

function expectDifferentSecrets() { 
  checkpoint('expectDifferentSecrets');

  var secrets = [];
  var count = 0;
  for (var i = 0; i < 10; i++) {
    var s = newSecret();
    if ( s != readSecret() ) 
      throw 'expectDifferentSecrets(): new secret is not saved';
    secrets.push(s);
  }
  count = uniqueElementCount(secrets);
  if ( count < 9 )
    throw 'expectDifferentSecrets() failed with only ' + count + ' different secrets';
} // expectDifferentSecrets
  • TDD-princip: Maktbalans mellan testkod och produktionskod
Fejka en tom newSecret(), testkör och se att det inte duger.

function newSecret() {
   return '1211';
} // newSecret

Förbättra vår fejkade newSecret() litet genom att spara svaret '1211'

function newSecret() {
  var secret = '1211';
  thisSheet().getRange('I1').setValue(secret);
  return secret;
} // newSecret

Kör testerna igen och fundera på det nya felet.

Koda den hemliga slumpgeneratorn, på riktigt den här gången. Det finns flera sätt att göra det. Här har jag valt att generera ett slumptal i intervallet 0 till 5555 räknat i talbasen 6 och sedan lägga till 1111. Slutresultatet blir då ett slumptal mellan 1111 och 6666 som blir den hemliga koden.
Math.pow(6, 4) är 6^4 och toString(6) omvandlar ett tal till talbas 6.

function newSecret() {
  var secret = '';
  secret = parseInt(Math.floor(Math.random() * Math.pow(6, 4)).toString(6)) + 1111;
  thisSheet().getRange('I1').setValue(secret);
  return secret;
} // newSecret

Kör testerna igen: nytt fel, eftersom 1211 inte är hemligheten varje gång längre.

Lägg till en ny parameter till newSecret() så att vi kan tvinga på den en viss hemlighet för att göra testningen enklare.
  • TDD-princip: ibland lönar det sig att göra koden mer testvänlig.
function newSecret(suggestion) {
  var secret = '';
  if (typeof suggestion == 'string' && suggestion.length == 4) {
    secret = suggestion;
  } else {
    secret = parseInt(Math.floor(Math.random() * Math.pow(6, 4)).toString(6)) + 1111;
  }   
  thisSheet().getRange('I1').setValue(secret);
  return secret;
} // newSecret

Kör testerna.

Testa spelet ur användarens perspektiv. Saknas några testfall?

Sammanfattning

Emily Baches sammanfattning
Vi har byggt ett enkelt testramverk och förbättrat det bit för bit
  • Det går att använda de inbyggda felsökningsverktygen för att köra enstaka tester
Huvudidé: små steg precis som testgeten. Kan lita på att vi aldrig är långt från ett fungerande system. Mindre stressande.
  • Bonus. om du har minst ett testfall som går fel vet du alltid vad du ska börja nästa gång :)
Huvudtanke: behåll maktbalanensen mellan kod och automattester
  • Tydlig (åter)koppling mellan tester och produktionskod. Låt test och kod påverka varandra på ett fruktbar sätt: mindre mängd otestbar kod, mindre av testning som ett nödvändigt ont
Huvudtanke: Testerna hjälper till när vi städar upp eller bygger om koden, refaktoriserar.

Källa

Annat intressant som inte tas upp här

  • Versionshantering, som behövs för att hålla reda på alla små steg. Google Apps stöder det.
  • Testprestanda: testerna behöver kunna köras tillräckligt snabbt för att vara till hjälp!
  • Hur man hanterar testdata och startvillkor. I vårt fall använde vi samma kalkylark för testning.
  • Hur man bryter ner krav/önskemål till testfall
  • Problem som uppstår när Apps Script och kalkylarket har olika åsikter om datatyper.

De tre TDD-reglerna

  1. Du får inte skriva någon produktionskod, förutom för att få ett testfall att gå igenom.
  2. Du får inte skriva ett längre testfall än vad som behövs för att misslyckas; kompileringsfel räknas också som fel.
  3. Du får inte skriva mer produktionskod än vad som krävs för att passera ett misslyckat testfall.
Källa: The Three Rules of TDD

    Läs mer