Kapitel 2. Test først paradigmet

Indholdsfortegnelse

Game of life
Har vi lært noget?
Findpudsning
Game over
Videre med testing
Pointer for dette kapitel

Jeg (Simon) udarbejder oftest afprøvningskode inden jeg går igang med at implementere koden. Jeg tilføjer et tomt afprøvningstilfælde, forsøgt navngivet efter hvad jeg har til hensigt med at teste. Jeg kigger på testcasen og langsomeligt får jeg manifesteret mine tanker i form af testkode. Når jeg føler at det som jeg har til hensigt med at testes, bliver tilstrækkeligt grundigt afprøvet i testcasen, så kan jeg gå igang med at implementere. Jeg skriver kode indtil at testcasen passer OK, hvor jeg så straks kan kaste mig over det næste punkt i todo-listen.

Sjældent går det så let som ovenfor og man vil altid komme ud for ubehagelige overraskelser. Vi vil derfor vise et detaljeret eksempel på hvordan paradigmet fungere i praksis. Allerførst skal man gøre sig nogle ideer om hvad man agter at lave.

Game of life

Reglerne er følgende:

  1. En befolket celle, med 0..1 naboer, dør af ensomhed.

  2. En befolket celle, med 2..3 naboer, overlever.

  3. En befolket celle, med >= 4 naboer, dør af overbefolkning.

  4. En tom celle, med 3 naboer, bliver befolket.

Lad os tage udgangspunkt i disse regler.

require 'test/unit'
class TestGameOfLife < Test::Unit::TestCase
  def determine_destiny(alive, count)
    # not yet implemented
  end
  def test_destiny_populated
    data = [
      [0, false],
      [1, false],
      [2, true],
      [3, true],
      [4, false],
      [5, false],
      [6, false]
    ]
    input, expected = data.transpose
    actual = input.map do |count|
      determine_destiny(true, count)
    end
    assert_equal(expected, actual)
  end
  def test_destiny_empty
    data = [
      [0, false],
      [1, false],
      [2, false],
      [3, true],
      [4, false],
      [5, false],
      [6, false]
    ]
    input, expected = data.transpose
    actual = input.map do |count|
      determine_destiny(false, count)
    end
    assert_equal(expected, actual)
  end
end
require 'test/unit/ui/console/testrunner'
Test::Unit::UI::Console::TestRunner.run(TestGameOfLife)
Loaded suite TestGameOfLife
Started
FF
Finished in 0.029156 seconds.

  1) Failure:
test_destiny_empty(TestGameOfLife) [a.rb:36]:
<[false, false, false, true, false, false, false]> expected but was
<[nil, nil, nil, nil, nil, nil, nil]>.

  2) Failure:
test_destiny_populated(TestGameOfLife) [a.rb:20]:
<[false, false, true, true, false, false, false]> expected but was
<[nil, nil, nil, nil, nil, nil, nil]>.

2 tests, 2 assertions, 2 failures, 0 errors

Lad os prøve at implementere determine_destiny.

def determine_destiny(alive, count)
  unless alive
    return (count == 3)
  end
  (count == 2) or (count == 3)
end
Loaded suite TestGameOfLife
Started
..
Finished in 0.00158 seconds.

2 tests, 2 assertions, 0 failures, 0 errors

Så langt så godt, vi har nu behov for en rutine som kan tælle antal tilstødende celler som er befolkede. Lad os lave noget testkode først.

def count_neighbours(cells, x, y)
  # not yet implemented
end
def test_count_neighbours0
  cells = [
    [0, 0, 0],
    [0, 1, 0],
    [0, 0, 0]
  ]
  n = count_neighbours(cells, 1, 1)
  assert_equal(0, n)
end
def test_count_neighbours1
  cells = [
    [1, 0, 0],
    [0, 1, 1],
    [0, 1, 0]
  ]
  n = count_neighbours(cells, 1, 1)
  assert_equal(3, n)
end
def test_count_neighbours2
  cells = [
    [0, 1, 1],
    [1, 1, 1],
    [1, 1, 0]
  ]
  n = count_neighbours(cells, 1, 1)
  assert_equal(6, n)
end
Loaded suite TestGameOfLife
Started
FFF..
Finished in 0.027788 seconds.

  1) Failure:
test_count_neighbours0(TestGameOfLife) [a.rb:50]:
<0> expected but was
<nil>.

  2) Failure:
test_count_neighbours1(TestGameOfLife) [a.rb:59]:
<3> expected but was
<nil>.

  3) Failure:
test_count_neighbours2(TestGameOfLife) [a.rb:68]:
<6> expected but was
<nil>.

5 tests, 5 assertions, 3 failures, 0 errors

Lad os implementere metoden count_neighbours.

def count_neighbours(cells, x, y)
  n = 0
  n += cells[y-1][x-1]
  n += cells[y-1][x]
  n += cells[y-1][x+1]
  n += cells[y][x-1]
  n += cells[y][x+1]
  n += cells[y+1][x-1]
  n += cells[y+1][x]
  n += cells[y+1][x+1]
  n
end
Loaded suite TestGameOfLife
Started
.....
Finished in 0.002225 seconds.

5 tests, 5 assertions, 0 failures, 0 errors

Lad os lave noget testkode for gennemlevning af en livscyklus.

def lifecycle(cells)
  # not yet implemented
end
def test_lifecycle1
  cells = [
    [0, 1, 1],
    [1, 1, 1],
    [1, 1, 0]
  ]
  expected_cells = [
    [1, 0, 1],
    [0, 0, 0],
    [1, 0, 1]
  ]     
  actual = lifecycle(cells)
  assert_equal(expected_cells, actual)
end
Loaded suite TestGameOfLife
Started
.....F
Finished in 0.024648 seconds.

  1) Failure:
test_lifecycle1(TestGameOfLife) [a.rb:95]:
<[[1, 0, 1], [0, 0, 0], [1, 0, 1]]> expected but was
<nil>.

6 tests, 6 assertions, 1 failures, 0 errors

Implementering...

def lifecycle(cells)
  next_cells = cells.map do |row|
    row.map do |cell|
      9
    end
  end
  next_cells
end
Loaded suite TestGameOfLife
Started
.....F
Finished in 0.027937 seconds.

  1) Failure:
test_lifecycle1(TestGameOfLife) [a.rb:100]:
<[[1, 0, 1], [0, 0, 0], [1, 0, 1]]> expected but was
<[[9, 9, 9], [9, 9, 9], [9, 9, 9]]>.

6 tests, 6 assertions, 1 failures, 0 errors

Vi har fat i noget af det rigtige.

def lifecycle(cells)
  y = 0
  next_cells = cells.map do |row|
    x = 0
    next_row = row.map do |cell|
      n = count_neighbours(cells, x, y)
      x += 1
      n
    end
    y += 1
    next_row
  end
  next_cells
end
Loaded suite TestGameOfLife
Started
.....E
Finished in 0.005263 seconds.

  1) Error:
test_lifecycle1(TestGameOfLife):
TypeError: nil can't be coerced into Fixnum
    a.rb:45:in `+'
    a.rb:45:in `count_neighbours'
    a.rb:85:in `lifecycle'
    a.rb:84:in `map'
    a.rb:84:in `lifecycle'
    a.rb:82:in `map'
    a.rb:82:in `lifecycle'
    a.rb:105:in `test_lifecycle1'

6 tests, 5 assertions, 0 failures, 1 errors

Boom, vi har et problem med count_neighbours. Hvad er det lige som er problemet her? Lad os lave nogle testcases som exerciser count_neighbours, sådan at samme problem fremprovokeres.

def test_count_neighbours3
  cells = [
    [0, 1, 1],
    [1, 1, 1],
    [1, 1, 0]
  ]
  n = count_neighbours(cells, 0, 0)
  assert_equal(3, n)
end
def test_count_neighbours4
  cells = [
    [0, 1, 1],
    [1, 1, 1],
    [1, 1, 0]
  ]
  n = count_neighbours(cells, 2, 2)
  assert_equal(3, n)
end
Loaded suite TestGameOfLife
Started
...FE..E
Finished in 0.02368 seconds.

  1) Failure:
test_count_neighbours3(TestGameOfLife) [a.rb:87]:
<3> expected but was
<7>.

  2) Error:
test_count_neighbours4(TestGameOfLife):
TypeError: nil can't be coerced into Fixnum
    a.rb:45:in `+'
    a.rb:45:in `count_neighbours'
    a.rb:95:in `test_count_neighbours4'

  3) Error:
test_lifecycle1(TestGameOfLife):
TypeError: nil can't be coerced into Fixnum
    a.rb:45:in `+'
    a.rb:45:in `count_neighbours'
    a.rb:103:in `lifecycle'
    a.rb:102:in `map'
    a.rb:102:in `lifecycle'
    a.rb:100:in `map'
    a.rb:100:in `lifecycle'
    a.rb:123:in `test_lifecycle1'

8 tests, 6 assertions, 1 failures, 2 errors

Første problem skyldes at opslag i Array med negativt index, hvorved vi så tilgå Array'et baglæns. Andet problem skyldes at vi prøvet at tilgå elementer udenfor Arrayet, hvorved der så returneres nil. Altså har vi "glemt" tage højde for grænsesituationerne. Det må vi se om vi kan løse. Lad os foretage følgende omskrivning.

def get(cells, x, y)
  return 0 if x < 0 or y < 0
  return 0 if y >= cells.size
  row = cells[y]
  return 0 if x >= row.size
  row[x]
end
def count_neighbours(cells, x, y)
  n = 0
  n += get(cells, y-1, x-1)
  n += get(cells, y-1, x)
  n += get(cells, y-1, x+1)
  n += get(cells, y, x-1)
  n += get(cells, y, x+1)
  n += get(cells, y+1, x-1)
  n += get(cells, y+1, x)
  n += get(cells, y+1, x+1)
  n
end
Loaded suite TestGameOfLife
Started
.......F
Finished in 0.027889 seconds.

  1) Failure:
test_lifecycle1(TestGameOfLife) [a.rb:131]:
<[[1, 0, 1], [0, 0, 0], [1, 0, 1]]> expected but was
<[[3, 4, 3], [4, 6, 4], [3, 4, 3]]>.

8 tests, 8 assertions, 1 failures, 0 errors

Meget bedre, nu kan vi forsætte med implementeringen af lifecycle. Af test uddata kan fornemme at vi har glemt kalde determine_destiny. Lad os kalde den.

def lifecycle(cells)
  y = 0
  next_cells = cells.map do |row|
    x = 0
    next_row = row.map do |cell|
      n = count_neighbours(cells, x, y)
      x += 1
      determine_destiny((cell != 0), n) ? 1 : 0
    end
    y += 1
    next_row
  end
  next_cells
end
Loaded suite TestGameOfLife
Started
........
Finished in 0.004913 seconds.

8 tests, 8 assertions, 0 failures, 0 errors

Vi har nu næsten et funktionsdygtigt game of life program.