TDD Beyond Basics : Tennis Kata

Problem Statement


Write a program to output correct score for a tennis game. Here is the summary of tennis scores:

  1. A game is won by the first player to if the player has won at least four points in total and at least two points more than the opponent.
  2. The running score of each game is describe as 'Love', 'Fifteen', 'Thirty' and 'Forty' for points from zero to three.
  3. If at least three points is scored by both players and the scores are equal, the score is 'Deuce'.
  4. If at least three points is scored by both players and a player has one more point than his opponent, the score of the game is 'Advantage' for the player with more points.

Sets and matches are out of scope. We only need to report the score for the current game.

Difficulty Level


Medium

Steps


Step 1


Create tennis_game_spec.rb with the following code:

class TennisGame

  def player_one_score

  end

  def player_two_score

  end
end

describe TennisGame do
  it 'initial scores for both players must be Love' do
    game = TennisGame.new

    player_one_score = game.player_one_score
    player_two_score = game.player_two_score

    expect(player_one_score).to eq('Love')
    expect(player_two_score).to eq('Love')
  end
end

This test fails for the right reason with the error message 'Expected Love got nil'.

Step 2

Make the test pass by returning hard-coded value as follows:

class TennisGame  
  def player_one_score
    'Love'
  end

  def player_two_score
    'Love'
  end
end

Step 3

Add the second spec as follows:

it 'returns 15 for player one when player one wins the point' do
  game = TennisGame.new
  game.wins_point(1)

  player_one_score = game.player_one_score
  player_two_score = game.player_two_score

  expect(player_one_score).to eq('Fifteen')
  expect(player_two_score).to eq('Love')
end

This test fails. The line:

game.wins_point(1)

does not make any sense.

Step 4

Let's rewrite our spec as follows:

it 'returns 15 for player one when player one wins the point' do
  player_one = Player.new('McEnroe')
  player_two = Player.new('Borg')

  game = TennisGame.new(player_one, player_two)
  player_one.wins_point

  player_one_score = game.player_one_score
  player_two_score = game.player_two_score

  expect(player_one_score).to eq('Fifteen')
  expect(player_two_score).to eq('Love')
end

This breaks the first test also. Let's comment out the second test and focus on getting the first spec to pass.

Step 5

Comment out the second spec and change the first spec as follows:

it 'initial scores for both players must be Love' do
  player_one = Player.new('McEnroe')
  player_two = Player.new('Borg')

  game = TennisGame.new(player_one, player_two)

  player_one_score = game.player_one_score
  player_two_score = game.player_two_score

  expect(player_one_score).to eq('Love')
  expect(player_two_score).to eq('Love')
end

xit 'returns 15 for player one when player one wins the point' do
  # same code as before
end

The minimal implementation to make this test pass is as follows:

class Player
  attr_reader :points

  def initialize(name)
    @name = name
    @points = 0
  end  

  def wins_point
    @points += 1
  end
end

class TennisGame  
  def initialize(player_one, player_two)
    @player_one = player_one
    @player_two = player_two  
  end

  def wins_point(player)
    @player_one.wins_point
  end

  def player_one_score
    if @player_one.points == 0
      'Love'
    end
  end

  def player_two_score
    if @player_two.points == 0
      'Love'
    end
  end
end

Step 6

Uncomment the second spec and run it. It now fails for the right reason with the error message 'expected Fifteen, got nil'. Change the implementation for TennisGame as follows:

class TennisGame  
  def initialize(player_one, player_two)
    @player_one = player_one
    @player_two = player_two  
  end

  def wins_point(player)
    @player_one.wins_point
  end

  def player_one_score
    @player_one.score
  end

  def player_two_score
    @player_two.score
  end
end

The test passes.

Step 7

Let's tackle the duplication of player score code in TennisGame class.

class Player
  attr_reader :points

  def initialize(name)
    @name = name
    @points = 0
  end  

  def wins_point
    @points += 1
  end

  def score
    if @points == 0
      'Love'
    elsif @points == 1
      'Fifteen'
    end
  end
end

class TennisGame  
  def initialize(player_one, player_two)
    @player_one = player_one
    @player_two = player_two  
  end

  def wins_point(player)
    @player_one.wins_point
  end

  def player_one_score
    @player_one.score
  end

  def player_two_score
    @player_two.score
  end
end

The TennisGame class delegates the score computation to the player class. The tests still pass.

Step 8

The name has been removed from the player class because it is not relevant in scoring a game. All tests pass after the refactoring. Add the third test as follows:

it 'returns 30 for player one when player one wins two points' do
  player_one = Player.new
  player_two = Player.new

  game = TennisGame.new(player_one, player_two)
  player_one.wins_point
  player_one.wins_point

  player_one_score = game.player_one_score
  player_two_score = game.player_two_score

  expect(player_one_score).to eq('Thirty')
  expect(player_two_score).to eq('Love')    
end

It fails for the right reason with the error message 'expected: Thirty, got nil'.

Step 9

To make the test pass, change the score method as follows:

def score
  if @points == 0
    'Love'
  elsif @points == 1
    'Fifteen'
  elsif @points == 2
    'Thirty'
  end
end

Step 10

Add the fourth test as follows:

it 'returns 40 for player one when player one wins three points' do
  player_one = Player.new
  player_two = Player.new

  game = TennisGame.new(player_one, player_two)
  player_one.wins_point
  player_one.wins_point
  player_one.wins_point

  player_one_score = game.player_one_score
  player_two_score = game.player_two_score

  expect(player_one_score).to eq('Forty')
  expect(player_two_score).to eq('Love')    
end

It fails for the right reason with the error message 'expected Forty, got nil'.

Step 11

You can make the test pass by changing the score method as follows:

def score
  if @points == 0
    'Love'
  elsif @points == 1
    'Fifteen'
  elsif @points == 2
    'Thirty'
  elsif @points == 3
    'Forty'
  end
end

Step 12

Let's add a test to check when both players have won one point, the scores are 15-all.

it 'returns 15 for both players when both have won one point' do
  player_one = Player.new
  player_two = Player.new

  game = TennisGame.new(player_one, player_two)
  player_one.wins_point
  player_two.wins_point

  player_one_score = game.player_one_score
  player_two_score = game.player_two_score

  expect(player_one_score).to eq('Fifteen')
  expect(player_two_score).to eq('Fifteen')        
end

This test passes.

Step 13

Let's consider the duece case:

it 'returns duece both players have won three points' do
  player_one = Player.new
  player_two = Player.new

  game = TennisGame.new(player_one, player_two)
  player_one.wins_point
  player_one.wins_point
  player_one.wins_point

  player_two.wins_point
  player_two.wins_point
  player_two.wins_point

  expect(game.score).to eq('Duece')
end

This fails with no score method for TennisGame error.

Step 14

Add the score method to the TennisGame class as follows:

def score
  if @player_one.points == 3 and @player_two.points == 3
    'Duece'
  end
end

The test passes.

Step 15

Let's tackle the 'Advantage' case now. This is the fourth rule in the problem statement:

If at least three points is scored by both players and a player has one more point than his opponent, the score of the game is 'Advantage' for the player with more points.

Add the new test for this case with the code as follows:

it 'returns Advantage for the player with one more point after 3 points by both' do

  player_one = Player.new
  player_two = Player.new

  game = TennisGame.new(player_one, player_two)
  player_one.wins_point
  player_one.wins_point
  player_one.wins_point

  player_two.wins_point
  player_two.wins_point
  player_two.wins_point

  player_one.wins_point

  expect(game.score).to eq("Advantage #{player_one.name}")
end 

This fails.

Step 16

Change the test as follows:

it 'returns Advantage for the player with one more point after 3 points by both' do

  player_one = Player.new('McEnroe')
  player_two = Player.new('Borg')

  game = TennisGame.new(player_one, player_two)
  player_one.wins_point
  player_one.wins_point
  player_one.wins_point

  player_two.wins_point
  player_two.wins_point
  player_two.wins_point

  player_one.wins_point

  expect(game.score).to eq("Advantage #{player_one.name}")
end 

Step 17

Change the player class as follows:

class Player
  attr_reader :points, :name

  def initialize(name='')
    @name = name
    @points = 0
  end  

  def wins_point
    @points += 1
  end

  def score
    if @points == 0
      'Love'
    elsif @points == 1
      'Fifteen'
    elsif @points == 2
      'Thirty'
    elsif @points == 3
      'Forty'
    end
  end
end

The test now fails for the right reason.

Step 18

Change the score method of the TennisGame as follows:

def score
  if @player_one.points == 3 and @player_two.points == 3
    'Duece'
  elsif (@player_one.points == @player_two.points + 1) and (@player_two.points >=3)
    "Advantage #{@player_one.name}"
  end
end

All tests now pass.

Step 19

Let's tackle the winning game with the following rule:

A game is won by the first player to if the player has won at least four points in total and at least two points more than the opponent.

Add the new test for the winning game as follows:

it "Game 'winner name' when a player wins 4 points with no points by the opponent" do

  player_one = Player.new('McEnroe')
  player_two = Player.new('Borg')

  game = TennisGame.new(player_one, player_two)
  player_one.wins_point
  player_one.wins_point
  player_one.wins_point
  player_one.wins_point

  expect(game.score).to eq("Game #{player_one.name}")
end 

This fails for the right reason with the error message 'expected: Game McEnroe got nil'.

Step 20

Change the score method of the TennisGame class as follows:

def score
  if @player_one.points == 3 and @player_two.points == 3
    'Duece'
  elsif (@player_one.points == @player_two.points + 1) and (@player_two.points >=3)
    "Advantage #{@player_one.name}"
  elsif (@player_one.points >= 4) and (@player_one.points >= (@player_two.points + 2))
    "Game #{@player_one.name}"
  end
end

All tests now pass.

Discussion


In this kata we discovered a missing collaborator when we found that the allocation of responsibilities were not in the right class. We found the missing abstraction Player when we eliminated the duplication is scoring logic. We also found that the scores must provide player's name in order to say who won the game. The attributes and behavior associated with a player is encapsulated in it's own class.

TennisGame class can be renamed to TennisUmpire class to better represent the abstraction. Because it knows which player won the point and has the knowledge to decide who wins the game based on the points scored by the players.

Some of the tests has two assertions. In a real tennis game, the score is determined based on who is serving. Since we have not taken that into account, we ended up with two assertions. We will keep the two assertions in the tests.

Exercise


Write tests for the missing cases and make them pass.


Related Articles


Ace the Technical Interview

  • Easily find the gaps in your knowledge
  • Get customized lessons based on where you are
  • Take consistent action everyday
  • Builtin accountability to keep you on track
  • You will solve bigger problems over time
  • Get the job of your dreams

Take the 30 Day Coding Skills Challenge

Gain confidence to attend the interview

No spam ever. Unsubscribe anytime.