EXPENSE REPORT KATA

Today I was introduced to a new kata - The Expense Report!
https://github.com/christianhujer/expensereport
Being in the corporate world, sounds like a good kata to give a go.

I'll be doing it in C#, shocker, I know.

The process of the kata is laid out as follow

  1. ๐Ÿ“š Read the code to understand what it does and how it works.
  2. ๐Ÿฆจ Read the code and check for design smells.
  3. ๐Ÿง‘โ€๐Ÿ”ฌ Analyze what you would have to change to implement the new requirement without refactoring the code.
  4. ๐Ÿงช Write a characterization test. Take note of all design smells that you missed that made your life writing a test miserable.
  5. ๐Ÿ”ง Refactor the code.
  6. ๐Ÿ”ง Refactor the test.
  7. ๐Ÿ‘ผ Test-drive the new feature. (Add Lunch with an expense limit of 2000.)

The steps are not 100% clear on the first read through. Especially since we're engineers and we want to code new shit! The first chance we can squeeze that into the meaning... totally.

I haven't done this kata yet. This isn't my first time thinking about it, but you will see it "live" as I do it for the first time.

Step 1, read the code. OK; clear
Step 2, Read the code... ok... seems familiar... and check your sniffer.
Step 3, Note the things that ain't quite right.
Step 4, Write a Pinning Test. Make more notes.
Step 5, refactor
step 6, refactor
step 7 - OK, NOW you can code.

I saw most teams today start adding the new feature around step 4. The wrote a test on the unrefactored code for the new feature. The struggled. Which, good. it shows how much of a PIA this kinda code is.

Now it's my turn.

I have seen the code, and there's a bit that jumps out at me RIGHT away; but I gotta go through the process. I can't refactor until I have a test proving I'm not breaking stuff.

I've copied the class (https://github.com/christianhujer/expensereport/blob/trunk/expensereport-csharp/expensereport-csharp/Class1.cs) into my test project.

I've kinda done step 1; but I like to read by refactoring... it's 30 lines, I think I understand it. The tests will show that or not.

Now it's time to check for design smells.

Here's a marked up of the smells I see. Some duplicates I didn't mark repeatedly. There's some hard coded values I don't tag each one. HC is bad... consider it applied.

using System;
using System.Collections.Generic;

//Smell - file and namespace names
namespace expensereport_csharp
{
    //DS - Enum
    //DS - Naming Convention
    public enum ExpenseType
    {
        //DS - undeclared backing value
        DINNER, BREAKFAST, CAR_RENTAL
    }
    
    public class Expense
    {
        //DS - DTO
        //DS - Exposed fields
        //DS - money as int
        public ExpenseType type;
        public int amount;
    }

    //DS - Could be static
    public class ExpenseReport
    {
        //DS - Could be static
        public void PrintReport(List<Expense> expenses)
        {
            //DS - money as int
            int total = 0;
            int mealExpenses = 0;

            //DS - Hard coupling to specific report output
            //DS - Direct use of DateTime
            Console.WriteLine("Expenses " + DateTime.Now);

            //DS - type behavior spread over 3 sections
            foreach (Expense expense in expenses)
            {
                
                if (expense.type == ExpenseType.DINNER || expense.type == ExpenseType.BREAKFAST)
                {
                    mealExpenses += expense.amount;
                }

                String expenseName = "";
                //DS - switch
                //DS - Hard coding
                switch (expense.type)
                {
                    case ExpenseType.DINNER:
                        expenseName = "Dinner";
                        break;
                    case ExpenseType.BREAKFAST:
                        expenseName = "Breakfast";
                        break;
                    case ExpenseType.CAR_RENTAL:
                        expenseName = "Car Rental";
                        break;
                }

                //DS - Hard Coded ... lots
                String mealOverExpensesMarker =
                    expense.type == ExpenseType.DINNER && expense.amount > 5000 ||
                    expense.type == ExpenseType.BREAKFAST && expense.amount > 1000
                        ? "X"
                        : " ";

                //DS - coupled writer
                Console.WriteLine(expenseName + "\t" + expense.amount + "\t" + mealOverExpensesMarker);

                total += expense.amount;
            }

            Console.WriteLine("Meal expenses: " + mealExpenses);
            Console.WriteLine("Total expenses: " + total);
        }
    }
}

Now, to test this.

The behavior that can be seen outside this method is through the Console.WriteLine so.... we have to capture that. Quick google gives some easy answers

var stringWriter = new StringWriter();
Console.SetOut(stringWriter);

Starting simple, if I pass in an underlimit dinner of 4999 I should get

Expenses <DATE GO HERE>
Dinner\t4999\t<space>
Meal expenses: 4999
Total expenses: 4999

Let's set up that string to match... I'm lazy and don't want to do multi line regex... so I'll be checking each line.

...
Or I can do an empty list first...

Expenses <DATE GO HERE>
Meal expenses: 0
Total expenses: 0

Dirty pinning test

[TestMethod]
public void NoExpenses()
{
    // ARRANGE
    StringWriter stringWriter = new StringWriter();
    Console.SetOut(stringWriter);
    ExpenseReport subject = new();

    // ACT
    subject.PrintReport(new List<Expense>());

    //ASSERT
    string[] lines = stringWriter.ToString().Split(Environment.NewLine);
    lines[0].Should().Contain("Expenses " + DateTime.Now);
    lines[1].Should().Be("Meal expenses: 0");
    lines[2].Should().Be("Total expenses: 0");
}

And now we can do a test for dinner under budget

[TestMethod]
public void SingleDinnerUnder()
{
    // ARRANGE
    StringWriter stringWriter = new StringWriter();
    Console.SetOut(stringWriter);
    ExpenseReport subject = new();
    Expense dinner = new Expense() { amount = 5000, type = ExpenseType.DINNER };

    // ACT
    subject.PrintReport(new List<Expense>{ dinner });

    //ASSERT
    string[] lines = stringWriter.ToString().Split(Environment.NewLine);
    lines[0].Should().Contain("Expenses " + DateTime.Now);
    lines[1].Should().Be("Dinner\t5000\t ");
    lines[2].Should().Be("Meal expenses: 5000");
    lines[3].Should().Be("Total expenses: 5000");
}

and over

[TestMethod]
public void SingleDinnerOver()
{
    // ARRANGE
    StringWriter stringWriter = new StringWriter();
    Console.SetOut(stringWriter);
    ExpenseReport subject = new();
    Expense dinner = new Expense() { amount = 5001, type = ExpenseType.DINNER };

    // ACT
    subject.PrintReport(new List<Expense> { dinner });

    //ASSERT
    string[] lines = stringWriter.ToString().Split(Environment.NewLine);
    lines[0].Should().Contain("Expenses " + DateTime.Now);
    lines[1].Should().Be("Dinner\t5001\tX");
    lines[2].Should().Be("Meal expenses: 5001");
    lines[3].Should().Be("Total expenses: 5001");
}

And now to include a car rental and a breakfast. Just to get all the pieces in.

[TestMethod]
public void AllWithBreakfastOver()
{
    // ARRANGE
    StringWriter stringWriter = new StringWriter();
    Console.SetOut(stringWriter);
    ExpenseReport subject = new();
    Expense bfast = new Expense() { amount = 1001, type = ExpenseType.BREAKFAST };
    Expense dinner = new Expense() { amount = 5000, type = ExpenseType.DINNER };
    Expense car = new Expense() { amount = 2000, type = ExpenseType.CAR_RENTAL };

    // ACT
    subject.PrintReport(new List<Expense> { dinner, bfast, car });

    //ASSERT
    string[] lines = stringWriter.ToString().Split(Environment.NewLine);
    lines[0].Should().Contain("Expenses " + DateTime.Now);
    lines[1].Should().Be("Dinner\t5000\t ");
    lines[2].Should().Be("Breakfast\t1001\tX");
    lines[3].Should().Be("Car Rental\t2000\t ");
    lines[4].Should().Be("Meal expenses: 6001");
    lines[5].Should().Be("Total expenses: 8001");
}

I could have done all of this as a single test. The last one is really all we need to pin the behavior. I like working up to it.

More pinning tests are hard to argue against.

But... OK... I have my test coverage.

Onto the smells!

I kinda skipped step 3... whoops... all excited to write some tests...

Let's look at the changes to add the lunch to the existing code.

foreach (Expense expense in expenses)
{
    if (expense.type == ExpenseType.DINNER || expense.type == ExpenseType.BREAKFAST)
    {
        mealExpenses += expense.amount;
    }

    String expenseName = "";
    switch (expense.type)
    {
        case ExpenseType.DINNER:
            expenseName = "Dinner";
            break;
        case ExpenseType.BREAKFAST:
            expenseName = "Breakfast";
            break;
        case ExpenseType.CAR_RENTAL:
            expenseName = "Car Rental";
            break;
    }

    String mealOverExpensesMarker =
        expense.type == ExpenseType.DINNER && expense.amount > 5000 ||
        expense.type == ExpenseType.BREAKFAST && expense.amount > 1000
            ? "X"
            : " ";

    Console.WriteLine(expenseName + "\t" + expense.amount + "\t" + mealOverExpensesMarker);

    total += expense.amount;
}

This is really the only bit we care about for behavior. We'll have to add a new enum value, but that'll show up

foreach (Expense expense in expenses)
{
    if (expense.type == ExpenseType.DINNER || expense.type == ExpenseType.BREAKFAST || expense.type == ExpenseType.LUNCH)
    {
        mealExpenses += expense.amount;
    }

    String expenseName = "";
    switch (expense.type)
    {
        case ExpenseType.DINNER:
            expenseName = "Dinner";
            break;
        case ExpenseType.BREAKFAST:
            expenseName = "Breakfast";
            break;
        case ExpenseType.LUNCH:     // NEW CODE
            expenseName = "Lunch";  // NEW CODE
            break;
        case ExpenseType.CAR_RENTAL:
            expenseName = "Car Rental";
            break;
    }

    String mealOverExpensesMarker =
        expense.type == ExpenseType.DINNER && expense.amount > 5000 ||
        expense.type == ExpenseType.BREAKFAST && expense.amount > 1000 ||
        expense.type == ExpenseType.LUNCH && expense.amount > 2000
            ? "X"
            : " ";

    Console.WriteLine(expenseName + "\t" + expense.amount + "\t" + mealOverExpensesMarker);

    total += expense.amount;
}

It's pretty straight forward to update the code for lunch. It's not the right thing to do though.

What we want to do, with pinning tests in place, is refactor.

The first thing I'm gonna do is to create a ReportScribe... Thanks Jim... it's stuck in my head now... that will handle the actual report generation.

internal sealed class ConsoleReportScribe : IReportScribe
{
    public void WriteLine(string msg)
    {
        Console.WriteLine(msg);
    }
}

internal interface IReportScribe
{
    void WriteLine(string msg);
}

This is really simple, but I can later provide a fake and not have to hack the Console.SetOut

Using my constructor chain dependency inversion, we can add it to the class and then replace the Console with _reportScribe

private readonly IReportScribe _reportScribe;

public ExpenseReport():this(new ConsoleReportScribe()) {}

private ExpenseReport(IReportScribe reportScribe) => _reportScribe = reportScribe;

This is one reason I try to match APIs as exact as I can in the first round of encapsulation/extraction. I can update the code easy.

I really want nCrunch on my computer... I bought it 2 years ago... restate to, I renewed 2 years ago... probably time to do it again. BRB...

...

OK, upgraded license and installed.
Now nCnruch will handle running the tests and I just need to refactor! It's a B-E-A-U-TI-FUL thing.

Once the report scribe applied, nCrunch is green... so I know I'm safe.

The next thing I want to do is move all of the "TYPE" behavior into the switch statement.
which looks like

switch (expense.type)
{
    case ExpenseType.DINNER:
        expenseName = "Dinner";
        mealExpenses += expense.amount;
        if (expense.amount > 5000) mealOverExpensesMarker = "X";
        break;
    case ExpenseType.BREAKFAST:
        expenseName = "Breakfast";
        mealExpenses += expense.amount;
        if (expense.amount > 1000) mealOverExpensesMarker = "X";
        break;
    case ExpenseType.CAR_RENTAL:
        expenseName = "Car Rental";
        break;
}

This sets us up pretty good for the next step of the refactor.
Here's my new DinnerExpense class

internal sealed class DinnerExpense
{
    private readonly Expense _expense;

    public DinnerExpense(Expense expense) => _expense = expense;

    public string DisplayName() => "Dinner";
    public int Amount() => _expense.amount;
    public bool IsOverLimit() => 5000 < _expense.amount;
}

I'll hack this in to make sure it does what I want... and then can create the other versions.
I'm just gonna drop it into the case statement

case ExpenseType.DINNER:
    DinnerExpense dinnerExpense = new(expense);
    expenseName = dinnerExpense.DisplayName();
    mealExpenses += dinnerExpense.Amount();
    if (dinnerExpense.IsOverLimit()) mealOverExpensesMarker = "X";
    break;

Everything still works... sooo... Yeah; let's do the other couple classes.

Dark and Stormy days pass.... OK, they were sunny and bright, but still days

I did the other classes and... ehhhh... don't like it. Somethings wrong... which is why I didn't look at it for days.

One thing I've found very useful is my "code nose". It's something we have to train to find the incorrectness in our code, their smell.
I can smell a lot of things in code, but sometimes... it's just "not right". There's an itch in my head that screams about something... there's SOMETHING.... and I gotta walk away before the code punishes me.
If something in the code isn't lined up with my thinking, it can cause me physical pain.
Working with a colleague on a system he wrote and my understanding was absent something. I could tell where I was fuzzy because I couldn't "think through" that area.
He said something that was SO disjointed from my understanding, it gave me a headache. Helped crystalize exactly where the lack of clarity was... but OW. It hurt.
Oh well... that got solved just like this will.

I had most of my tests failing because I forgot to add the meal expense in... but to make those pass, I have to add the meal expense in the switch statement.

switch (expense.type)
{
    case ExpenseType.DINNER:
        smartExpense = new DinnerExpense(expense);
        mealExpenses += smartExpense.Amount();
        break;
    case ExpenseType.BREAKFAST:
        smartExpense = new BreakfastExpense(expense);
        mealExpenses += smartExpense.Amount();
        break;
    case ExpenseType.CAR_RENTAL:
        smartExpense = new CarRental(expense);
        break;
}

I'm gonna blame it being midnight last time (looks at the current 10:30pm time...) that I didn't see it right away.

The itch, the problem I see here is that there's no smooth way to evolve the code into the form I want. Much like creating the class... there's another giant leap.
The summation must be in the class.

I don't think a bunch of small shifts are gonna get me there. Not easily. They'll muddy the water and tie up options.

It's gonna be a hard swap.

The issue is that there are mutliple types of totals. These totals need to be reflected by something. I'm gonna create a new "AggregateType" to .... ya know what... I don't know exactly yet. But I have an idea.
A type to represent the type of totaling happeing, a collection of aggregates for each type. The Expense classes know what they aggregate to, and can provide that info, with the amount, to the Total-er. Fuck, damn -ers.

I'm gonna go make that happen, and I'll share when I'm done.

Here's the ExpenseReport#PrintReport for method currently

public void PrintReport(List<Expense> expenses)
{
    _reportScribe.WriteLine("Expenses " + DateTime.Now);
    
    foreach (Expense expense in expenses)
    {
        ISmartExpense smartExpense = new FlexibleSmartExpense(expense);
        smartExpense.AddToTotal(_visitorTotal);
        smartExpense.ReportTo(_reportScribe);
    }

    _visitorTotal.ReportTo(_reportScribe);
}

The FlexivleSmartExpense is a little rough, but I'm not doing a chain or pulling in my Cache type

internal sealed class FlexibleSmartExpense: ISmartExpense
{
    private readonly Expense _expense;
    private ISmartExpense? _cache;

    public FlexibleSmartExpense(Expense expense) => _expense = expense;

    public void AddToTotal(VisitorTotal visitor) => Cached().AddToTotal(visitor);

    public void ReportTo(IReportScribe reportScribe) => Cached().ReportTo(reportScribe);

    private ISmartExpense Cached()
    {
        if (_cache is not null) return _cache;

        return _cache = ToCache();
    }

    private ISmartExpense ToCache()
    {
        return _expense.type switch
        {
            ExpenseType.DINNER => new DinnerExpense(_expense),
            ExpenseType.BREAKFAST => new BreakfastExpense(_expense),
            ExpenseType.CAR_RENTAL => new CarRental(_expense),
            _ => throw new NotImplementedException("unknown tye")
        };
    }
}

internal abstract class SmartExpense:ISmartExpense
{
    private readonly TotalType _totalType;
    private readonly Expense _expense;
    private readonly string _name;
    private readonly int _maxAllowed;

    protected SmartExpense(TotalType totalType, Expense expense, string name, int maxAllowed)
    {
        _totalType = totalType;
        _expense = expense;
        _name = name;
        _maxAllowed = maxAllowed;
    }
    public void AddToTotal(VisitorTotal visitor) => visitor.Add(_totalType, _expense.amount);

    public void ReportTo(IReportScribe reportScribe) => reportScribe.WriteLine(_name + "\t" + _expense.amount + "\t" + OverLimitMarker());
    private bool IsOverLimit() => _maxAllowed < _expense.amount;
    private string OverLimitMarker() => IsOverLimit() ? "X" : " ";
}

internal abstract class MealExpense : SmartExpense
{
    protected MealExpense(Expense expense, string name, int maxAllowed) : base(TotalType.Meal, expense, name, maxAllowed)
    {
    }
}

internal abstract class TravelExpense : SmartExpense
{
    protected TravelExpense(Expense expense, string name) : base(TotalType.Travel, expense, name, int.MaxValue)
    {
    }
}

The total aggregation and reporting is done here

internal abstract class Total : ITotal
{
    private readonly string _tag;
    private int _amount;

    protected Total(string tag) : this(tag, 0)
    {
    }

    private Total(string tag, int amount)
    {
        _tag = tag;
        _amount = amount;
    }
    public virtual void Add(TotalType type, int amount)
    {
        _amount += amount;
    }

    public void ReportTo(IReportScribe reportScribe)
    {
        reportScribe.WriteLine($"{_tag} expenses: {_amount}");
    }
}

internal sealed class MealTotal : Total
{
    public MealTotal() : base("Meal")
    { }

    public override void Add(TotalType type, int amount)
    {
        if (!TotalType.Meal.Equals(type)) return;
        base.Add(type, amount);
    }
}

internal sealed class TotalTotal : Total
{
    public TotalTotal() : base("Total")
    { }
}

internal sealed class VisitorTotal : ITotal
{
    private readonly List<ITotal> _totals;

    public VisitorTotal() : this(new List<ITotal> { new MealTotal(), new TotalTotal() })
    { }

    private VisitorTotal(List<ITotal> totals) => _totals = totals;

    public void Add(TotalType type, int amount)
    {
        _totals.ForEach(x => x.Add(type, amount));
    }

    public void ReportTo(IReportScribe reportScribe)
    {
        _totals.ForEach(x => x.ReportTo(reportScribe));
    }
}

internal enum TotalType
{
    Total = 0,
    Meal = 1,
    Travel = 2
}

Going all the way back to the FlexibleSmartExpense - I may not need this at the end.
Once this code is cleaned up... I can refactor out the Expense class, and have the tests provide the DinnerExpense directly.
No need to translate.

Tests are still all passing.

To support the migration to passing in ISmartExpense objects, we create an overload and invoke it from the original

public void PrintReport(List<Expense> expenses)
{
    PrintReport(expenses.Select(expense => new FlexibleSmartExpense(expense)));
}

public void PrintReport(IEnumerable<ISmartExpense> expenses)
{
    //DS - Direct use of DateTime
    _reportScribe.WriteLine("Expenses " + DateTime.Now);
    
    foreach (ISmartExpense expense in expenses)
    {
        expense.AddToTotal(_visitorTotal);
        expense.ReportTo(_reportScribe);
    }

    _visitorTotal.ReportTo(_reportScribe);
}

This has been most of step 5: ๐Ÿ”ง Refactor the code.

Since my plan is for the FlexibleSmartExpense to go away... and I've split the logic from the conversion; I'm just gonna pull the converting function up.

public void PrintReport(List<Expense> expenses)
{
    PrintReport(expenses.Select(Convert));
}
private ISmartExpense Convert(Expense expense)
{
    return expense.type switch
    {
        ExpenseType.DINNER => new DinnerExpense(expense),
        ExpenseType.BREAKFAST => new BreakfastExpense(expense),
        ExpenseType.CAR_RENTAL => new CarRental(expense),
        _ => throw new NotImplementedException("unknown tye")
    };
}

This will allow me a simpler cleanup after I refactor the tests.

Since I should sleep... Gonna go do that before I knock the tests to the new form.

Then it'll be easy peasy to TDD the new Lunch class.
It'll look like

internal sealed class DinnerExpense : MealExpense
{
    public DinnerExpense(Expense expense) : base(expense, "Dinner", 5000)
    { }
}

Whoops, copy and pasted Dinner... Here's what lunch will look like

internal sealed class LunchExpense : MealExpense
{
    public LunchExpense(Expense expense) : base(expense, "Lunch", 2000)
    { }
}

Pretty sure that won't be hard to generate through TDD. Then... Done.
ZERO modification of existing functionality.

I could turn the MealExpense into a sealed class and do composition if I really wanted to.

Let's wait to the end and see what it looks like.

After I do the sleeping.


I did the sleeping.
Maybe twice... I didn't count.

I need to update my tests to use the expense objects, not Expense. Which shows me I missed something...

These take an Expense. I don't want that. I want them to take the amount. That's it.
I'll add that constructor. Which... currently... will require an internal generation of the Expense object; which is a good temporary step.

public DinnerExpense(int amount):this(new Expense(){amount = amount, type = ExpenseType.DINNER}){}

And similar for the other types.
... Why didn't anyone tell me I had CarRental instead of CarRentalExpense? This is what I depend on from my mob... oh... right... solo code... D'OH!

All tests now provide the ISmartExpense objects, and the Expense param method can go away.

public class ExpenseReport
{
    private readonly IReportScribe _reportScribe;
    private readonly VisitorTotal _visitorTotal;

    public ExpenseReport() : this(new ConsoleReportScribe(), new VisitorTotal())
    { }

    private ExpenseReport(IReportScribe reportScribe, VisitorTotal visitorTotal)
    {
        _reportScribe = reportScribe;
        _visitorTotal = visitorTotal;
    }

    public void PrintReport(IEnumerable<ISmartExpense> expenses)
    {
        //DS - Direct use of DateTime
        _reportScribe.WriteLine("Expenses " + DateTime.Now);
        
        foreach (ISmartExpense expense in expenses)
        {
            expense.AddToTotal(_visitorTotal);
            expense.ReportTo(_reportScribe);
        }

        _visitorTotal.ReportTo(_reportScribe);
    }
}

I noticed the tests don't fake the IReportScribe. I'm gonna make that ctor public for testing for now so I can stop hi-jacking the Console.

Easy-peasy

FakeReportScribe fakeReportScribe = new();
ExpenseReport subject = new(fakeReportScribe, new VisitorTotal());

I am not faking the VisitorTotal as that holds some of the behavior needed.

And now to remove Expense from the ISmartExpense classes.
And by remove, it's mostly change to an int.

And I can remove both Expense and ExpenseType.

I think, right now; for a refactor, it's in a solid state. There are WAY to many hard coded values, and DateTime is still used... there's stuff that I don't like. And I have plans to clean it up... later.

For this post, I think it's time to get Lunch.

Of my lunch object, what's it do? What does dinner do?

internal sealed class DinnerExpense : MealExpense
{
    public DinnerExpense(int amount):base(amount, "Dinner", 5000)
    { }
}

It sets values.

I, personally, use reflection to pull the set values out of the base class and validate them. I don't want to parse behavior/output to know if DinnerExpense is passing in the expected values.

But, I can get everything out of the ReportTo method. For the purposes of this blog, I'll use that base class behavior. The line it writes is one of the things I want to see changed. I know this test is a bit fragile for future purposes... but ... it's ok... for now.

[TestMethod]
public void LunchShouldExist()
{
    new LunchExpense();
}

Fuck yeah I am!

[TestMethod]
public void LunchShouldExpendMealExpense()
{
    MealExpense subject = new LunchExpense();
}

A test to drive the code!
I'll often do the second test as these combined, as ReSharper is MUCH better at making it extend it for me when I do that.
The first test also goes away now.

Adding the constructor

    public class LunchExpense: MealExpense
    {   
    public LunchExpense(int amount) : base(amount, null, 0)
        {
        }
    }

means I gotta get some functionality in place.

Could I do more new tests... yes. Will I... No.
There's gonna be one test.

OK, there will be 2. I have to make sure the max is set through inference, I can't check the set value directly.

[TestMethod]
public void LunchShouldSetExpectedValuesOverMax(){
    //ARRANGE
    FakeReportScribe fakeReportScribe = new();
    MealExpense subject = new LunchExpense(2001);

    //ACT
    subject.ReportTo(fakeReportScribe);

    //ASSERT
    fakeReportScribe.Lines.First().Should().Be("Lunch\t2001\tX");
}
[TestMethod]
public void LunchShouldSetExpectedValuesAtMax(){
    //ARRANGE
    FakeReportScribe fakeReportScribe = new();
    MealExpense subject = new LunchExpense(2000);

    //ACT
    subject.ReportTo(fakeReportScribe);

    //ASSERT
    fakeReportScribe.Lines.First().Should().Be("Lunch\t2000\t ");
}

And a final test showing the full report behavior also includes Lunch

[TestMethod]
public void AllIncludingLunch()
{
    // ARRANGE
    FakeReportScribe fakeReportScribe = new();
    ExpenseReport subject = new(fakeReportScribe, new VisitorTotal());
    BreakfastExpense breakfastExpense = new(1001);
    DinnerExpense dinnerExpense = new(5000);
    LunchExpense lunchExpense = new(2001);
    CarRentalExpense carRentalExpense = new(2000);

    // ACT
    subject.PrintReport(new List<ISmartExpense> { dinnerExpense, breakfastExpense, carRentalExpense, lunchExpense });

    //ASSERT
    string[] lines = fakeReportScribe.Lines.ToArray();
    lines[0].Should().Contain("Expenses " + DateTime.Now);
    lines[1].Should().Be("Dinner\t5000\t ");
    lines[2].Should().Be("Breakfast\t1001\tX");
    lines[3].Should().Be("Car Rental\t2000\t ");
    lines[4].Should().Be("Lunch\t2001\tX");
    lines[5].Should().Be("Meal expenses: 8002");
    lines[6].Should().Be("Total expenses: 10002");
}

This is on GitHub Here: https://github.com/Fyzxs/ExpenseReportKata

I didn't do well on sensible commits. Such is the life of code and blog.

There's some clean up I'd like to do... but isn't there always...

Show Comments