So, where do we have mutable types?
public class Bank {
...
public void AddExchangeRate(Currency from, Currency to, double rate)
=> _exchangeRates[KeyFor(from, to)] = rate;
...
}
public class Portfolio {
...
public void Add(Money money) => this.moneys.Add(money);
...
}
By making it immutable, we want to have a method AddExchangeRate
with a signature like this Currency
-> Currency
-> double
-> Bank
.
🔴 Let's start by adapting a test to make it red
wih a compilation error.
🟢 Make it green
again.
public Bank AddExchangeRate(Currency from, Currency to, double rate)
{
var updatedRates = this._exchangeRates;
var key = KeyFor(from, to);
updatedRates.TryAdd(key, default);
updatedRates[key] = rate;
return new Bank(updatedRates);
}
🔵 Now we can refactor and simplify our test.
[Fact(DisplayName = "Conversion with different exchange rates EUR -> USD")]
public void ConvertWithDifferentExchangeRates()
{
_bank.Convert(new Money(10, EUR), USD)
.Should()
.Be(new Money(12, USD));
_bank.AddExchangeRate(EUR, USD, 1.3)
.Convert(new Money(10, EUR), USD)
.Should()
.Be(new Money(13, USD));
}
Still, ExchangeRates
can be mutated accidentally from within the Bank
class.
🔵 We can address this point by using ReadOnlyDictionary
from the private constructor.
public class Bank {
private readonly ReadOnlyDictionary<string, double> _exchangeRates;
private Bank(IDictionary<string, double> exchangeRates) => _exchangeRates = new ReadOnlyDictionary<string, double>(exchangeRates);
public static Bank WithExchangeRate(Currency from, Currency to, double rate) =>
new Bank(new Dictionary<string, double>()).AddExchangeRate(from, to, rate);
public Bank AddExchangeRate(Currency from, Currency to, double rate)
{
var updatedRates = new Dictionary<string, double>(this._exchangeRates);
var key = KeyFor(from, to);
updatedRates.TryAdd(key, default);
updatedRates[key] = rate;
return new Bank(updatedRates);
}
}
public class PortfolioTest {
private readonly Bank bank;
public PortfolioTest()
{
this.bank = Bank
.WithExchangeRate(Currency.EUR, Currency.USD, 1.2)
.AddExchangeRate(Currency.USD, Currency.KRW, 1100);
}
...
}
We have to adapt the Add
method too but, it is used in a lot of places.
We'll have to use a different refactoring strategy for this.
Let's use the Wrap Technique like we did already.
🔵 Rename the existing method to AddOld
.
public void AddOld(Money money) => this.moneys.Add(money);
🔴 Adapt a test to have a red test that will call the new method.
🟢 Generate the new Add
method from test and call the old method from the new one to guarantee the same behavior.
public Portfolio Add(Money money)
{
this.AddOld(money);
return this;
}
🔵 We are now able to refactor this.
- We add a private constructor
- And define a public one as well
public class Portfolio {
private readonly ICollection<Money> moneys;
public Portfolio()
{
this.moneys = new List<Money>();
}
private Portfolio(IEnumerable<Money> moneys)
{
this.moneys = moneys.ToImmutableList();
}
public Portfolio Add(Money money)
{
List<Money> updatedMoneys = this.moneys.ToList();
updatedMoneys.Add(money);
return new Portfolio(updatedMoneys);
}
...
}
🟢 Our test is now green again.
🔵 Let's refactor our tests to make it simple to instantiate Portfolio
. One solution is to encapsulate Portfolio
instantiation in a dedicated method taking Moneys as args
.
[Fact(DisplayName = "5 USD + 10 USD = 15 USD")]
public void Add_ShouldAddMoneyInTheSameCurrency()
{
var portfolio = PortfolioWith(
new Money(5, Currency.USD),
new Money(10, Currency.USD));
portfolio.Evaluate(bank, Currency.USD).Should().Be(new Money(15, Currency.USD));
}
private static Portfolio PortfolioWith(params Money[] moneys) =>
moneys.Aggregate(new Portfolio(), (portfolio, money) => portfolio.Add(money));
Let's plug remaining tests to the new Add
method.
[Fact(DisplayName = "5 USD + 10 EUR = 17 USD")]
public void Add_ShouldAddMoneyInDollarAndEuro() =>
PortfolioWith(new Money(5, Currency.USD), new Money(10, Currency.EUR))
.Evaluate(this.bank, Currency.USD)
.Should()
.Be(new Money(17, Currency.USD));
[Fact(DisplayName = "1 USD + 1100 KRW = 2200 KRW")]
public void Add_ShouldAddMoneyInDollarAndKoreanWons() =>
PortfolioWith(new Money(1, Currency.USD), new Money(1100, Currency.KRW))
.Evaluate(this.bank, Currency.KRW)
.Should()
.Be(new Money(2200, Currency.KRW));
[Fact(DisplayName = "5 USD + 10 EUR + 4 EUR = 21.8 USD")]
public void Add_ShouldAddMoneyInDollarsAndMultipleAmountInEuros() =>
PortfolioWith(new Money(5, Currency.USD), new Money(10, Currency.EUR), new Money(4, Currency.EUR))
.Evaluate(bank, Currency.USD)
.Should()
.Be(new Money(21.8, Currency.USD));
[Fact(DisplayName = "Throws a MissingExchangeRatesException in case of missing exchange rates")]
public void Add_ShouldThrowAMissingExchangeRatesException()
{
Action act = () => PortfolioWith(new Money(1, Currency.EUR), new Money(1, Currency.USD), new Money(1, Currency.KRW))
.Evaluate(this.bank, Currency.EUR);
act.Should().Throw<MissingExchangeRatesException>()
.WithMessage("Missing exchange rate(s): [USD->EUR],[KRW->EUR]");
}
[Fact(DisplayName = "5 USD + 10 USD = 15 USD")]
public void Add_ShouldAddMoneyInTheSameCurrency()
{
PortfolioWith(new Money(5, Currency.USD), new Money(10, Currency.USD))
.Evaluate(bank, Currency.USD).Should().Be(new Money(15, Currency.USD));
}
We now can safely delete the AddOld
method.
By now, you've noticed how easy it can be to 🔵 refactor when you have a test suite protecting you.