This section begins with the specification of a payroll system. The specs include descriptions of varying types of employees and how they are compensated. Uncle Bob then recommends that the reader "implement the first few use cases test-first." This sounds like a good idea. So I decided to implement the 7 use cases from the book on my own first. Here's the first one:
---------------------------------------------------------
USE CASE 1: ADD NEW EMPLOYEE
A new employee is added by the receipt of an AddEmp transaction. This transaction contains the employee's name, address, and assigned employee number. The transaction has the following three forms:
AddEmp [EmpID] "[name]" "[address]" H [hourly-rate]
AddEmp [EmpID] "[name]" "[address]" S [monthly-salary]
AddEmp [EmpID] "[name]" "[address]" C [monthly-salary] [commission-rate]
The employee record is created with its fields assigned appropriately.
Alternative: An error in the transaction structure
If the transaction structure is inappropriate, it is printed out in an error message, and no action is taken.
---------------------------------------------------------
I recognized (too?) quickly that I would need a database to manage the creating and deleting of employees, so I went with DataMapper. This decision was mostly made because I've never used DataMapper before and I thought this would provide me with an easy introduction.
Here are my files and tests to satisfy Use Case 1. I've provided observations on the crafting of this code below.
records_manager_test.rb
require File.join(File.dirname(__FILE__) ,'/test_helper')
require 'lib/records_manager'
class RecordsManagerTest < Test::Unit::TestCase
def setup
DataMapper.auto_migrate!
@any_id = 100
@any_name = "name"
@any_address = "address"
@new_employee_fields = { :emp_id => @any_id,
:name => @any_name,
:address => @any_address,
:salary => new_salary('monthly')
}
@employee_commission = 3.0
@records_manager = RecordsManager.new
end
def teardown
Employee.all.each { |e| e.destroy }
Salary.all.each { |e| e.destroy }
end
def test_add_employee_creates_employee_record
@records_manager.add_employee(@new_employee_fields)
assert_employee_was_created
assert_equal @any_id, new_employee.emp_id
assert_equal @any_name, new_employee.name
assert_equal @any_address, new_employee.address
end
def test_add_employee_creates_hourly_rate_employee
hourly_salary = new_salary('hourly')
assert_creation_of_employee_with hourly_salary
end
def test_add_employee_creates_monthly_rate_employee
monthly_salary = new_salary('monthly')
assert_creation_of_employee_with monthly_salary
end
def test_add_employee_creates_commission_employee
commission_salary = new_salary('monthly', @employee_commission)
assert_creation_of_employee_with commission_salary
end
def test_add_employee_fails_when_provided_no_emp_id
assert_employee_not_created_with_nil :emp_id
end
def test_add_employee_fails_when_provided_no_name
assert_employee_not_created_with_nil :name
end
def test_add_employee_fails_when_provided_no_ddress
assert_employee_not_created_with_nil :address
end
private
def assert_employee_was_created
assert_equal 1, Employee.count
end
def assert_creation_of_employee_with(salary)
@new_employee_fields[:salary] = salary
@records_manager.add_employee(@new_employee_fields)
assert_equal salary, new_employee.salary
end
def assert_employee_not_created_with_nil(field)
@new_employee_fields[field] = nil
error_message = "must not be blank"
assert_match error_message, @records_manager.add_employee(@new_employee_fields)
end
def new_salary(compensation, commission=nil)
Salary.new :pay_rate => pay_rate(compensation),
:compensation => compensation,
:commission => commission
end
def pay_rate(type)
return 5000 if type == 'monthly'
return 12.50 if type == 'hourly'
end
def new_employee
Employee.first
end
end
records_manager.rb
require 'app/models/salary'
require 'app/models/employee'
class RecordsManager
def add_employee(fields)
new_employee = Employee.new(fields)
return if new_employee.save
error_message_for new_employee
end
private
def error_message_for(employee)
employee.errors.full_messages.to_s
end
end
require 'rubygems'
require 'dm-core'
require 'dm-aggregates'
require 'dm-validations'
require 'app/models/salary'
DataMapper.setup(:default, :adapter => 'mysql',
:username => 'root',
:password => 'even-fish',
:database => 'payroll')
class Employee
include DataMapper::Resource
property :id, Serial
property :emp_id, Integer
property :name, String
property :address, Text
has 1, :salary
validates_present :emp_id, :name, :address
end
salary.rb
require 'rubygems'
require 'dm-core'
require 'dm-aggregates'
require 'dm-types'
require 'app/models/employee'
DataMapper.setup(:default, :adapter => 'mysql',
:username => 'root',
:password => 'even-fish',
:database => 'payroll')
class Salary
include DataMapper::Resource
property :id, Serial
property :pay_rate, Float
property :commission, Float
property :compensation, Enum['monthly','hourly']
belongs_to :employee
end
Thoughts:
- DataMapper is a heavyweight solution for the payroll system at this point. I cheated a little bit in looking at Use Case 2, which specifies that we should be able to delete employees. Still, it gave me an excuse to play with it and I'm pretty confident in it being a good solution as the software evolves.
- When I created a Salary model I thought immediately that I would need several types of Salary objects. My first thought was to use STI and I originally built it in for this use case. But then I reverted, eventually deciding that I could postpone this decision until the application gets a bit more complex. For now I'm just dealing with the pain of specifying a type of salary in my test cases.
- You'll notice that function 'add_employee' doesn't really take four or five arguments. It takes a hash because this seemed like a simpler solution. Now DataMapper handles the setting of fields and my RecordsManager object doesn't have to care what type of salary it's being passed. I'm not sure if that's in keeping with the specifications, but I'm going to leave it like that for now.
- I have many duplicated require statements in my models. Also, I'm duplicating my database setup. A future iteration should abstract this away and DRY up the models.