$30 off During Our Annual Pro Sale. View Details »

How to design a test suite you’ll love to maintain

How to design a test suite you’ll love to maintain

Have you worked on a test suite where you're cursing it more often than you're thanking it for helping you out? I sure have! I have written them myself, seen a good suite degrade into it over time, and even taken over messes.

I will share the constraints I put on my test suites that help me maintain them and why they help. We'll look at how to discover when you're breaking these constraints and solutions to make living with the constraints simpler.

Presented at GopherCon Singapore with a recording on Youtube.

Links in the presentation:

- Presentation git repo
- Rob Pike: Self-referential functions and the design of options
- Justin Searls: Please don’t mock me. A great talk that talks about the steps to make sure you don’t mix collaboration and business logic, walks through how to think about mocks in an effective way, which is the big missing piece in this presentation
- Jay Fields: Working Effectively With Unit Tests. Where I picked up most of the things for how to highlight differences, great exploration of builders, and how to think about unit testing
- GeePaw Hill: Many More Much Smaller Steps and Microtest TDD. To get some good ideas about how to think about testing and moving in smaller steps. GeePaw's Leading Technical Change course is also fantastic if you want to get ideas for how to influence others to get changes across an org

Björn Andersson

November 03, 2023
Tweet

More Decks by Björn Andersson

Other Decks in Programming

Transcript

  1. Why? Let’s not curse the darkness let’s light a fl

    ood light • How many haven't worked on a test suite where you're cursing its existance rather than praising it because it's saving you? • For example, have you • Read a lot of tests and you can’t tell what they’re doing? • Made a single change in logic which snowballed when all callers needed updates with the new logic? • Or had to tweak exactly what a mock returned so some business logic would work after a change?
  2. What we’ll be working on • Reviewing some test code

    and get a feel for what it’s doing • Refactor it according to some constraints • We’ll be walking through the refactors step-by-step and explain the concepts as we go along • The domain is a calculator that could be sitting in a Point of Sale system
  3. Test case review { name: "Sums to 0 with an

    empty cart", items: []LineItem{ {}, }, totalAmount: 0, totalTaxAmount: 0, },
  4. Test case review { name: "Calculate an item with a

    tax rate", items: []LineItem{ { Description: "Overpriced Banana", Quantity: 1, TaxRate: 0.12, Price: 1, }, }, totalAmount: 1, totalTaxAmount: 0.1071, },
  5. Test case review { name: "Calculate an item with a

    tax rate", items: []LineItem{ { Description: "Overpriced Banana", Quantity: 1, TaxRate: 0.12, Price: 1, }, }, totalAmount: 1, totalTaxAmount: 0.1071, }, The product we’re buying, we’ll use it as the primary key for these tests
  6. Test case review { name: "Calculate an item with a

    tax rate", items: []LineItem{ { Description: "Overpriced Banana", Quantity: 1, TaxRate: 0.12, Price: 1, }, }, totalAmount: 1, totalTaxAmount: 0.1071, }, How many we’re looking to buy
  7. Test case review { name: "Calculate an item with a

    tax rate", items: []LineItem{ { Description: "Overpriced Banana", Quantity: 1, TaxRate: 0.12, Price: 1, }, }, totalAmount: 1, totalTaxAmount: 0.1071, }, How many % of tax was added to the price
  8. Test case review { name: "Calculate an item with a

    tax rate", items: []LineItem{ { Description: "Overpriced Banana", Quantity: 1, TaxRate: 0.12, Price: 1, }, }, totalAmount: 1, totalTaxAmount: 0.1071, }, The cost of one item inclusive of tax
  9. Test case review { name: "Calculate an item with a

    tax rate", items: []LineItem{ { Description: "Overpriced Banana", Quantity: 1, TaxRate: 0.12, Price: 1, }, }, totalAmount: 1, totalTaxAmount: 0.1071, }, How much we’re paying including tax
  10. Test case review { name: "Calculate an item with a

    tax rate", items: []LineItem{ { Description: "Overpriced Banana", Quantity: 1, TaxRate: 0.12, Price: 1, }, }, totalAmount: 1, totalTaxAmount: 0.1071, }, Of what we paid, this much was in taxes
  11. Test case review { name: "Calculate an item where quantity

    is not 1", items: []LineItem{ { Description: "Overpriced Banana", Quantity: 2, TaxRate: 0.12, Price: 1, }, }, totalAmount: 2, totalTaxAmount: 0.2142, },
  12. Test case review { name: "Calculate an item where quantity

    is not 1", items: []LineItem{ { Description: "Overpriced Banana", Quantity: 2, TaxRate: 0.12, Price: 1, }, }, totalAmount: 2, totalTaxAmount: 0.2142, }, Doubling the quantity
  13. Test case review { name: "Calculate an item where quantity

    is not 1", items: []LineItem{ { Description: "Overpriced Banana", Quantity: 2, TaxRate: 0.12, Price: 1, }, }, totalAmount: 2, totalTaxAmount: 0.2142, }, Doubles the price
  14. Test case review { name: "Stops calculating when there's an

    invalid tax rate", items: []LineItem{ { Description: "Invalid Banana", Quantity: 1, TaxRate: 0.66, Price: 1, }, }, expectError: true, },
  15. Test case review { name: "Stops calculating when there's an

    invalid tax rate", items: []LineItem{ { Description: "Invalid Banana", Quantity: 1, TaxRate: 0.66, Price: 1, }, }, expectError: true, }, An invalid tax rate, but how do we know this?
  16. Test case review { name: "Stops calculating when there's an

    invalid tax rate", items: []LineItem{ { Description: "Invalid Banana", Quantity: 1, TaxRate: 0.66, Price: 1, }, }, expectError: true, }, And we want to see that it failed
  17. Test case review { name: "Stops calculating when there's an

    invalid tax rate", items: []LineItem{ { Description: "Invalid Banana", Quantity: 1, TaxRate: 0.66, Price: 1, }, }, expectError: true, }, And we want to see that it failed Right, some kind of magic here
  18. Test case review { name: "Calculates with discounts applied", items:

    []LineItem{ { Description: "Ripe Banana", Quantity: 1, TaxRate: 0.12, Price: 1, }, }, totalAmount: 0.8, totalTaxAmount: 0.08568, },
  19. Test case review { name: "Calculates with discounts applied", items:

    []LineItem{ { Description: "Ripe Banana", Quantity: 1, TaxRate: 0.12, Price: 1, }, }, totalAmount: 0.8, totalTaxAmount: 0.08568, }, Okay, di ff erent name
  20. Test case review { name: "Calculates with discounts applied", items:

    []LineItem{ { Description: "Ripe Banana", Quantity: 1, TaxRate: 0.12, Price: 1, }, }, totalAmount: 0.8, totalTaxAmount: 0.08568, }, Same price…
  21. Test case review { name: "Calculates with discounts applied", items:

    []LineItem{ { Description: "Ripe Banana", Quantity: 1, TaxRate: 0.12, Price: 1, }, }, totalAmount: 0.8, totalTaxAmount: 0.08568, }, But our totals are di ff erent!?
  22. Test case review { name: "Calculates with discounts applied", items:

    []LineItem{ { Description: "Ripe Banana", Quantity: 1, TaxRate: 0.12, Price: 1, }, }, totalAmount: 0.8, totalTaxAmount: 0.08568, }, How does all this come together!?
  23. Smells • Magic values • Why did 0.66 fail as

    a tax rate? How do I fi gure out what a valid value is? • Why did the “Ripe Banana” get 20% cheaper? • Di ff erent fi elds used in the tests • This implies you’re encouraged to add more fi elds to handle a one-o ff situation for your case
  24. Test setup Where our table hits the code taxRates :=

    NewStaticTaxRates( TaxRate(0.25, 0.20), TaxRate(0.12, 0.1071), TaxRate(0.06, 0.0566), TaxRate(0, 0), ) discountRules := NewDiscountForItem( "Ripe Banana", Discount{"Expiring soon", 0.2}, ) calc := NewCalculator(taxRates, []Discounter{discountRules}) result, err := calc.Calculate(tc.items) if tc.expectError { require.Error(t, err) } else { require.True(t, result.Valid) require.Equal(t, tc.totalAmount, result.TotalAmount) require.Equal(t, tc.totalTaxAmount, result.TotalTaxAmount) }
  25. Test setup Where our table hits the code taxRates :=

    NewStaticTaxRates( TaxRate(0.25, 0.20), TaxRate(0.12, 0.1071), TaxRate(0.06, 0.0566), TaxRate(0, 0), ) discountRules := NewDiscountForItem( "Ripe Banana", Discount{"Expiring soon", 0.2}, ) calc := NewCalculator(taxRates, []Discounter{discountRules}) result, err := calc.Calculate(tc.items) if tc.expectError { require.Error(t, err) } else { require.True(t, result.Valid) require.Equal(t, tc.totalAmount, result.TotalAmount) require.Equal(t, tc.totalTaxAmount, result.TotalTaxAmount) } Aah, the 0.66 tax rate is invalid because it’s not in this list
  26. Test setup Where our table hits the code taxRates :=

    NewStaticTaxRates( TaxRate(0.25, 0.20), TaxRate(0.12, 0.1071), TaxRate(0.06, 0.0566), TaxRate(0, 0), ) discountRules := NewDiscountForItem( "Ripe Banana", Discount{"Expiring soon", 0.2}, ) calc := NewCalculator(taxRates, []Discounter{discountRules}) result, err := calc.Calculate(tc.items) if tc.expectError { require.Error(t, err) } else { require.True(t, result.Valid) require.Equal(t, tc.totalAmount, result.TotalAmount) require.Equal(t, tc.totalTaxAmount, result.TotalTaxAmount) } And “Ripe Bana” is the only item with a discount at the moment
  27. Test setup Where our table hits the code taxRates :=

    NewStaticTaxRates( TaxRate(0.25, 0.20), TaxRate(0.12, 0.1071), TaxRate(0.06, 0.0566), TaxRate(0, 0), ) discountRules := NewDiscountForItem( "Ripe Banana", Discount{"Expiring soon", 0.2}, ) calc := NewCalculator(taxRates, []Discounter{discountRules}) result, err := calc.Calculate(tc.items) if tc.expectError { require.Error(t, err) } else { require.True(t, result.Valid) require.Equal(t, tc.totalAmount, result.TotalAmount) require.Equal(t, tc.totalTaxAmount, result.TotalTaxAmount) } Ou ff , so setting an error means we don’t expect to check the values
  28. Test setup Where our table hits the code taxRates :=

    NewStaticTaxRates( TaxRate(0.25, 0.20), TaxRate(0.12, 0.1071), TaxRate(0.06, 0.0566), TaxRate(0, 0), ) discountRules := NewDiscountForItem( "Ripe Banana", Discount{"Expiring soon", 0.2}, ) calc := NewCalculator(taxRates, []Discounter{discountRules}) result, err := calc.Calculate(tc.items) if tc.expectError { require.Error(t, err) } else { require.True(t, result.Valid) require.Equal(t, tc.totalAmount, result.TotalAmount) require.Equal(t, tc.totalTaxAmount, result.TotalTaxAmount) } Ah, so we’re testing in the same package and not in _test
  29. Refactor the tests // TODO Move to _test package Remove

    if-statement from test body Highlight important setup
  30. Refactor the tests Move to _test package package cart //

    … taxRates := NewStaticTaxRates( TaxRate(0.25, 0.20), TaxRate(0.12, 0.1071), TaxRate(0.06, 0.566), TaxRate(0, 0), ) discountRules := NewDiscountForItem( "Ripe Banana", Discount{"Expiring soon", 0.2}, ) calc := NewCalculator(taxRates, []Discounter{discountRules})
  31. Refactor the tests Move to _test package package cart //

    … taxRates := NewStaticTaxRates( TaxRate(0.25, 0.20), TaxRate(0.12, 0.1071), TaxRate(0.06, 0.566), TaxRate(0, 0), ) discountRules := NewDiscountForItem( "Ripe Banana", Discount{"Expiring soon", 0.2}, ) calc := NewCalculator(taxRates, []Discounter{discountRules})
  32. Refactor the tests Move to _test package package cart_test //

    … taxRates := NewStaticTaxRates( TaxRate(0.25, 0.20), TaxRate(0.12, 0.1071), TaxRate(0.06, 0.566), TaxRate(0, 0), ) discountRules := NewDiscountForItem( "Ripe Banana", Discount{"Expiring soon", 0.2}, ) calc := NewCalculator(taxRates, []Discounter{discountRules})
  33. Refactor the tests Move to _test package package cart_test //

    … taxRates := NewStaticTaxRates( TaxRate(0.25, 0.20), TaxRate(0.12, 0.1071), TaxRate(0.06, 0.566), TaxRate(0, 0), ) discountRules := NewDiscountForItem( "Ripe Banana", Discount{"Expiring soon", 0.2}, ) calc := NewCalculator(taxRates, [] Discounter{discountRules})
  34. Refactor the tests Move to _test package package cart_test import

    "cart" // … taxRates := cart.NewStaticTaxRates( cart.TaxRate(0.25, 0.20), cart.TaxRate(0.12, 0.1071), cart.TaxRate(0.06, 0.566), cart.TaxRate(0, 0), ) discountRules := cart.NewDiscountForItem( "Ripe Banana", cart.Discount{"Expiring soon", 0.2}, ) calc := cart.NewCalculator(taxRates, []cart.Discounter{discountRules})
  35. Refactor the tests Move to _test package • Now we

    can only test public interfaces • Which should make us think about what we make public • The goal of our tests is to test the behavior and not the implementation • And it’s much easier to fall into the trap of testing implementation if you have access to everything • Because private stu ff are implementation details • If you really need to test private things then put them in an internal package and make them public in that internal package
  36. Refactor the tests // TODO Move to _test package Remove

    if-statement Highlight important setup
  37. Refactor the tests Remove if-statement { name: "Stops calculating when

    there's an invalid tax rate", // … expectError: true, }, if tc.expectError { require.Error(t, err) } else { require.True(t, result.Valid) require.Equal(t, tc.totalAmount, result.TotalAmount) require.Equal(t, tc.totalTaxAmount, result.TotalTaxAmount) }
  38. Refactor the tests Remove if-statement { name: "Stops calculating when

    there's an invalid tax rate", // … expectError: }, if tc.expectError { require.Error(t, err) } else { require.True(t, result.Valid) require.Equal(t, tc.totalAmount, result.TotalAmount) require.Equal(t, tc.totalTaxAmount, result.TotalTaxAmount) }
  39. Refactor the tests Remove if-statement { name: "Stops calculating when

    there's an invalid tax rate", // … expectError: func(t *testing.T, err error) { require.ErrorIs(t, err, &cart.UnknownTaxRate{}) }, }, if tc.expectError { require.Error(t, err) } else { require.True(t, result.Valid) require.Equal(t, tc.totalAmount, result.TotalAmount) require.Equal(t, tc.totalTaxAmount, result.TotalTaxAmount) }
  40. Refactor the tests Remove if-statement { name: "Stops calculating when

    there's an invalid tax rate", // … expectError: func(t *testing.T, err error) { require.ErrorIs(t, err, &cart.UnknownTaxRate{}) }, }, if tc.expectError { } else { require.True(t, result.Valid) require.Equal(t, tc.totalAmount, result.TotalAmount) require.Equal(t, tc.totalTaxAmount, result.TotalTaxAmount) }
  41. Refactor the tests Remove if-statement { name: "Stops calculating when

    there's an invalid tax rate", // … expectError: func(t *testing.T, err error) { require.ErrorIs(t, err, &cart.UnknownTaxRate{}) }, }, if tc.expectError != nil { tc.expectError(t, err) } else { require.True(t, result.Valid) require.Equal(t, tc.totalAmount, result.TotalAmount) require.Equal(t, tc.totalTaxAmount, result.TotalTaxAmount) }
  42. Refactor the tests Remove if-statement { name: "Stops calculating when

    there's an invalid tax rate", // … expectError: func(t *testing.T, err error) { require.ErrorIs(t, err, &cart.UnknownTaxRate{}) }, }, if tc.expectError != nil { tc.expectError(t, err) } else { require.True(t, result.Valid) require.Equal(t, tc.totalAmount, result.TotalAmount) require.Equal(t, tc.totalTaxAmount, result.TotalTaxAmount) } DIdn’t solve it, just changed it
  43. Refactor the tests Remove if-statement { name: "Stops calculating when

    there's an invalid tax rate", // … expectError: func(t *testing.T, err error) { require.ErrorIs(t, err, &cart.UnknownTaxRate{}) }, }, if tc.expectError != nil { tc.expectError(t, err) } else { require.True(t, result.Valid) require.Equal(t, tc.totalAmount, result.TotalAmount) require.Equal(t, tc.totalTaxAmount, result.TotalTaxAmount) } But I’ll have to do the same thing here, so let’s repeat
  44. Refactor the tests Remove if-statement { name: "Stops calculating when

    there's an invalid tax rate", // … expectError: func(t *testing.T, err error) { require.ErrorIs(t, err, &cart.UnknownTaxRate{}) }, }, if tc.expectError != nil { tc.expectError(t, err) } else { require.True(t, result.Valid) require.Equal(t, tc.totalAmount, result.TotalAmount) require.Equal(t, tc.totalTaxAmount, result.TotalTaxAmount) }
  45. Refactor the tests Remove if-statement { name: "Stops calculating when

    there's an invalid tax rate", // … expectError: func(t *testing.T, err error) { require.ErrorIs(t, err, &cart.UnknownTaxRate{}) }, }, if tc.expectError != nil { tc.expectError(t, err) } else { }
  46. Refactor the tests Remove if-statement { name: "Stops calculating when

    there's an invalid tax rate", // … expectError: func(t *testing.T, err error) { require.ErrorIs(t, err, &cart.UnknownTaxRate{}) }, expectResult: func(t *testing.T, result *cart.Result) { require.Empty(t, result) }, }, if tc.expectError != nil { tc.expectError(t, err) } else if tc.expectResult != nil { tc.expectResult(t, result) }
  47. Refactor the tests Remove if-statement { name: "Stops calculating when

    there's an invalid tax rate", // … expectError: func(t *testing.T, err error) { require.ErrorIs(t, err, &cart.UnknownTaxRate{}) }, expectResult: func(t *testing.T, result *cart.Result) { require.Empty(t, result) }, }, if tc.expectError != nil { tc.expectError(t, err) } else if tc.expectResult != nil { tc.expectResult(t, result) } This is just getting messier 😬
  48. Refactor the tests Remove if-statement func noError(t *testing.T, err error)

    { t.Helper() require.NoError(t, err) } func totals(totalAmount, totalTaxAmount float64) func(*testing.T, *cart.Result) { return func(t *testing.T, result *cart.Result) { t.Helper() require.True(t, result.Valid) require.Equal(t, totalAmount, result.TotalAmount) require.Equal(t, totalTaxAmount, result.TotalTaxAmount) } }
  49. Refactor the tests Remove if-statement { name: "Sums to 0

    with an empty cart", items: []cart.LineItem{ {}, }, totalAmount: 0, },
  50. Refactor the tests Remove if-statement { name: "Sums to 0

    with an empty cart", items: []cart.LineItem{ {}, }, },
  51. Refactor the tests Remove if-statement { name: "Sums to 0

    with an empty cart", items: []cart.LineItem{ {}, }, expectError: noError, expectResult: totals(0, 0), },
  52. Refactor the tests Remove if-statement { name: "Sums to 0

    with an empty cart", items: []cart.LineItem{ {}, }, expectError: noError, expectResult: totals(0, 0), }, And now repeat that across our tests with correct values
  53. Refactor the tests Remove if-statement if tc.expectError != nil {

    tc.expectError(t, err) } else if tc.expectResult != nil { tc.expectResult(t, result) }
  54. Refactor the tests // TODO Move to _test package Remove

    if-statement Highlight important setup
  55. Refactor the tests Highlight important setup taxRates := cart.NewStaticTaxRates( cart.TaxRate(0.25,

    0.20), cart.TaxRate(0.12, 0.1071), cart.TaxRate(0.06, 0.566), cart.TaxRate(0, 0), ) discountRules := cart.NewDiscountForItem( "Ripe Banana", cart.Discount{"Expiring soon", 0.2}, ) calc := cart.NewCalculator(taxRates, []cart.Discounter{discountRules}) result, err := calc.Calculate(tc.items) tc.expectError(t, err) tc.expectResult(t, result)
  56. Refactor the tests Highlight important setup calc := cart.NewCalculator( )

    result, err := calc.Calculate(tc.items) tc.expectError(t, err) tc.expectResult(t, result)
  57. Refactor the tests Highlight important setup calc := cart.NewCalculator(tc.taxRates, tc.discounts)

    result, err := calc.Calculate(tc.items) tc.expectError(t, err) tc.expectResult(t, result) Straightforward test body!
  58. Refactor the tests Highlight important setup func swedishTaxRates() cart.TaxRates {

    return cart.NewStaticTaxRates( cart.TaxRate(0.25, 0.20), cart.TaxRate(0.12, 0.1071), cart.TaxRate(0.06, 0.566), cart.TaxRate(0, 0), ) } func noDiscounts() []cart.Discounter { return nil }
  59. Refactor the tests Highlight important setup { name: "Sums to

    0 with an empty cart", taxRates: swedishTaxRates, discounts: noDiscounts, items: []cart.LineItem{ {}, }, expectError: noError, expectResult: totals(0, 0), }, Let’s declare our defaults
  60. Refactor the tests Highlight important setup { name: "Stops calculating

    when there's an invalid tax rate", taxRates: func() cart.TaxRates { return cart.NewStaticTaxRates() // no tax rate is ever valid }, discounts: noDiscounts, items: []cart.LineItem{ { Description: "Invalid Banana", Quantity: 1, TaxRate: 0.66, Price: 1, }, }, expectError: func(t *testing.T, err error) { require.ErrorIs(t, err, &cart.UnknownTaxRate{0.66}) }, // .. }, Make sure the tax rate is invalid
  61. Refactor the tests Highlight important setup { name: "Sums to

    0 with an empty cart", taxRates: swedishTaxRates, discounts: noDiscounts, items: []cart.LineItem{ { name: "Calculate an item with a tax rate", taxRates: swedishTaxRates, discounts: noDiscounts, items: []cart.LineItem{ { name: "Calculate an item where quantity is not 1", taxRates: swedishTaxRates, discounts: noDiscounts, items: []cart.LineItem{
  62. Refactor the tests Highlight important setup func NewCalculator(taxRates TaxRates, discounts

    []Discounter) *Calculator { return &Calculator{ taxRates: taxRates, discounts: discounts, } }
  63. Refactor the tests Highlight important setup // Builder pattern calculator().

    WithTaxRates(cart.NewStaticTaxRates()). // no tax rate is ever valid Build() vs. // Options (variant of strategy) pattern defaultCalculator( withTaxRates(cart.NewStaticTaxRates()), // no tax rate is ever valid )
  64. Refactor the tests Highlight important setup func defaultCalculator(options ...calculatorOption) *cart.Calculator

    { calc := calculatorOptionBuilder{ taxRates: swedishTaxRates(), discounts: nil, } for _, opt := range options { opt(&calc) } return cart.NewCalculator(calc.taxRates, calc.discounts) } A struct that takes on our default
  65. Refactor the tests Highlight important setup func defaultCalculator(options ...calculatorOption) *cart.Calculator

    { calc := calculatorOptionBuilder{ taxRates: swedishTaxRates(), discounts: nil, } for _, opt := range options { opt(&calc) } return cart.NewCalculator(calc.taxRates, calc.discounts) } type calculatorOptionBuilder struct { taxRates cart.TaxRates discounts []cart.Discounter }
  66. Refactor the tests Highlight important setup func defaultCalculator(options ...calculatorOption) *cart.Calculator

    { calc := calculatorOptionBuilder{ taxRates: swedishTaxRates(), discounts: nil, } for _, opt := range options { opt(&calc) } return cart.NewCalculator(calc.taxRates, calc.discounts) } type calculatorOptionBuilder struct { taxRates cart.TaxRates discounts []cart.Discounter }
  67. Refactor the tests Highlight important setup type calculatorOption func(c *calculatorOptionBuilder)

    func withTaxRates(tx cart.TaxRates) calculatorOption { return func(c *calculatorOptionBuilder) { c.taxRates = tx } } defaultCalculator( withTaxRates(swedishTaxRates()) )
  68. Refactor the tests Highlight important setup type calculatorOption func(c *calculatorOptionBuilder)

    func withTaxRates(tx cart.TaxRates) calculatorOption { return func(c *calculatorOptionBuilder) { c.taxRates = tx } } defaultCalculator( withTaxRates(swedishTaxRates()) )
  69. Refactor the tests Highlight important setup type calculatorOption func(c *calculatorOptionBuilder)

    func withTaxRates(tx cart.TaxRates) calculatorOption { return func(c *calculatorOptionBuilder) { c.taxRates = tx } } defaultCalculator( withTaxRates(swedishTaxRates()) )
  70. Refactor the tests Highlight important setup type calculatorOption func(c *calculatorOptionBuilder)

    func withTaxRates(tx cart.TaxRates) calculatorOption { return func(c *calculatorOptionBuilder) { c.taxRates = tx } } defaultCalculator( withTaxRates(swedishTaxRates()) )
  71. Refactor the tests // TODO Move to _test package Remove

    if-statement Highlight important setup • And make it changeable without forcing too many new fi elds
  72. Refactor the tests Don’t mix collaboration and business logic func

    (c *Calculator) Calculate(items []LineItem) (*Result, error) { for i := 0; i < len(items); i++ { for _, d := range c.discounts { d.Apply(&items[i]) } } var totalTaxAmount float64 var totalAmount float64 for _, li := range items { priceAmount := li.Price * float64(li.Quantity) if li.Discount.PercentageOff > 0 { priceAmount -= priceAmount * li.Discount.PercentageOff } amount, err := c.taxRates.TaxableAmount(li.TaxRate, priceAmount) if err != nil { return nil, fmt.Errorf("failed to calculate tax amount for %q: %w", li.Description, err) } totalTaxAmount += amount totalAmount += priceAmount } return &Result{ Valid: true, TotalAmount: totalAmount, TotalTaxAmount: totalTaxAmount, LineItems: items, }, nil } We’re trying to apply discounts to line items
  73. Refactor the tests Don’t mix collaboration and business logic func

    (c *Calculator) Calculate(items []LineItem) (*Result, error) { for i := 0; i < len(items); i++ { for _, d := range c.discounts { d.Apply(&items[i]) } } var totalTaxAmount float64 var totalAmount float64 for _, li := range items { priceAmount := li.Price * float64(li.Quantity) if li.Discount.PercentageOff > 0 { priceAmount -= priceAmount * li.Discount.PercentageOff } amount, err := c.taxRates.TaxableAmount(li.TaxRate, priceAmount) if err != nil { return nil, fmt.Errorf("failed to calculate tax amount for %q: %w", li.Description, err) } totalTaxAmount += amount totalAmount += priceAmount } return &Result{ Valid: true, TotalAmount: totalAmount, TotalTaxAmount: totalTaxAmount, LineItems: items, }, nil } Then we’re calculating how much tax to pay while summing
  74. Refactor the tests Don’t mix collaboration and business logic func

    (c *Calculator) Calculate(items []LineItem) (*Result, error) { for i := 0; i < len(items); i++ { for _, d := range c.discounts { d.Apply(&items[i]) } } var totalTaxAmount float64 var totalAmount float64 for _, li := range items { priceAmount := li.Price * float64(li.Quantity) if li.Discount.PercentageOff > 0 { priceAmount -= priceAmount * li.Discount.PercentageOff } amount, err := c.taxRates.TaxableAmount(li.TaxRate, priceAmount) if err != nil { return nil, fmt.Errorf("failed to calculate tax amount for %q: %w", li.Description, err) } totalTaxAmount += amount totalAmount += priceAmount } return &Result{ Valid: true, TotalAmount: totalAmount, TotalTaxAmount: totalTaxAmount, LineItems: items, }, nil } And then we return the total and the current version of the items
  75. Refactor the tests // TODO Move to _test package Remove

    if-statement Highlight important setup • And make it changeable without forcing too many new fi elds Don’t mix collaboration and business logic
  76. Refactor the tests Don’t mix collaboration and business logic Make

    LineItem calculate the total while taking discount into account Make the various tax rates into domain objects instead of numbers
  77. Refactor the tests Don’t mix collaboration and business logic /

    LineItem.TotalPrice() { description: "An item with price costs nothing", item: cart.LineItem{ Description: "Air", Quantity: 1, Price: 0, }, expected: 0, }, { description: "A single item with a price sums up to that price", item: cart.LineItem{ Description: "Overpriced Banana", Quantity: 1, Price: 1, }, expected: 1, }, { description: "An item with quantity of 2 doubles the single price", item: cart.LineItem{ Description: "Overpriced Banana", Quantity: 2, Price: 1, }, expected: 2, }, { description: "An item will apply it", item: cart.LineItem{ Description: "Ripe Banana", Quantity: 1, Price: 1, Discount: cart.Discount{ Description: "Expiring soon", PercentageOff: 0.2, }, }, expected: 0.8, },
  78. Refactor the tests Don’t mix collaboration and business logic /

    LineItem.TotalPrice() t.Run(tc.description, func(t *testing.T) { require.Equal(t, tc.expected, tc.item.TotalPrice()) }) func (i *LineItem) TotalPrice() float64 { total := i.Price * float64(i.Quantity) if i.Discount.PercentageOff > 0 { total -= total * i.Discount.PercentageOff } return total }
  79. Refactor the tests Don’t mix collaboration and business logic /

    LineItem.TotalPrice() for _, li := range items { priceAmount := li.Price * float64(li.Quantity) if li.Discount.PercentageOff > 0 { priceAmount -= priceAmount * li.Discount.PercentageOff } amount, err := c.taxRates.TaxableAmount(li.TaxRate, priceAmount) if err != nil { return nil, fmt.Errorf("failed to calculate tax amount for %q: %w", li.Description, err) } totalTaxAmount += amount totalAmount += priceAmount }
  80. Refactor the tests Don’t mix collaboration and business logic /

    LineItem.TotalPrice() for _, li := range items { priceAmount := li. amount, err := c.taxRates.TaxableAmount(li.TaxRate, priceAmount) if err != nil { return nil, fmt.Errorf("failed to calculate tax amount for %q: %w", li.Description, err) } totalTaxAmount += amount totalAmount += priceAmount }
  81. Refactor the tests Don’t mix collaboration and business logic /

    LineItem.TotalPrice() for _, li := range items { priceAmount := li.TotalPrice() amount, err := c.taxRates.TaxableAmount(li.TaxRate, priceAmount) if err != nil { return nil, fmt.Errorf("failed to calculate tax amount for %q: %w", li.Description, err) } totalTaxAmount += amount totalAmount += priceAmount }
  82. Refactor the tests Don’t mix collaboration and business logic Make

    LineItem calculate the total while taking discount into account Make the various tax rates into domain objects instead of number
  83. Refactor the tests Don’t mix collaboration and business logic /

    TaxRate amount, err := c.taxRates.TaxableAmount(li.TaxRate, priceAmount) if err != nil { return nil, fmt.Errorf("failed to calculate tax amount for %q: %w", li.Description, err) } // into amount := li.TaxableAmount()
  84. Refactor the tests Don’t mix collaboration and business logic /

    TaxRate { description: "Tax of 0 on remove returns 0", item: cart.LineItem{ Description: "Overpriced Banana", TaxRate: cart.TaxRate{Add: 0, Remove: 0}, Price: 1, }, expected: 0, }, { description: "Tax of 10% on remove returns that amount", item: cart.LineItem{ Description: "Overpriced Banana", TaxRate: cart.TaxRate{Add: 0, Remove: 0.1}, Quantity: 1, Price: 1, }, expected: 0.1, },
  85. Refactor the tests Don’t mix collaboration and business logic /

    TaxRate t.Run(tc.description, func(t *testing.T) { require.Equal(t, tc.expected, tc.item.TaxableAmount()) }) func (i *LineItem) TaxableAmount() float64 { return i.TotalPrice() * i.TaxRate.Remove }
  86. Refactor the tests Don’t mix collaboration and business logic /

    TaxRate for _, li := range items { priceAmount := li.TotalPrice() amount, err := c.taxRates.TaxableAmount(li.TaxRate, priceAmount) if err != nil { return nil, fmt.Errorf("failed to calculate tax amount for %q: %w", li.Description, err) } totalTaxAmount += amount totalAmount += priceAmount }
  87. Refactor the tests Don’t mix collaboration and business logic /

    TaxRate for _, li := range items { priceAmount := li.TotalPrice() amount totalTaxAmount += amount totalAmount += priceAmount }
  88. Refactor the tests Don’t mix collaboration and business logic /

    TaxRate for _, li := range items { priceAmount := li.TotalPrice() amount := li.TaxableAmount() totalTaxAmount += amount totalAmount += priceAmount }
  89. Refactor the tests Don’t mix collaboration and business logic /

    TaxRate for _, li := range items { totalTaxAmount += totalAmount += }
  90. Refactor the tests Don’t mix collaboration and business logic /

    TaxRate for _, li := range items { totalTaxAmount += li.TotalPrice() totalAmount += li.TaxableAmount() }
  91. Refactor the tests Don’t mix collaboration and business logic /

    TaxRate var ( taxRateFood = cart.TaxRate{Add: 0.12, Remove: 0.1071} ) []cart.LineItem{ { Description: "Overpriced Banana", Quantity: 1, TaxRate: taxRateFood, Price: 1, }, }
  92. Refactor the tests Don’t mix collaboration and business logic Make

    LineItem calculate the total while taking discount into account Make the various tax rates into domain objects instead of numbers
  93. Conclusions Constraints • Write the tests in the _test package

    • Be intentional about what’s in your public interface • No if-statements in the test body & no optional fi elds in the test setup • The tests quickly get hard to follow. Make it con fi gurable instead • Highlight the di ff erences • Make it clear exactly what is di ff erent between each case • Create helpers to show it
  94. Conclusions Constraints • Don’t mix business logic and collaboration •

    Grow your domain • A good sign that you’re mixing is that you have many test cases, keep the number of cases for a method/function small • Don’t copy and paste when writing tests • Feel the pain of typing it out and use it to fuel the discovery of better ways
  95. Conclusions Strategies • Named helpers • The if-statements were replaced

    with con fi gurable assertions, 
 even named and reused ones • Strategy pattern • Used a lot by the helpers, provide some way of providing unique code to run surrounded by your standard stu ff • Builders / object creators • Make it super simple to create your objects for the tests • Have some default ones to use which you can modify, it’ll make it much clearer over time
  96. Resources • Presentation git repo • Rob Pike: Self-referential functions

    and the design of options • Justin Searls: Please don’t mock me • A great talk that talks about the steps to make sure you don’t mix collaboration and business logic • Jay Fields: Working E ff ectively With Unit Tests • Where I picked up most of the things for how to highlight di ff erences, great exploration of builders and how to think about unit testing • GeePaw Hill: Many More Much Smaller Steps and Microtest TDD • To get some good ideas about how to think about testing and moving in smaller steps