Monday, July 19, 2010

TDD Kata for DDD (building a simple domain model)

Overview
1) The goal of this TDD kata is to build a simple domain model from tests.

2) The code for the kata is posted here, on github.

3) It will be based on the user story:
"A CSR (customer service rep) can manually generate monthly charges for a customer's gym membership."

4) The following classes will be created as part of the kata.
Entities:
  • Gym
  • MonthlyPackage
  • Customer
  • Batch
  • Transaction
Value Objects:
  • Address
Note how they create three (extremely small) aggregates:
 5) Collection encapsulation:
Collections should be encapsulated in domain models so that the ability to change collections flows only out of test-driven scenarios. When creating your collections, use the following approach to encapsulate the collections: a private IList<T> field, and a public getter IEnumerable<T> property:

private readonly IList<MonthlyPackage> _monthlyPackages;

public Gym()
{
    _monthlyPackages = new List<MonthlyPackage>();
}

public IEnumerable<MonthlyPackage> MonthlyPackages
{
    get { return _monthlyPackages; }
}


6) Each time you are asked to create a test against a DIFFERENT entity, create a new test class, for example:
  • GymTests.cs to test Gym
  • CustomerTests.cs to test Customer
7) Since equality in a domain model is frequently based on an Id property (such as NHibernate might use to map to an underlying data store), we'll create an EntityBase class to store entity identity.

Setup
1) Create a new solution with two projects:
  • Tests.Unit
  • Domain
2) In the test project, add a reference to the NUnit.Framework.DLL.

3) Create your first test class, GymTests.cs, and add a using statement for NUnit.Framework.


TDD Kata for DDD, part 1
Part 1 is by far the longest part of the kata, but is mostly setup; creating classes, relationships and properties.

1) Create a test to verify that Gym is an instance of EntityBase
Note   For each test, I'll provide a code sample. The complete kata is here, on github.
[Test]
public void Constructor_NoInputParams_IsInstanceOfEntityBase()
{
    var sut = new Gym();
    Assert.IsInstanceOfType(typeof(EntityBase), sut);
}

2) Verify that two EntityBase instances are equal when both have the same int Id value.

[Test]
public void TwoInstances_SameIdProperty_AreEqual()
{
    const int idToAdd = 9135121;
    var sut1 = new EntityBase { Id = idToAdd };
    var sut2 = new EntityBase { Id = idToAdd };
    Assert.AreEqual(sut1, sut2);
}

3) Two EntityBase instances are not equal when each has a different Id value.

[Test]
public void TwoInstances_DifferentIdProperty_AreNotEqual()
{
    var sut1 = new EntityBase { Id = 3819025 };
    var sut2 = new EntityBase { Id = 82934 };
    Assert.AreNotEqual(sut1, sut2);
}

4) Two EntityBase instances are not equal when both have a 0 Id value.

[Test]
public void TwoInstances_ZeroIdProperty_AreNotEqual()
{
    const int idToAdd = 0;
    var sut1 = new EntityBase { Id = idToAdd };
    var sut2 = new EntityBase { Id = idToAdd };
    Assert.AreNotEqual(sut1, sut2);
}

5) MonthlyPackage is an instance of EntityBase.

[Test]
public void Constructor_NoInputParams_IsInstanceOfEntityBase()
{
    var sut = new MonthlyPackage();
    Assert.IsInstanceOfType(typeof(EntityBase), sut);
}

6) MonthlyPackage has a name and price.

[Test]
public void NameAndPriceProperties_Set_MatchAssignedValues()
{
    const string name = "Test Package";
    const decimal price = 35.00M;

    var sut = new MonthlyPackage {Id = 35, Name = name, Price = price};

    Assert.AreEqual(name, sut.Name);
    Assert.AreEqual(price, sut.Price);
}

7) Gym has MonthlyPackages collection.

[Test]
public void MonthlyPackagesProperty_Getter_HasCountOf0()
{
    var sut = new Gym();
    Assert.AreEqual(0, sut.MonthlyPackages.Count());
}

8) Adding a MonthlyPackage to Gym increments count.

[Test]
public void AddMonthlyPackageMethod_MonthlyPackageInput_IncrementsMonthlyPackagesCount(
{
    var sut = new Gym();
    Assert.AreEqual(0, sut.MonthlyPackages.Count());
    sut.AddMonthlyPackage(new MonthlyPackage());
    Assert.AreEqual(1, sut.MonthlyPackages.Count());
}

9) Adding the same MonthlyPackage throws an exception.

[Test]
[ExpectedException(typeof(ArgumentException), ExpectedMessage = "You cannot add a duplicate MonthlyPackage.")]
public void AddMonthlyPackageMethod_DuplicateInput_ThrowsException()
{
    var sut = new Gym();
    var monthlyPackage1 = new MonthlyPackage {Id = 1535235};
    sut.AddMonthlyPackage(monthlyPackage1);
    sut.AddMonthlyPackage(monthlyPackage1);
}

Note   This kind of test is satisfied by putting a guard condition at the beginning of the method. Remember to use "red, green, refactor" to extract each guard condition you create into a meaningfully named static method.
10) Address MUST have a non-null Street1, City, and Province, or throw an exception.

[Test]
[ExpectedException(typeof(ArgumentException), ExpectedMessage = "You must provide a non-null address.")]
public void Constructor_StreetNullWithCityAndProvince_ThrowsException()
{
    const string street = "";
    const string city = "Winnipeg";
    const string province = "MB";

    var sut = new Address(street, city, province);

}

[Test]
[ExpectedException(typeof(ArgumentException), ExpectedMessage = "You must provide a non-null city.")]
public void Constructor_StreetWithNullCityAndProvince_ThrowsException()
{
    const string street = "1234 Happy St";
    const string city = "";
    const string province = "MB";

    var sut = new Address(street, city, province);

}

