On Github brookingcharlie / tdds-slides
Take customer's shopping basket and generate an itemised receipt.
We'll build a simple console application using TDD.
Pizza - Pepperoni,12.99 Pizza - Supreme,12.99 Garlic bread,8.50 Chianti,21.00
*************** RECEIPT ****************
Pizza - Pepperoni 12.99
Pizza - Supreme 12.99
Garlic bread 8.50
Chianti 21.00
--------
Total for 4 items 55.48
========
@Test
public void testSingle() throws Exception {
String input =
"Pizza - Pepperoni,12.99";
String expectedOutput =
"*************** RECEIPT ****************\n" +
"\n" +
"Pizza - Pepperoni 12.99\n" +
" --------\n" +
"Total for 1 items 12.99\n" +
" ========\n";
try (
StringReader reader = new StringReader(input);
StringWriter writer = new StringWriter();
) {
App.run(reader, writer);
assertThat(writer.toString(), is(equalTo(expectedOutput)));
}
}
public static void run(Reader reader, Writer writer) throws IOException {
try (
BufferedReader bufferedReader = new BufferedReader(reader);
PrintWriter printWriter = new PrintWriter(writer);
) {
String line = bufferedReader.readLine();
String[] parts = line.split(",");
String product = parts[0];
BigDecimal price = new BigDecimal(parts[1]);
printWriter.println("*************** RECEIPT ****************");
printWriter.println();
printWriter.println(String.format("%-32s%8.2f", product, price));
printWriter.println(String.format("%-32s%8s", "", "--------"));
printWriter.println(String.format("%-32s%8.2f", "Total for 1 items", price));
printWriter.println(String.format("%-32s%8s", "", "========"));
}
}
Responsibilities of our App class?
Reads formatted input text Calculates total price (well, it will) Writes formatted output textSeparate the App class's responsibilities:
Model basket concept as domain class Make basket responsible for calculating total Read/write text formats in separate classpublic class Basket {
public void addItem(Item item) {...}
public Item getItem(int i) {...}
public int getCount() {...}
public BigDecimal getTotal() {...}
}
public static void run(Reader input, Writer output) throws IOException {
Basket basket = new Basket();
new BasketReader().read(basket, input);
new BasketWriter().write(basket, output);
}
Test adding/getting items, count, and total.
@Test
public void testSingleItem() {
Basket basket = new Basket();
basket.addItem(new Item("Pizza - Pepperoni", new BigDecimal("12.99")));
assertThat(basket.getCount(), is(equalTo(1)));
assertThat(basket.getItem(0).getProduct(),
is(equalTo("Pizza - Pepperoni")));
assertThat(basket.getItem(0).getPrice(),
is(equalTo(new BigDecimal("12.99"))));
assertThat(basket.getTotal(),
is(equalTo(new BigDecimal("12.99"))));
}
public class Basket {
private final ArrayList<Item> items;
public Basket() { this.items = new ArrayList<Item>(); }
public void addItem(Item item) { items.add(item); }
public Item getItem(int i) { return items.get(i); }
public int getCount() { return items.size(); }
public BigDecimal getTotal() {
return items.stream()
.map(i -> i.getPrice())
.reduce((a, b) -> a.add(b))
.orElse(new BigDecimal("0.00"));
}
}
Verify that reader adds items to the basket.
@Test
public void testSingleItem() throws IOException {
Basket basket = mock(Basket.class);
StringReader input = new StringReader("Pizza - Pepperoni,12.99\n");
new BasketReader().read(basket, input);
ArgumentCaptor<Item> item = ArgumentCaptor.forClass(Item.class);
verify(basket, times(1)).addItem(item.capture());
assertThat(item.getValue().getProduct(),
is(equalTo("Pizza - Pepperoni")));
assertThat(item.getValue().getPrice(),
is(equalTo(new BigDecimal("12.99"))));
}
public void read(Basket basket, Reader input) throws IOException {
try (
BufferedReader bufferedReader = new BufferedReader(input);
) {
bufferedReader.lines()
.map(line -> line.split(","))
.map(parts -> new Item(parts[0], new BigDecimal(parts[1])))
.forEach(item -> basket.addItem(item));
}
}
Compare actual vs. expected output.
@Test
public void testSingleItem() throws IOException {
Basket basket = new Basket();
basket.addItem(new Item("Pizza - Pepperoni", new BigDecimal("12.99")));
StringWriter output = new StringWriter();
new BasketWriter().write(basket, output);
String expectedOutput =
"*************** RECEIPT ****************\n" +
"\n" +
"Pizza - Pepperoni 12.99\n" +
" --------\n" +
"Total for 1 items 12.99\n" +
" ========\n";
assertThat(output.toString(), is(equalTo(expectedOutput)));
}
public void write(AccessibleBasket basket, Writer output) throws IOException {
try (PrintWriter printer = new PrintWriter(output)) {
printer.println("*************** RECEIPT ****************");
printer.println();
for (int i = 0; i < basket.getCount(); i++) {
printer.println(String.format("%-32s%8.2f",
basket.getItem(i).getProduct(), basket.getItem(i).getPrice()));
}
printer.println(String.format("%-32s%8s", "", "--------"));
String total = String.format("Total for %d items", basket.getCount());
printer.println(String.format("%-32s%8.2f", total, basket.getTotal()));
printer.println(String.format("%-32s%8s", "", "========"));
}
}
Consider the clients of our Basket class:
public interface MutatableBasket {
void addItem(Item item);
}
public interface AccessibleBasket {
Item getItem(int i);
int getCount();
BigDecimal getTotal();
}
public class Basket implements MutatableBasket, AccessibleBasket {...}
public class BasketReader {
public void read(MutatableBasket basket, Reader input) {...}
}
public class BasketWriter {
public void write(AccessibleBasket basket, Writer output) {...}
}
abstract class Shape {
}
class Rectangle extends Shape {
void drawRectangle() {...}
}
class Square extends Shape {
void drawSquare() {...}
}
class App {
void render(Shape s) {
if (s instanceof Rectangle) {
shape.drawRectangle();
}
// ...
}
}
abstract class Shape {
abstract void draw();
}
class Rectangle extends Shape {
void draw() {...}
}
class Square extends Shape {
void draw() {...}
}
class App {
void render(Shape shape) {
shape.draw();
}
}
public class SaleBasket extends Basket {
@Override
public BigDecimal getTotal() {
return super.getTotal().multiply(new BigDecimal("0.9")).setScale(2);
}
}
BasketWriter
String totalDescription = String.format("Total for %d items", basket.getCount());
printWriter.println(String.format("%-32s%8.2f", totalDescription, basket.getTotal()));
printWriter.println(String.format("%-32s%8s", "", "====...."));
+ if (basket instanceof SaleBasket) {
+ printWriter.println("Includes discount of 10%");
+ }
}
}
Basket vs SaleBasket
public String getMessage() {
return null;
}
@Override
public String getMessage() {
return "Includes discount of 10%";
}
BasketWriter.java
-if (basket instanceof SaleBasket) {
- printWriter.println("Includes discount of 10%");
-}
+if (basket.getMessage() != null) {
+ printWriter.println(basket.getMessage());
+}
Problem: cannot extend a class without modifying it.
Solution: design fixed classes with extension points.
Basket vs SaleBasket
@Override
public List<Item> getExtraItems() {
return Collections.emptyList();
}
@Override
public List<Item> getExtraItems() {
BigDecimal discount = super.getTotal().multiply(new BigDecimal("-0.1")).setScale(2);
return asList(new Item("Discount", discount));
}
BasketWriter.java
for (int i = 0; i < basket.getCount(); i++) {
printWriter.println(String.format("%-32s%8.2f", basket.getItem(i).getProduct(), basket.getItem(i).getPrice()));
}
+for (Item item : basket.getExtraItems()) {
+ printWriter.println(String.format("%-32s%8.2f", item.getProduct(), item.getPrice()));
+}
Basket vs SaleBasket
public class Basket implements MutatableBasket, AccessibleBasket {
public final List<Item> getItems() {
List<Item> result = new ArrayList<Item>(items);
result.addAll(getExtraItems());
return result;
}
protected List<Item> getExtraItems() {
return Collections.emptyList();
}
}
public class SaleBasket extends Basket {
@Override
protected List<Item> getExtraItems() {
BigDecimal discount = super.getTotal().multiply(new BigDecimal("-0.1")).setScale(2);
return asList(new Item("Discount", discount));
}
}
Basket
public interface Basket implements MutatableBasket, AccessibleBasket {
public void addItem(Item item) {...}
public List<Item> getItems() {...}
public BigDecimal getTotal() {...}
public String getMessage() {...}
}
BasketWriter.java
for (Item item : basket.getItems()) {
printWriter.println(String.format("%-32s%8.2f", item.getProduct(), item.getPrice()));
}
Both should depend upon abstractions!
Our app depends on several concrete classes.
public class App {
public void run(Reader input, Writer output) throws IOException {
BasketReader inputReader = new BasketReader();
BasketWriter outputWriter = new BasketWriter();
Basket basket = new Basket();
inputReader.read(basket, input);
outputWriter.write(basket, output);
}
}
public class App {
private final IBasketFactory basketFactory;
private final IBasketReader inputReader;
private final IBasketWriter outputWriter;
public App(IBasketFactory basketFactory, IBasketReader inputReader,
IBasketWriter outputWriter) {
this.basketFactory = basketFactory;
this.inputReader = inputReader;
this.outputWriter = outputWriter;
}
public void run(Reader input, Writer output) throws IOException {
Basket basket = basketFactory.createBasket();
inputReader.read(basket, input);
outputWriter.write(basket, output);
}
}
public interface BasketFactory {
Basket createBasket();
}
public class WeekendSaleBasketFactory implements BasketFactory {
@Override
public Basket createBasket() {
LocalDate date = LocalDate.now();
if (date.getDayOfWeek() == SATURDAY || date.getDayOfWeek() == SUNDAY) {
return new SaleBasket();
}
else {
return new Basket();
}
}
}
public class WeekendSaleBasketFactory implements BasketFactory {
private LocalDate date;
public WeekendSaleBasketFactory(LocalDate date) {
this.date = date;
}
@Override
public Basket createBasket() {
if (date.getDayOfWeek() == SATURDAY || date.getDayOfWeek() == SUNDAY) {
return new SaleBasket();
}
else {
return new Basket();
}
}
}
public class DecimalTest {
@Test
public void testIncorrectDoubles() {
double result = 0.1 + 0.2;
assertThat(result, is(equalTo(0.30000000000000004)));
}
@Test
public void testCorrectBigDecimal() {
BigDecimal result = new BigDecimal("0.1").add(new BigDecimal("0.2"));
assertThat(result, is(equalTo(new BigDecimal("0.3"))));
}
}