Easy Fix, Difficult Test
Here is the sample app
What does it do?
We own a company that sells Books on-line in various cities around the world. To manage our purchases, we have developed an internal system that has two portals:
1. Customer Portal
This portal, provides our customers with features allowing them to search for and purchase books.
The purchase workflow is as follows:
- Customers add the books they want to purchase to a basket.
- They then checkout their basket.
- Upon checkout, our system should generate an invoice. The invoice should
have the following properties:
- Tax rates and tax reduction rules should be applied for each item in the basket
- The total amount of the invoice should be the sum of the amount of all books (after tax) in the basket
- The currency of the invoice is the same currency as the respective country
- The Invoice is sent to the customer, and a copy of it is saved in our repository for future reference
Tip: It is important to note that each country has its own tax rates and tax reduction rules. You can find a table of those rules below.
2. Reporting Portal
The second portal is used by administrators to generate reports of the sales around the world.
The report should include the following:
- Accumulative sum of all the issued invoices’ amount
- The count of processed invoices
- The currency of the report should be in USD
Countries, Currencies, Language, Tax Rates, and Tax Reduction Rules
Country | Currency | Language | Exchange Rate to USD | Tax Rate | Tax Reduction Rules |
---|---|---|---|---|---|
USA | USD | English | 1.0 | 15% | Reduction by 2% on Novels |
France | Euro | French | 1.14 | 25% | No Reduction on taxes |
UK | Pound Sterling | English | 1.27 | 20% | Reduction by 7% on Novels |
Spain | Euro | Spanish | 1.14 | 10% | Removed taxes on all foreign language books |
China | Renminbi | Mandarin | 0.15 | 35% | Removed taxes on all foreign language books |
Japan | YEN | Japanese | 0.0093 | 30% | No Reduction on taxes |
Australia | Australian Dollar | English | 0.70 | 13% | No Reduction on taxes |
Germany | Euro | German | 1.14 | 22% | Dropped to 5% on books written by German Authors |
Repository
The repository is our database where we store copies of all the issued invoices. The repository is defined by an interface that has 2 methods:
- addInvoice: adds an invoice to the repository’s database
- getInvoiceMap: return all the available invoices in a Map
Having this interface enables us to have different implementations for our database (Json, InMemory, Relational, NoSql, etc).
In this workshop, we wrote a JSON implementation for this Repository interface (JsonRepository.java). We assumed that we are storing our data in JSON format in a file under the resources folder.
Tip: Reading the repository.json file might help you understand the structure of the code faster.
On initialization, the class parses the Json file and loads the data into a Map.
The MainRepository singleton returns the currently configured Repository.
We found a bug!
We noticed that some of the numbers generated by the report were wrong.
Report | Actual | Expected |
---|---|---|
The total number of books sold | 16 | 16 |
The total number of issued invoices | 6 | 6 |
The total amount of all invoices in USD | 1016.04 | 424.57 |
Fortunately, our great team mates gave us some clues as to the origin of the problem:
It looks like conversion rates and tax reductions were not applied. [The domain expert]
It’s weird because there is code for these in TaxRule and CurrencyConverter! [A senior developer]
We fixed it!
After some analysis, one of our developers was able to quickly identify the bugs in the code and provided us with quick fixes!
If you run the App now, you should see that The total amount of all invoices in USD
is looking good: it’s 424.57
.
Java
Sneak Peek at Bug Fix in Invoice.java
public double computeTotalAmount() {
double sum = 0.0;
for (PurchasedBook purchasedBook : purchasedBooks) {
- double totalPrice = purchasedBook.getTotalPrice();
+ double totalPrice = purchasedBook.getTotalPrice() * TaxRule.getApplicableRate(country, purchasedBook.getBook());
sum += totalPrice;
}
return sum;
}
Sneak Peek at Bug Fix in ReportGenerator.java
public double getTotalAmount() {
Map<Integer, Invoice> invoiceMap = repository.getInvoiceMap();
double totalAmount = 0.0;
for (Invoice invoice : invoiceMap.values()) {
- totalAmount += invoice.computeTotalAmount();
+ totalAmount += CurrencyConverter.toUSD(invoice.computeTotalAmount(), invoice.getCountry().getCurrency());
}
return getRoundedAmount(totalAmount);
}
C++
Sneak Peek at Bug Fix in Invoice.cpp
double sum = 0.0;
for (const auto purchasedBook : purchasedBooks_)
{
- double totalPrice = purchasedBook->getTotalPrice();
+ double totalPrice = purchasedBook->getTotalPrice() * finance::getApplicableRate(country_, *purchasedBook->getBook());
sum += totalPrice;
}
return sum;
Sneak Peek at Bug Fix in ReportGenerator.cpp
double totalAmount = 0.0;
for (const auto id2Invoice : invoiceMap)
{
- totalAmount += id2Invoice.second->computeTotalAmount();
+ const auto& invoice = *id2Invoice.second;
+ totalAmount += finance::toUSD(invoice.computeTotalAmount(), invoice.getCountry().getCurrency());
}
return getRoundedValueOf(totalAmount);
C#
Sneak Peek at Bug Fix in Invoice.cs
```diff c# public double ComputeTotalAmount() { var totalAmount = 0.0;
- totalAmount = PurchasedBooks.Sum(book => book.TotalPrice);
- totalAmount = PurchasedBooks.Sum(book => book.TotalPrice * TaxRule.GetApplicableRate(Country, book.Book)); return totalAmount; } ```
Sneak Peek at Bug Fix in ReportGenerator.cs
```diff c# public double GetTotalAmount() { var invoices = _repository.GetInvoiceMap().Values;
- var totalAmount = invoices.Sum(invoice => invoice.ComputeTotalAmount());
- var totalAmount = invoices.Sum(invoice => CurrencyConverter.ToUsd(invoice.ComputeTotalAmount(), invoice.Country.Currency)); return GetRoundedAmount(totalAmount); } ```
Kotlin
Sneak Peek at Bug Fix in Invoice.kt
fun computeTotalAmount(): Double {
var sum = 0.0
for (purchasedBook in purchasedBooks) {
- val totalPrice: Double = purchasedBook.getTotalPrice()
+ val totalPrice: Double = purchasedBook.getTotalPrice() * getApplicableRate(country, purchasedBook.book)
sum += totalPrice
}
return sum
}
Sneak Peek at Bug Fix in ReportGenerator.kt
fun getTotalAmount(): Double {
val invoiceMap = repository.getInvoiceMap()
var totalAmount = 0.0
for (invoice in invoiceMap.values) {
- totalAmount += invoice.computeTotalAmount()
+ totalAmount += CurrencyOperations.toUSD(invoice.computeTotalAmount(), invoice.country.currency)
}
return getRoundedAmount(totalAmount);
}
Scala
Sneak Peek at Bug Fix in Invoice.scala
def computeTotalAmount: Double = {
var sum = 0.0
for (purchasedBook <- purchasedBooks) {
- val totalPrice: Double = purchasedBook.getTotalPrice()
+ val totalPrice: Double = purchasedBook.getTotalPrice * getApplicableRate(country, purchasedBook.book)
sum += totalPrice
}
sum
}
Sneak Peek at Bug Fix in ReportGenerator.scala
def getTotalAmount: Double = {
val invoiceMap = repository.getInvoiceMap
var totalAmount = 0.0
for (invoice <- invoiceMap.values) {
- totalAmount += invoice.computeTotalAmount();
+ totalAmount += toUSD(invoice.computeTotalAmount, invoice.country.currency)
}
getRoundedAmount(totalAmount)
}
Now we need Unit Tests
1. On the Invoice
Your mission is to add a test for the Invoice class
(java | c++ | c# | kotlin | scala) to cover the fix we added.
Instructions are provided in InvoiceTest (java | c++ | c# | kotlin | scala).
Make sure your test is correct: it must fail when you reintroduce the bug in Invoice
.
Mocking a legacy code base is not a great idea. The only fake we are allowed is
the InMemoryRepository
class (java | c++ | c# | kotlin | scala)
2. [BONUS] On the ReportGenerator
If you have the time, do the same for
ReportGenerator (java | c++ | c# | kotlin | scala).
You’ll find instructions in ReportGeneratorTest (java | c++| c# | kotlin | scala).
Mini Retro
Take a few minutes to discuss the good and the bad of this approach.
Then compare them to what people usually say in the Retrospectives Guide
Continue: