TDD with C#: Bank Kata
Instructions
From: https://www.codurance.com/katas/bank
Write a class named Account that implements the following public interface:
public interface AccountService { void deposit(int amount) void withdraw(int amount) void printStatement() }
Rules
You cannot change the public interface of this class.
Desired Behaviour
Here’s the specification for an acceptance test that expresses the desired behaviour for this
Given a client makes a deposit of 1000 on 10–01–2012
And a deposit of 2000 on 13–01–2012
And a withdrawal of 500 on 14–01–2012
When they print their bank statement
Then they would see:
Date || Amount || Balance 14/01/2012 || -500 || 2500 13/01/2012 || 2000 || 3000 10/01/2012 || 1000 || 1000
Empezamos…
Lo primero que tenemos que tener en cuenta al realizar esta kata, es ver el test de aceptación. Nos cuenta dos cosas desde las que empezar a trabajar:
La interfaz de Account es inmutable y todos sus métodos siguen el principio Tell, don’t ask, así que ninguno devolverá nada.
Tras realizar una serie de operaciones, el output ha de ser el siguiente:
Date || Amount || Balance 14/01/2012 || -500 || 2500 13/01/2012 || 2000 || 3000 10/01/2012 || 1000 || 1000
Bien, con estas dos cosas ya podemos empezar a trabajar en nuestro test de aceptación. Creamos dos proyectos, uno para nuestro código y otro para los test y empezamos a trabajar en este último.
Creamos nuestro test de aceptación
A decir verdad, hay varias maneras de encarar esta kata, sin embargo, una muy eficaz es escribir primero el test de aceptación y que este defina claramente los requerimientos de la kata.
[Test] public void CorrectlySetTransaction() { var account = new Account(); account.Deposit(1000); account.Deposit(2000); account.Withdraw(500); account.PrintStatement(); Received.InOrder(() => { testConsole.Received().PrintLine("date || credit || debit || balance"); testConsole.Received().PrintLine("14/01/2012 || || 500.00 || 2500.00"); testConsole.Received().PrintLine("13/01/2012 || 2000.00 || || 3000.00"); testConsole.Received().PrintLine("10/01/2012 || 1000.00 || || 1000.00"); }); }
Yo he utilizado NSubstitute para mockear las funciones que hacen falta y esperar que la consola (dado que el requerimiento es que el printStatement no devuelva nada entiendo que es por consola) reciba las líneas definidas en las instrucciones en orden.
De paso podemos crear ya la clase Account que herede de la interfaz definida pero con todo lanzando la excepción de NotImplemented como recordatorio continuo de que partes están sin hacer.
public class Account : AccountService { public Account() { } public void Deposit(int amount) { throw new NotImplementedException(); } public void Withdraw(int amount) { throw new NotImplementedException(); } public void PrintStatement() { throw new NotImplementedException(); }
Métodos de Deposit y Withdraw
A la hora de crear test para nuestros test de Deposit y Withdraw tenemos que pensar en lo que definen las instrucciones: no deben devolver nada. Y sin embargo printStatement tiene que saber las operaciones que se han realizado para luego imprimirlas.
Con esto en mente, se ve claro que es necesario una suerte de repositorio que guarde las operaciones y registre el balance de nuestra cuenta.
Podemos proceder a implementar tests para Deposit con ello en mente:
public void BalanceIncreasesWhenMakingADeposit(int initialBalance, int amountToDeposit, int expectedBalance) { Account account = new Account(balance); account.Deposit(amountToDeposit); Assert.AreEqual(expectedBalance, account.Balance); }
public void BalanceDecreasesWhenMakingAWithdraw(int initialBalance, int amountToWithdraw, int expectedBalance) { Account account = new Account(balance); account.Withdraw(amountToWithdraw); Assert.AreEqual(expectedBalance, account.Balance); }
Al lanzar estos tests tendremos el resultado esperado: rojo, no está implementado. Vamos a solucionar esto, volvemos a la clase Account y vemos como implementar estos dos métodos teniendo en cuenta que hemos de crear un mecanismo para guardar las operaciones.
Añadimos a la clase Account nuestro repositorio:
private TransactionRepository transactionRepository; public Account(TransactionRepository transactionRepositoryInstance) { transactionRepository = transactionRepositoryInstance; }
Antes de proceder a implementarlo, el primer test asociado a nuestro repositorio es que efectivamente una transacción se registre correctamente.
[Test] public void AddATransactionReturnsATransaction() { var transactionRepository = new TransactionRepository(); transactionRepository.AddTransaction(400); Assert.That(1, Is.EqualTo(transactionRepository.GetTransactions().Count)); }
Implementamos nuestro repositorio siguiendo dicho test:
public class TransactionRepository { public int Balance {get; private set; } private List<Transaction> listTransactions; public TransactionRepository() { listTransactions = new List<Transaction>(); } public void AddTransaction(int amount) { listTransactions.Add(new Transaction(amount)); Balance += amount; } public List<Transaction> GetTransactions() { return listTransactions; } }
Y también el objeto transaction donde guardaremos los datos de la transacción, de momento, solo el amount.
public class Transaction { public int Amount { get; private set; } public Transaction(int amount) { Amount = amount; }
}
Ahora el test del repositorio esta en verde y podemos poner en verde también el de Deposit y el de Withdraw:
public void Deposit(int amount) { transactionRepository.AddTransaction(amount); Balance = transactionRepository.Balance; } public void Withdraw(int amount) { transactionRepository.AddTransaction(-amount); Balance = transactionRepository.Balance; }
Estamos en verde, nuestro deposit y withdraw funcionan como queremos y tenemos un registro de dichas operaciones en nuestra cuenta. ¡Sigamos con el printStatement!
Método de PrintStatement
El método printStatement es el más sencillo de todos pero quizá también el que nos da más pistas a la hora de implementarse. Hay varias cosas a tener en cuenta:
No puede devolver nada, por lo que recurriremos a la consola para hacer el trabajo.
Ha de registrar el tiempo de la transacción, por lo que tendremos que recurrir también a la clase Clock y que nuestro repositorio de transactions lo registre.
Ambos elementos deben ser fácilmente “sustituibles” en nuestro test de aceptación, por lo que siguiendo la lógica de .NET podemos crear interfaces para ambos que luego sustituiremos con NSubstitute.
Y eso es todo! Primero ampliaremos con un pequeño refactor pues el test de aceptación con los elementos identificados:
public class PrintStatementTest { private readonly IConsole testConsole = Substitute.For<IConsole>(); private readonly IClock testClock = Substitute.For<IClock>(); [Test] public void CorrectlySetTransaction() { var account = new Account(new TransactionRepository(), testClock, testConsole); testClock.Now().Returns(new DateTime(2012, 01, 10)); account.Deposit(1000); testClock.Now().Returns(new DateTime(2012, 01, 13)); account.Deposit(2000); testClock.Now().Returns(new DateTime(2012, 01, 14)); account.Withdraw(500); account.PrintStatement(); Received.InOrder(() => { testConsole.Received().PrintLine("date || credit || debit || balance"); testConsole.Received().PrintLine("14/01/2012 || || 500.00 || 2500.00"); testConsole.Received().PrintLine("13/01/2012 || 2000.00 || || 3000.00"); testConsole.Received().PrintLine("10/01/2012 || 1000.00 || || 1000.00"); }); } }
Implementamos sendas interfaces de Clock y Console para que el test de aceptación pueda sustituirlas:
public interface IConsole { void PrintLine(string output); }
public interface IClock { public DateTime Now(); }
Esto nos obliga a añadir el clock y la consola a nuestra account:
public class Account { private IConsole console; private IClock clock; private TransactionRepository transactionRepository; public Account(TransactionRepository transactionRepositoryInstance, IClock clockInstance, IConsole consoleInstance) { console = consoleInstance; clock = clockInstance; transactionRepository = transactionRepositoryInstance; } }
Y de esta manera, también a añadir el clock a las operaciones de transaction y al repository:
public void Deposit(int amount) { transactionRepository.AddTransaction(amount, clock.Now()); } public void Withdraw(int amount) { transactionRepository.AddTransaction(-amount, clock.Now()); }
public void AddTransaction(int amount, DateTime time) { listTransactions.Add(new Transaction(amount, time)); }
La implementación de ambas será sencilla, simplemente llamaremos a las funciones del sistema:
public class Clock : IClock { public DateTime Now() { return DateTime.Now; } }
public class Console: IConsole { public void PrintLine(string output) { Console.WriteLine(output); } }
Ahora, observando el test de aceptación, observamos que necesitamos que el printStatement imprima ya formateadas las transacciones que se han realizado. Creamos un test para ello en nuestra suite del transactionRepository:
[Test] public void CorrectlyFormattedTransactions() { TransactionRepository transactionRepository = new TransactionRepository(); transactionRepository.AddTransaction(1000, new DateTime(2012, 01, 10)); var formattedTransactions = transactionRepository.FormatTransactions(); formattedTransactions.Contains("10/01/2012 || 1000.00 || || 1000.00"); }
El código podría lucir tal que así:
public List<string> FormatTransactions() { int balance = 0; List<string> formattedTransactions = new List<string>(); foreach (var transaction in listTransactions) { balance += transaction.Amount; formattedTransactions.Add($"{transaction.Format()} || {balance}.00"); } formattedTransactions.Reverse(); return formattedTransactions; }
Ya solo nos queda añadir el código al printStatement y la kata estaría completa con el test de aceptación en verde:
public void PrintStatement() { console.PrintLine("date || credit || debit || balance"); var transactions = transactionRepository.FormatTransactions(); foreach(var transaction in transactions) { console.PrintLine(transaction); } }
Conclusión
Indudablemente la resolución de esta kata tiene muchas mejoras en su diseño y siempre y cuando se apliquen los criterios de TDD y SOLID tendremos una buena solución.
El código para toda la kata lo podéis encontrar en mi github: