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")))); } }