I need some help with some TDD concepts. Say I have the following code
def execute(command)
case command
when "c"
create_new_character
when "i"
display_inventory
end
end
def create_new_character
# do stuff to create new character
end
def display_inventory
# do stuff to display inventory
end
Now I'm not sure what to write my unit tests for. If I write unit tests for the execute
method doesn't that pretty much cover my tests for create_new_character
and display_inventory
? Or am I testing the wrong stuff at that point? Should my test for the execute
method only test that execution is passed off to the correct methods and stop there? Then should I write more unit tests that specifically test create_new_character
and display_inventory
?
I'm presuming since you mention TDD the code in question does not actually exist. If it does then you aren't doing true TDD but TAD (Test-After Development), which naturally leads to questions such as this. In TDD we start with the test. It appears that you are building some type of menu or command system, so I'll use that as an example.
describe GameMenu do
it "Allows you to navigate to character creation" do
# Assuming character creation would require capturing additional
# information it violates SRP (Single Responsibility Principle)
# and belongs in a separate class so we'll mock it out.
character_creation = mock("character creation")
character_creation.should_receive(:execute)
# Using constructor injection to tell the code about the mock
menu = GameMenu.new(character_creation)
menu.execute("c")
end
end
This test would lead to some code similar to the following (remember, just enough code to make the test pass, no more)
class GameMenu
def initialize(character_creation_command)
@character_creation_command = character_creation_command
end
def execute(command)
@character_creation_command.execute
end
end
Now we'll add the next test.
it "Allows you to display character inventory" do
inventory_command = mock("inventory")
inventory_command.should_receive(:execute)
menu = GameMenu.new(nil, inventory_command)
menu.execute("i")
end
Running this test will lead us to an implementation such as:
class GameMenu
def initialize(character_creation_command, inventory_command)
@inventory_command = inventory_command
end
def execute(command)
if command == "i"
@inventory_command.execute
else
@character_creation_command.execute
end
end
end
This implementation leads us to a question about our code. What should our code do when an invalid command is entered? Once we decide the answer to that question we could implement another test.
it "Raises an error when an invalid command is entered" do
menu = GameMenu.new(nil, nil)
lambda { menu.execute("invalid command") }.should raise_error(ArgumentError)
end
That drives out a quick change to the execute
method
def execute(command)
unless ["c", "i"].include? command
raise ArgumentError("Invalid command '#{command}'")
end
if command == "i"
@inventory_command.execute
else
@character_creation_command.execute
end
end
Now that we have passing tests we can use the Extract Method refactoring to extract the validation of the command into an Intent Revealing Method.
def execute(command)
raise ArgumentError("Invalid command '#{command}'") if invalid? command
if command == "i"
@inventory_command.execute
else
@character_creation_command.execute
end
end
def invalid?(command)
!["c", "i"].include? command
end
Now we finally got to the point we can address your question. Since the invalid?
method was driven out by refactoring existing code under test then there is no need to write a unit test for it, it's already covered and does not stand on it's own. Since the inventory and character commands are not tested by our existing test, they will need to be test driven independently.
Note that our code could be better still so, while the tests are passing, lets clean it up a bit more. The conditional statements are an indicator that we are violating the OCP (Open-Closed Principle) we can use the Replace Conditional With Polymorphism refactoring to remove the conditional logic.
# Refactored to comply to the OCP.
class GameMenu
def initialize(character_creation_command, inventory_command)
@commands = {
"c" => character_creation_command,
"i" => inventory_command
}
end
def execute(command)
raise ArgumentError("Invalid command '#{command}'") if invalid? command
@commands[command].execute
end
def invalid?(command)
[email protected]_key? command
end
end
Now we've refactored the class such that an additional command simply requires us to add an additional entry to the commands hash rather than changing our conditional logic as well as the invalid?
method.
All the tests should still pass and we have almost completed our work. Once we test drive the individual commands you can go back to the initialize method and add some defaults for the commands like so:
def initialize(character_creation_command = CharacterCreation.new,
inventory_command = Inventory.new)
@commands = {
"c" => character_creation_command,
"i" => inventory_command
}
end
The final test is:
describe GameMenu do
it "Allows you to navigate to character creation" do
character_creation = mock("character creation")
character_creation.should_receive(:execute)
menu = GameMenu.new(character_creation)
menu.execute("c")
end
it "Allows you to display character inventory" do
inventory_command = mock("inventory")
inventory_command.should_receive(:execute)
menu = GameMenu.new(nil, inventory_command)
menu.execute("i")
end
it "Raises an error when an invalid command is entered" do
menu = GameMenu.new(nil, nil)
lambda { menu.execute("invalid command") }.should raise_error(ArgumentError)
end
end
And the final GameMenu
looks like:
class GameMenu
def initialize(character_creation_command = CharacterCreation.new,
inventory_command = Inventory.new)
@commands = {
"c" => character_creation_command,
"i" => inventory_command
}
end
def execute(command)
raise ArgumentError("Invalid command '#{command}'") if invalid? command
@commands[command].execute
end
def invalid?(command)
[email protected]_key? command
end
end
Hope that helps!
Brandon
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With