Exercises for Programmers (Chapter 1)
This is to do some drills of source and test writing approaches using Go. The exercise is to write a tip calculator: the user provides a bill amount and a tip rate and the program displays the tip amount and the total bill.
Source
The main function handles the UI: prompting for input and displaying the final output. It doesn’t have automated tests due to the complexity of testing user interaction and given that the code here to handle user interaction utilises Go’s standard library and is easily tested by running the program. The other functions called by main do have automated tests and are written such that they’re pure, or effectively so: validateBillAmount and validateTipRate both call log.Fatal if regexp.MatchString errors but, given that the regular expression is correct, this should never happen.
The fact that the functions are pure makes automated tests easy to write since all that is needed is to provide each function a range of inputs and check the outputs (which will always be the same given the same inputs).
toFixed does not have its own automated tests but is tested implicitly in those for calculateTip. In a strict context where rounding half up is required, toFixed is a defective function: e.g. toFixed(1.015) returns 1.01 due to the precision of the float64 type of the parameter being too low (something for me to understand better).
package main
import (
"fmt"
"log"
"math"
"regexp"
"strconv"
)
// main prompts the user to input the bill amount and tip rate.
// The user inputs are validated and parsed.
// The tip and total amount are calulated and displayed to the user.
func main() {
var billAmountInput, tipRateInput string
fmt.Print("What is the bill amount? ")
fmt.Scanf("%s", &billAmountInput)
billAmount, err := validateBillAmount(billAmountInput)
if err != nil {
fmt.Println(err)
return
}
fmt.Print("What is the tip rate? ")
fmt.Scanf("%s", &tipRateInput)
tipRate, err := validateTipRate(tipRateInput)
if err != nil {
fmt.Println(err)
return
}
tip, total := calculateTip(billAmount, tipRate)
fmt.Print(formatOutput(tip, total))
}
// validateBillAmount checks whether the user input string when parsed will be a non-negative
// floating point number to two decimal places.
// It returns the parsed bill amount and any validation error.
func validateBillAmount(billAmount string) (float64, error) {
matched, err := regexp.MatchString(`^\d+(\.\d{2})?$`, billAmount)
if err != nil {
log.Fatal("Error running tip calculator")
}
if matched != true {
return 0, fmt.Errorf(
"Please enter positive bill amount in pounds and pence, e.g. 65.60")
}
parsed, _ := strconv.ParseFloat(billAmount, 64)
return parsed, nil
}
// validateTipRate checks whether the user input string when parsed will be a non-negative
// floating point number to any number of decimal places.
// It returns the parsed tip rate and any validation error.
func validateTipRate(tipRate string) (float64, error) {
matched, err := regexp.MatchString(`^\d+(\.\d+)?$`, tipRate)
if err != nil {
log.Fatal("Error running tip calculator")
}
if matched != true {
return 0, fmt.Errorf(
"Please enter a positive tip rate as a percentage, e.g. 15 or 12.5")
}
parsed, _ := strconv.ParseFloat(tipRate, 64)
return parsed, nil
}
// calculateTip calculates the tip amount using billAmount and tipRate.
// It returns the tip amount and the total bill (tip + billAmount).
func calculateTip(billAmount, tipRate float64) (tip, total float64) {
tip = toFixed(billAmount * (tipRate / 100))
total = toFixed(billAmount + tip)
return
}
// formatOutput takes the tip and total and returns a formatted string to display to the user.
func formatOutput(tip, total float64) string {
return fmt.Sprintf("\nTip: £%.2f\nTotal: £%.2f\n", tip, total)
}
// toFixed takes a float f and returns a float which is f fixed to two decimal places.
func toFixed(f float64) float64 {
return math.Round(f*100) / 100
}
Tests
The tests use Go’s built-in testing package and a common approach in Go of using table driven tests. I’ve been of the opinion that tests should be written declaritively (frameworks such as JavaScript’s Jest lend themselves to this), but I much preferred the plain imperative style of Go combined with the table driven approach and the fact that no other dependencies are needed.
I would like to understand better how to decide test cases: are there standard, generalised sets of inputs to consider when testing a function? e.g. for a function that takes a number there are positive, negative and fractional numbers where a fractional number can be exactly between two numbers or closer to one than the other. How can a developer best approach the exhaustive testing of a program or as near to this as possible without just hoping that an important test case has not been missed?
package main
import (
"errors"
"testing"
)
func TestValidateBillAmount(t *testing.T) {
errMsg := "Please enter positive bill amount in pounds and pence, e.g. 65.60"
tests := []struct {
in string
out float64
err error
}{
{in: "1", out: 1, err: nil},
{in: "1.12", out: 1.12, err: nil},
{in: "-1", err: errors.New(errMsg)},
{in: "1.123", err: errors.New(errMsg)},
{in: "abc123", err: errors.New(errMsg)},
}
for _, test := range tests {
t.Run(test.in, func(t *testing.T) {
out, err := validateBillAmount(test.in)
if err != nil && err.Error() != test.err.Error() {
t.Fatalf("got %q, want %q", err, test.err)
}
if out != test.out {
t.Fatalf("got %f, want %f", out, test.out)
}
})
}
}
func TestValidateTipRate(t *testing.T) {
errMsg := "Please enter a positive tip rate as a percentage, e.g. 15 or 12.5"
tests := []struct {
in string
out float64
err error
}{
{in: "10", out: 10, err: nil},
{in: "10.123", out: 10.123, err: nil},
{in: "-10", err: errors.New(errMsg)},
{in: "abc123", err: errors.New(errMsg)},
}
for _, test := range tests {
t.Run(test.in, func(t *testing.T) {
out, err := validateTipRate(test.in)
if err != nil && err.Error() != test.err.Error() {
t.Fatalf("got %q, want %q", err, test.err)
}
if out != test.out {
t.Fatalf("got %f, want %f", out, test.out)
}
})
}
}
func TestCalculateTipRate(t *testing.T) {
tests := map[string]struct {
billAmount,
tipRate,
tip,
total float64
}{
"zero": {billAmount: 0, tipRate: 0, tip: 0, total: 0},
"zeroBill": {billAmount: 0, tipRate: 25, tip: 0, total: 0},
"zeroTip": {billAmount: 10, tipRate: 0, tip: 0, total: 10},
"25%": {billAmount: 10, tipRate: 25, tip: 2.5, total: 12.50},
"50%": {billAmount: 2.24, tipRate: 50, tip: 1.12, total: 3.36},
"roundedDown": {billAmount: 12.62, tipRate: 33, tip: 4.16, total: 16.78},
"roundedUp": {billAmount: 20, tipRate: 10.234, tip: 2.05, total: 22.05},
"large": {billAmount: 9999999999.99, tipRate: 99, tip: 9899999999.99, total: 19899999999.98},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
tip, total := calculateTip(test.billAmount, test.tipRate)
if tip != test.tip {
t.Fatalf("tip: got %f, want %f", tip, test.tip)
}
if total != test.total {
t.Fatalf("total: got %f, want %f", total, test.total)
}
})
}
}
func TestFormatOutput(t *testing.T) {
tests := []struct {
tip, total float64
out string
}{
{tip: 10, total: 20, out: "\nTip: £10.00\nTotal: £20.00\n"},
{tip: 10.12, total: 20.12, out: "\nTip: £10.12\nTotal: £20.12\n"},
{tip: 10.12000, total: 20.12000, out: "\nTip: £10.12\nTotal: £20.12\n"},
}
for _, test := range tests {
t.Run("", func(t *testing.T) {
if out := formatOutput(test.tip, test.total); out != test.out {
t.Fatalf("got %s, want: %s", out, test.out)
}
})
}
}