[Test]
[ExpectedException(typeof(ArgumentException), ExpectedMessage = "You must provide a non-null province.")]
public void Constructor_StreetWithCityAndNullProvince_ThrowsException()
{
    const string street = "1234 Happy St";
    const string city = "Winnipeg";
    const string province = "";

    var sut = new Address(street, city, province);

}

11) Two Addresses are the same when they have the same Street1, City and Province.

[Test]
public void TwoInstances_SameConstructorInputs_AreEqual()
{
    const string street = "1234 Happy St";
    const string city = "Winnipeg";
    const string province = "MB";

    var sut1 = new Address(street, city, province);
    var sut2 = new Address(street, city, province);
    Assert.AreEqual(sut1, sut2);
}

12) Customer is an instance of EntityBase.

[Test]
public void Constructor_NoInputParams_IsInstanceOfEntityBase()
{
    var sut = new Customer();
    Assert.IsInstanceOfType(typeof(EntityBase), sut);
}

13) Customer HAS-A Address.

[Test]
public void AddressProperty_Set_AddressEqualsCustomerAddress()
{
    const string street = "1234 Happy St";
    const string city = "Winnipeg";
    const string province = "MB";

    var address = new Address(street, city, province);

    var sut = new Customer {Address = address};

    Assert.AreEqual(address, sut.Address);
}

14) Customer HAS-A MonthlyPackage.

[Test]
public void MonthlyPackageProperty_Set_PackageEqualsCustomerMonthlyPackage()
{
    var monthlyPackage = new MonthlyPackage { Id = 91351 };

    var sut = new Customer {MonthlyPackage = monthlyPackage};

    Assert.AreEqual(monthlyPackage, sut.MonthlyPackage);
}

15) Batch is an instance of EntityBase.
[Test]
public void Constructor_NoInputParams_IsInstanceOfEntityBase()
{
    var sut = new Batch();
    Assert.IsInstanceOfType(typeof(EntityBase), sut);
}

16) Transaction is an instance of EntityBase.

[Test]
public void Constructor_NoInputParams_IsInstanceOfEntityBase()
{
    var sut = new Transaction();
    Assert.IsInstanceOfType(typeof(EntityBase), sut);
}

17) Batch has Transactions.

[Test]
public void TransactionProperty_Getter_HasCountOf0()
{
    var sut = new Batch();
    Assert.AreEqual(0, sut.Transactions.Count());
}

18) Adding a Transaction to Batch increments Count.

[Test]
public void AddTransactionMethod_TransactionInput_IncrementsCount()
{
    var sut = new Batch();
    Assert.AreEqual(0, sut.Transactions.Count());
    sut.AddTransaction(new Transaction {Id = 91352, Amount = 10.01M});
    Assert.AreEqual(1, sut.Transactions.Count());
}

19) Adding the same Transaction to batch throws an Exception.

[Test]
[ExpectedException(typeof(ArgumentException), ExpectedMessage = "You cannot add duplicate transactions.")]
public void AddTransactionMethod_DuplicateInput_ThrowsException()
{
    var transaction = new Transaction { Id = 91325125, Amount = 10.01M };

    var sut = new Batch();
    sut.AddTransaction(transaction);
    sut.AddTransaction(transaction);
}

20) Adding Transaction with Amount < $10 throws an Exception.

[Test]
[ExpectedException(typeof(ArgumentException), ExpectedMessage = "A transaction charge must be at least $10.")]
public void AddTransactionMethod_AmountLessThanTenDollars_ThrowsException()
{
    var transaction = new Transaction { Id = 91325125, Amount = 9.99M };

    var sut = new Batch();
    sut.AddTransaction(transaction);
}


TDD Kata for DDD, part 2
Part 2 relies on everything you have created in the first part to create tests which exercise the user story. Once you have completed the kata, you could create additional user stories, and build tests based on the entities you have already established.

21) Customer can manually generate monthly charge with the Customer.BillForMonthlyCharge(DateTime input) method:
     a) Customer creates a Batch and adds a Transaction to it, assigning package price to Transaction's NetAmount.
     b) after running a new Batch and Transaction record have been created with Transaction.Amount = Customer.MonthlyPackage.Price.

[Test]
public void BillForMonthlyChargeMethod_CustomerPackageInput_GeneratesBatchWithTransaction()
{
    const decimal price = 12.20M;
    var monthlyPackage = new MonthlyPackage { Id = 1235, Name = "Top Fit", Price = price };
    var sut = new Customer { Id = 91352, MonthlyPackage = monthlyPackage };
    var batch = sut.BillForMonthlyCharge(DateTime.Today);
    Assert.IsTrue(batch.TransactionsContainsChargeOf(price));
}

22) Adding a manual charge for a Customer from Ontario throws an exception.
[Test]
[ExpectedException(typeof(Exception), ExpectedMessage = "A manual charge cannot be run for Ontario customers.")
public void BillForMonthlyChargeMethod_CustomerIsFromOntario_ThrowsException()
{
var monthlyPackage = new MonthlyPackage { Id = 1235, Name = "Top Fit", Price = 9.20M };
var address = new Address("1234 Happy St", "Toronto", "Ontario");
var sut = new Customer { Id = 91352, MonthlyPackage = monthlyPackage, Address = address };
sut.BillForMonthlyCharge(DateTime.Today);
}

This completes the TDD kata. However, you can see that this is an obvious jumping off point for writing additional user stories and writing tests to take the domain model further. Try writing at least 2 additional user stories and implementing them as tests.

[end]

No comments: