Thursday, February 04, 2010

Day 1 of 5 Day TDD Kata: "Taxes Are Validated As They Are Added to City"

I've just completed Day 1 of the 5 Day TDD kata in about 40 minutes:

Samples posted to github here.

DAY 1: TAXES ARE VALIDATED AS THEY ARE ADDED TO CITY
  1. Tax must be created with its 3 properties: TaxType, StartDate, and EndDate, none of which can be null.
  2. StartDate must be less than EndDate
  3. Equality is based on the 3 properties together
  4. City has Taxes.
  5. City rejects duplicate Taxes (by object equality.)
  6. City rejects overlapping taxes (EndDate > other tax start date) for a given TaxType.
To achieve this:

I first created tests for Tax, which validated that it was fed all properties from the constructor, did not allow nulls, based object equality on all 3 properties, and did not allow EndDate earlier than StartDate.

I then created tests for City, which added tax objects to Taxes collection, rejected duplicates, and rejected overlapping EndDates with StartDate by tax type.

Tests and classes below.

TaxTests.cs

[TestFixture]
public class TaxTests
{
    [Test]
    [ExpectedException(typeof(TaxValuesMissingException))]
    public void TaxCannotBeCreatedWithAllNullProperties()
    {
        var tax = new Tax(null, null, null);
    }

    [Test]
    [ExpectedException(typeof(TaxValuesMissingException))]
    public void TaxCannotBeCreatedWithNullTaxType()
    {
        var tax = new Tax(null, DateTime.Today.AddDays(1), DateTime.Today.AddYears(1));
    }

    [Test]
    [ExpectedException(typeof(TaxValuesMissingException))]
    public void TaxCannotBeCreatedWithNullStartDate()
    {
        var tax = new Tax("PST", null, DateTime.Today.AddYears(1));
    }

    [Test]
    [ExpectedException(typeof(TaxValuesMissingException))]
    public void TaxCannotBeCreatedWithNullEndDate()
    {
        var tax = new Tax("PST", DateTime.Today.AddDays(1), null);
    }

    [Test]
    public void TaxCanBeCreatedWhenAllPropertiesSupplied()
    {
        var tax = new Tax("PST", DateTime.Today.AddDays(1), DateTime.Today.AddYears(1));
        Assert.IsNotNull(tax);
    }

    [Test]
    [ExpectedException(typeof(InvalidTaxDateRangeException))]
    public void TaxStartDateCannotBeGreaterThanEndDate()
    {
        var tax = new Tax("PST", DateTime.Today.AddYears(1).AddDays(1), DateTime.Today.AddYears(1));
        Assert.IsNotNull(tax);
    }

    [Test]
    public void TaxesAreEqualWhenConstructorParametersMatch()
    {
        var tax1 = new Tax("PST", DateTime.Today.AddDays(1), DateTime.Today.AddYears(1));
        var tax2 = new Tax("PST", DateTime.Today.AddDays(1), DateTime.Today.AddYears(1));

        Assert.IsTrue(tax1.Equals(tax2));
        // Assert.AreSame(tax1, tax2) failed, may be referencing another nunit namespace??
    }
}


Tax.cs

public class Tax
{
    private readonly string _taxType;
    private readonly DateTime? _startDate;
    private readonly DateTime? _endDate;

    private Tax()
    {
    }

    public Tax(string taxType, DateTime? startDate, DateTime? endDate)
    {
        _taxType = taxType;
        _startDate = startDate;
        _endDate = endDate;

        ValidateAllParametersHaveNonNullValue();

        ValidateEndDateGreaterThanStartDate();
    }

    public string TaxType
    {
        get { return _taxType; }
    }

    public DateTime? StartDate
    {
        get { return _startDate; }
    }

    public DateTime? EndDate
    {
        get { return _endDate; }
    }

    public override bool Equals(object obj)
    {
        var otherTax = (Tax) obj;

        var isEqual = otherTax.TaxType.Equals(this.TaxType)
               && otherTax.StartDate.Value.Equals(this.StartDate.Value)
               && otherTax.EndDate.Value.Equals(this.EndDate.Value);

        return isEqual;
    }

    public override int GetHashCode()
    {
        return StartDate.GetHashCode() + EndDate.GetHashCode() + TaxType.GetHashCode();
    }

    private void ValidateEndDateGreaterThanStartDate()
    {
        if (StartDate.Value > EndDate.Value)
            throw new InvalidTaxDateRangeException();
    }

    private void ValidateAllParametersHaveNonNullValue()
    {
        if (TaxType == null
            || !StartDate.HasValue
            || !EndDate.HasValue)
            throw new TaxValuesMissingException();
    }
}


CityTests.cs

[TestFixture]
public class CityTests
{
    [Test]
    public void CityCanAddTaxes()
    {
        var city = new City("Winnipeg");
        var pstTax = new Tax("PST", DateTime.Today, DateTime.Today.AddMonths(6));
        city.AddTax(pstTax);

        Assert.IsTrue(city.Taxes.Contains(pstTax));
    }

    [Test]
    [ExpectedException(typeof(DuplicateTaxesException))]
    public void CityRejectsDuplicateTaxes()
    {
        var city = new City("Winnipeg");
        var pstTax1 = new Tax("PST", DateTime.Today, DateTime.Today.AddMonths(6));
        var pstTax2 = new Tax("PST", DateTime.Today, DateTime.Today.AddMonths(6));
        city.AddTax(pstTax1);
        city.AddTax(pstTax2);
    }

    [Test]
    [ExpectedException(typeof(OverlappingTaxTypesException))]
    public void CityRejectsOverlappingTaxesPerTaxType()
    {
        var city = new City("Winnipeg");
        var pstTax1 = new Tax("PST", DateTime.Today, DateTime.Today.AddMonths(6));
        var pstTax2 = new Tax("PST", DateTime.Today.AddMonths(6), DateTime.Today.AddYears(1));
        city.AddTax(pstTax1);
        city.AddTax(pstTax2);
    }

}


City.cs

public class City
{
    private readonly string _name;

    public City(string name)
    {
        _name = name;
        Taxes = new List();
    }

    public List Taxes { get; private set; }

    public void AddTax(Tax tax)
    {
        if (tax == null) throw new ArgumentNullException("tax");
        RejectDuplicateTaxes(tax);
        RejectOverlappingTaxes(tax);

        this.Taxes.Add(tax);
    }

    private void RejectOverlappingTaxes(Tax tax)
    {
        foreach(var currowTax in this.Taxes)
        {
            if(IsFutureTax(tax, currowTax)
               && FutureTaxOverlapsEndDateOfCurrowTax(tax, currowTax))
                throw new OverlappingTaxTypesException();

            if(IsEarlierTax(tax, currowTax)
                && CurrowTaxOverlapsEndDateOfPreviousTax(tax, currowTax))
                throw new OverlappingTaxTypesException();
        }
    }

    private static bool CurrowTaxOverlapsEndDateOfPreviousTax(Tax tax, Tax currowTax)
    {
        return currowTax.TaxType.Equals(tax.TaxType)
                && currowTax.StartDate <= tax.EndDate;     }     private static bool IsEarlierTax(Tax tax, Tax currowTax)     {         return currowTax.TaxType.Equals(tax.TaxType)                 && tax.StartDate.Value < currowTax.StartDate;     }     private static bool FutureTaxOverlapsEndDateOfCurrowTax(Tax tax, Tax currowTax)     {         return currowTax.TaxType.Equals(tax.TaxType)                 && tax.StartDate <= currowTax.EndDate;     }     private static bool IsFutureTax(Tax tax, Tax currowTax)     {         return currowTax.TaxType.Equals(tax.TaxType)                 && tax.StartDate.Value > currowTax.StartDate;
    }

    private void RejectDuplicateTaxes(Tax tax)
    {
        if(this.Taxes.Contains(tax))
            throw new DuplicateTaxesException();
    }
}

No comments: