Natcha Luang - Aroonchai

Trust me I'm Petdo

Bangkok, Thailand

[TDD-Kata] ตอนที่ 2 Bowling game

กลับมาอีกครั้งกับบทความชุด TDD-Kata ในตอนที่ 2 ซึ่งห่างจากตอนแรกไปนานพอสมควร สำหรับโจทย์ในคราวนี้มีชื่อว่า "Bowling Game Kata" สามารถดาวน์โหลดโจทย์แบบเต็ม ๆ ทั้งหมดได้จาก ที่นี่

และในบทความนี้จะพิเศษสักหน่อยตรงที่ผมจะเขียนชุดเทสด้วย BDD แทนที่จะเป็น TDD อย่างที่เคยทำกันมา ซึ่งเจ้า BDD จะเหมือนกับ TDD เพียงแต่เพิ่มในส่วนของการอธิบายเทสเข้ามาด้วย

ลองมาดูตัวอย่างไปพร้อม ๆ กันดีกว่าครับ

ภาพรวม

ก่อนที่จะเริ่มในขั้นตอนถัดไปมีเรื่องที่ต้องบอกกันสักเล็กน้อยคือ

  • ผมจะเขียนเทสด้วยภาษา Go
  • ภาษา Go จะมีแพคเกจสำหรับเขียน unit test มาด้วยอยู่แล้วชื่อว่า testing
  • แต่ผมจะเขียนเทสด้วยหลักการของ BDD จึงต้องใช้ framework เข้ามาช่วยก็คือ Ginkgo

เป็นอันตกลงตามด้านบนน่ะครับ เรามาลองดูโจทย์กันดีกว่าว่ามีเงื่อนไขอย่างไรบ้าง จากโจทย์จะเป็นการคำนวณคะแนนจากการเล่นเกมโบวลิ่ง โดยที่ตารางคะแนนจะมีอยู่ 10 ช่องตามภาพด้านล่างนี้

ตารางคะแนน

ในแต่ละช่องคะแนนผู้เล่นจะมีโอกาสสองครั้งในการล้มพินโบวลิ่งทั้งหมด (10 พิน) และคะแนนที่ปรากฎในช่องคือจำนวนพินที่ทำให้ล้มรวมกับคะแนนโบนัสจากการทำสไตร์ (ล้มทั้ง 10 พินในครั้งแรกครั้งเดียว) หรือจากการทำสแปร์ (ล้มพินที่เหลือทั้งหมดในครั้งที่สอง)

วิธีการนับคะแนนโบวลิ่งจาก wikiHow

โจทย์

ให้สร้างคลาสชื่อว่า Game ที่มีสอง method คือ roll(pins: int) และ score() : int โดยที่

  • ฟังก์ชัน roll จะถูกเรียกทุกครั้งที่ผู้เล่นโยนบอลและส่งค่าพารามิเตอร์เป็นจำนวนพินที่ล้มได้ในการโยนครั้งนั้น
  • ฟังก์ชัน score จะถูกเรียกเมื่อจบเกมและทำการคำนวนคะแนนของผู้เล่น

โดยเนื้อหาส่วนที่เป็นโจทย์จะสิ้นสุดลงตั้งแต่สไลด์ที่ 3 ตั้งแต่สไลด์ที่ 4 จะเป็นเฉลยแนะนำให้ทดลองลงมือทำด้วยตัวเองก่อนดีกว่า

ลงมือทำ

โจทย์กำหนดให้เราสร้างคลาส Game ซึ่งในภาษา Go ไม่มีสิ่งที่เรียกว่าคลาสแต่มี struct ก็ใช้แทนกันได้ :)

เนื่องจากเราใช้งาน Ginkgo เริ่มต้นด้วยการสร้างไฟล์ bootstrap ด้วยคำสั่ง

$ cd /path/to/project
$ ginkgo bootstrap

และจากนั่นสั่ง ganerate ไฟล์สำหรับเขียนเทสด้วยคำสั่ง

$ ginkgo generate game

จะได้ไฟล์ game_test.go รวมทั้งหมดแล้วจะได้ไฟล์ประมาณนี้

├── game.go
├── game_test.go
└── tdd_kata_bowling_game_suite_test.go

จากนั้นเราจะเขียนเทสที่ไฟล์ game_test.go เป็นคำสั่งเทสแบบง่าย ๆ คือสมมติว่าผู้เล่นโยนทั้งหมด 20 ครั้ง และไม่มีสักครั้งที่จะล้มพินได้เลย ดังนั้นคะแนนต้องออกมาเป็น 0

game_test.go

...

var _ = Describe("Game", func() {
  var (
    game *Game
  )

  BeforeEach(func() {
    game = &Game{}
  })

  Describe("The Bowling Game", func() {
    Context("roll 0 pin all frame", func() {
      It("should return 0 score", func() {
        for i := 0; i < 20; i++ {
          game.Roll(0)
        }

        Expect(game.Score()).To(Equal(0))
      })
    })
  })
})

ต่อจากนั้นก็มาเขียนโปรแกรมที่ไฟล์ game.go

game.go

...

type Game struct {
}

func (g *Game) Roll(pins int) {
}

func (g *Game) Score() int {
  return 0
}

จากนั้นรันเทสด้วยคำสั่ง go test หรือจะใช้ ginkgo ก็ได้ จะได้ผลลัพธ์ออกมาแบบนี้

Running Suite: TddKataBowlingGame Suite
=======================================
Random Seed: 1454037766
Will run 1 of 1 specs

•
Ran 1 of 1 Specs in 0.000 seconds
SUCCESS! -- 1 Passed | 0 Failed | 0 Pending | 0 Skipped PASS

Ginkgo ran 1 suite in 1.602385514s
Test Suite Passed

ผ่านแน่นอนจากนั้นเราจะมาเขียนเทสเคสถัดไป ให้เขียนอยู่ภายใต้ Describe ของเดิมเลยครับเพียงแต่เพิ่ม Context ต่อด้านล่างเท่านั้น

เมื่อผู้เล่นทำการล้มพินได้แค่ 1 ฟินในการโยนแต่ละครั้ง รวมแล้วได้สองพินในแต่ละเฟรมคะแนนที่ได้ต้องเป็น 20

game_test.go

...
Describe("The Bowling Game", func() {
  ...

  Context("roll 1 pin all frame", func() {
    It("should return 20 score", func() {
      for i := 0; i < 20; i++ {
        game.Roll(1)
      }

      Expect(game.Score()).To(Equal(20))
    })
  })
})

ยังไม่ต้องแก้โปรแกรมใด ๆ รันเทสจะต้องได้ false แน่นอนแบบนี้

Running Suite: TddKataBowlingGame Suite
=======================================
Random Seed: 1454038171
Will run 2 of 2 specs

•
------------------------------
• Failure [0.004 seconds]
Game
/path/to/project/game_test.go:40
  The Bowling Game
  /path/to/project/game_test.go:39
    roll 1 pin
    /path/to/project/game_test.go:38
      should return 20 score [It]
      /path/to/project/game_test.go:37

      Expected
          <int>: 0
      to equal
          <int>: 20

      /path/to/project/game_test.go:36
------------------------------


Summarizing 1 Failure:

[Fail] Game The Bowling Game roll 1 pin [It] should return 20 score
/path/to/project/game_test.go:36

Ran 2 of 2 Specs in 0.004 seconds
FAIL! -- 1 Passed | 1 Failed | 0 Pending | 0 Skipped --- FAIL: TestTddKataBowlingGame (0.00s)
FAIL

Ginkgo ran 1 suite in 1.620633136s
Test Suite Failed

จากนั้นเราจะมาลงมือแก้โปรแกรมเพื่อให้รันเทสได้ผ่านฉลุยกัน

game.go

...

type Game struct {
  totalScore int
}

func (g *Game) Roll(pins int) {
  g.totalScore += pins
}

func (g *Game) Score() int {
  return g.totalScore
}

จัดการเพิ่มฟิลด์ totalScore เข้าไปจากนั้นที่ฟังก์ชัน Roll ให้ทำการบวกจำนวนของพินที่ล้มได้เข้าไปเป็นคะแนน ก่อนจะรีเทิร์นกลับที่ฟังก์ชัน Score ครับ

จัดการรันเทสอีกครั้งผ่านแน่นอน มาต่อกันที่การคำนวนคะแนนจากแต้มพิเศษกันเข้าไป เริ่มที่การคิดคะแนนจากการทำสแปร์ เมื่อผู้เล่นทำสแปร์ได้คะแนนที่ผู้เล่นจะได้รับคือจำนวนพินที่ล้มได้ในเฟรมนั้น 10 รวมกับจำนวนพินที่ล้มได้ในการโยนครั้งถัดไป สมมติว่าครั้งถัดไปผู้เล่นสามารถโยนพินล้มได้ 2 พินคะแนนที่จะปรากฎในเฟรมแรกคือ 12 และเมื่อนำมารวมกับคะแนนในเฟรมที่สองคือ 2 จะต้องได้คะแนนรวมเท่ากับ 14

จัดการเขียนเทสกันก่อน

game_test.go

...
Describe("The Bowling Game", func() {
  ...

  Context("spare at 1st frame and 2 score at next roll", func() {
    It("should return 14 score", func() {
      game.Roll(7)
      game.Roll(3) // spare
      game.Roll(2)
      for i := 0; i < 17; i++ {
        game.Roll(0)
      }

      Expect(game.Score()).To(Equal(14))
    })
  })
})

ทำการรันเทสก่อน เมื่อพบว่า false จากนั้นเราก็จะมาลงมือแก้ไขโปรแกรมกัน ในการแก้ไขโปรแกรมครั้งนี้มีสิ่งที่เปลี่ยนแปลงคือผมเพิ่มฟิลด์ชื่อว่า Frames ซึ่งเป็น slice ของ struct สำหรับเก็บคะแนนและสถานะของแต่ละเฟรมเข้ามาใน Game แบบนี้

game.go

...

type Game struct {
  i      int
  Frames []Frame
}

func (g *Game) Roll(pins int) {
  f := &g.Frames[g.i]
  f.Roll(pins)

  if f.IsFinished() {
    if g.i > 0 {
      f.SetPrevious(&g.Frames[g.i-1])
    }
    if g.i < len(g.Frames)-1 {
      f.SetNext(&g.Frames[g.i+1])
    }

    g.i++
  }
}

func (g *Game) Score() int {
  var totalScore int
  for _, frame := range g.Frames {
    totalScore += frame.Score()
  }

  return totalScore
}

นอกจากนี้ยังแก้ไขฟังก์ชัน Roll ให้ทำการชี้โหนดในแต่ละเฟรมเมื่อผู้เล่นโยนครบสองครั้ง จากนั้นที่ฟังก์ชัน Score เปลี่ยนมาคำนวนคะแนนจากเฟรมแทนการคำนวนที่ฟังก์ชัน Roll สร้างไฟล์ frame.go สำหรับทำงานกับเฟรมโดยเฉพาะ

frame.go

...

type Frame struct {
    i          []int
    totalScore int
    IsSpare    bool
    IsStrike   bool

    previous *Frame
    next     *Frame
}

ลักษณะของเฟรมจะคล้าย Doubly linked list คือมีส่วนของ pointer สำหรับชี้เข้าหาโหนดก่อนหน้าและโหนดถัดไป (previous, next) โดยโหนดแรกสุดจะไม่มี previous และโหนดสุดท้ายก็จะไม่มี next ตามลำดับ

นอกจากนั้นในเฟรมก็จะเก็บค่าสถานะว่าเป็นสแปร์หรือสไตร์ด้วยและแยกเก็บคะแนนในการโยนแต่ละครั้งลงที่ฟิลด์ i ซึ่งเป็น slice int ต่อจากนั้นผมเพิ่มฟังก์ชัน Roll สำหรับบันทึกคะแนนในการโยนแต่ละครั้ง แถมฟังก์ชันสำหรับคำนวนคะแนน Score, FirstScore และ SecondScore

...

func (f *Frame) Roll(pins int) {
  f.i = append(f.i, pins)
  f.totalScore += pins

  // spare
  if len(f.i) == 2 && f.totalScore == 10 {
    f.IsSpare = true
  }
}

func (f *Frame) Score() int {
  if f.IsSpare {
    // 10 + next roll score
    if f.next != nil {
      return f.totalScore + f.next.FirstScore()
    }
  }

  return f.totalScore
}

func (f *Frame) FirstScore() int {
  return f.i[0]
}

func (f *Frame) SecondScore() int {
  if len(f.i) == 2 {
    return f.i[1]
  }
  return 0
}

เรียบร้อยแล้วตอนนี้โค๊ดยังดูดีอยู่ มาต่อที่เงื่อนไขถัดไปคือการทำสไตร์ เมื่อผู้เล่นทำสไตร์ได้คะแนนที่ผู้เล่นจะได้รับในเฟรมนั้นคือจำนวนพินที่ล้มได้ 10 รวมกับจำนวนพินที่ล้มได้ในการโยนครั้งถัดไปอีกสองครั้ง สมมติว่าครั้งถัดไปผู้เล่นโยนพินล้มได้ 2 พินและ 4 พินตามลำดับคะแนนที่ปรากฎในเฟรมแรกคือ 16 และคะแนนที่ปรากฎในเฟรมที่สองคือ 22

จัดการเขียนเทสกันก่อน

game_test.go

...
Describe("The Bowling Game", func() {
  ...

  Context("strike at 1st frame and 2, 4 score at next roll", func() {
    It("should return 22 score", func() {
      game.Roll(10) // strike
      game.Roll(2)
      game.Roll(4)
      for i := 0; i < 16; i++ {
        game.Roll(0)
      }

      Expect(game.Score()).To(Equal(22))
    })
  })
})

ตรงนี้รอบการโยนจะมีแค่ 19 รอบเพราะในเฟรมแรกเราทำสไตร์และเฟรมต่อ ๆ มาก็โยนสองครั้งตามปกติ รันเทสต้องไม่ผ่านแน่นอนเพราะยังไม่ได้ใส่ฟังก์ชันจัดการกับสไตร์ ตรงนี้เราแก้ไม่ยากเพียงใส่เงื่อนไขสำหรับสไตร์เข้าไปคล้ายกับตอนทำเงื่อนไขสแปร์ครับ

frame.go

...

func (f *Frame) Roll(pins int) {
  f.i = append(f.i, pins)
  f.totalScore += pins

  // spare
  if len(f.i) == 2 && f.totalScore == 10 {
    f.IsSpare = true
  }

  // strike
  if len(f.i) == 1 && f.totalScore == 10 {
    f.IsStrike = true
  }
}

func (f *Frame) Score() int {
  if f.IsSpare {
    // 10 + next roll score
    if f.next != nil {
      return f.totalScore + f.next.FirstScore()
    }
  }

  if f.IsStrike {
    // 10 + next 2 roll score
    if f.next != nil {
      return f.totalScore + f.next.FirstScore() + f.next.SecondScore()
    }
  }

  return f.totalScore
}

สุดท้ายในเฟรมที่สิบจะพิเศษอยู่อย่างนึงคือถ้าเกิดผู้เล่นทำสแปร์หรือสไตร์ได้จะได้รับสิทธ์ให้โยนใน 1 และ 2 ครั้งตามลำดับ

มาเขียนเทสกันก่อน

game_test.go

...

Describe("The Bowling Game", func() {
  ...

  Context("spare at 10th frame and 4 score at next roll", func() {
    It("should return 14 score", func() {
      for i := 0; i < 19; i++ {
        game.Roll(0)
      }
      game.Roll(4)
      game.Roll(6) // spare
      game.Roll(4)

      Expect(game.Score()).To(Equal(14))
    })
  })

  Context("strike at 10th frame and make 4, 5 score at next roll", func() {
    It("should return 19 score", func() {
      for i := 0; i < 18; i++ {
        game.Roll(0)
      }
      game.Roll(10) // strike
      game.Roll(4)
      game.Roll(5)

      Expect(game.Score()).To(Equal(19))
    })
  })
})

รันเทสจะเกิด false เนื่องจากระบบจะคำนวนโบนัสคะแนนจากการโยนในเฟรมถัดไป แต่ในเฟรมที่ 10 ที่เกิดโบนัสและไม่ได้มีเฟรมมาต่อจะทำให้ค่าผิดพลาดไป ซึ่งจริง ๆ โปรแกรมต้องเอาค่าโบนัสจากการโยนครั้งที่สามแทน

ผมเลยแก้ไขที่ฟังก์ชัน Score ในขั้นตอนการคำนวนคะแนนโบนัสให้เช็คว่าเฟรมที่กำลังคำนวนอยู่นั้นเป็นเฟรมที่เท่าไร โดยสร้างฟิลด์ชื่อว่า ID และให้ Game ส่งเลขที่ของเฟรมเข้ามาเป็น ID นั่นเอง

frame.go

...

type Frame struct {
  i          []int
  totalScore int
  ID         int
  IsSpare    bool
  IsStrike   bool

  previous *Frame
  next     *Frame
}

func (f *Frame) Score() int {
  // spare
  if f.IsSpare && f.next != nil && f.ID != 10 {
    return f.totalScore + f.next.FirstScore()
  }

  // strike
  if f.IsStrike && f.next != nil && f.ID != 10 {
    if f.next.IsStrike && f.ID != 9 {
      return f.totalScore + f.next.FirstScore() + f.next.next.FirstScore()
    }
    return f.totalScore + f.next.FirstScore() + f.next.SecondScore()
  }

  return f.totalScore
}

func (f *Frame) SecondScore() int {
  if len(f.i) >= 2 {
    return f.i[1]
  }
  return 0
}

func (f *Frame) ThirdScore() int {
  if len(f.i) >= 3 {
    return f.i[2]
  }
  return 0
}

นอกจากนี้ยังแก้ไขที่ฟังก์ชัน SecondScore และเพิ่มฟังก์ชัน ThirdScore เข้ามาอีกด้วย ส่วนที่ฟังก์ชัน Roll ใน Game ก็เพิ่มขั้นตอน assign ID เข้ามาแบบนี้

game.go

...

func (g *Game) Roll(pins int) {
  ...

  f.ID = g.i + 1

  ...
}

เมื่อทดลองรันเทสอีกครั้งก็จะผ่านทั้งหมดแล้วครับ


ในบทความนี้ถ้าใครที่ตามไม่ทันสามารถดูตัวอย่างได้จาก repository บน GitHub ที่ ลิงค์นี้ ได้ครับ


อ้างอิง

Comments