Upgrade to Pro — share decks privately, control downloads, hide ads and more …

TDD: The Good Parts

TDD: The Good Parts

A look at what makes good code, how TDD can help, how it can get in the way, and strategies for testing first without focusing only on testability.

34147b9eecf59779b777eb68a1805113?s=128

Adam Wathan

May 29, 2014
Tweet

More Decks by Adam Wathan

Other Decks in Programming

Transcript

  1. TDD The Good Parts @adamwathan

  2. None
  3. None
  4. Who's this?

  5. Kent Beck?

  6. Close, it's actually God.

  7. None
  8. None
  9. Code that's hard to test in isolation is poorly designed

    1 Test Driven Evangelists
  10. Isolated testing has given birth to some truly horrendous monstrosities

    of architecture. 1 David Heinemeier Hansson
  11. A dense jungle of service objects, command patterns, and worse.

    1 David Heinemeier Hansson
  12. What makes good code?

  13. 1. Easy to change

  14. What makes good code? Easy to change 4 No duplication

    4 Low coupling 4 Separation of concerns
  15. 2. Simple to understand

  16. What makes good code? Simple to understand 4 Expressive names

    4 Short methods 4 Minimal indirection
  17. 3. Enjoyable to use

  18. What makes good code? Enjoyable to use 4 Intuitive public

    API 4 Forgiving and flexible 4 It just works
  19. None
  20. What am I doing wrong?!

  21. Why test first?

  22. 1. Confident refactoring

  23. 2. Emphasize public API

  24. 3. Identify task at hand

  25. Isolation is overrated

  26. Pitfall #1 Lying tests

  27. // OrderItemTest.php public function test_can_get_total_price() { $product = M::mock('Product'); $product->shouldReceive('getPrice')->andReturn(500);

    $orderItem = new OrderItem($product, 3); $this->assertEquals(1500, $orderItem->getTotalPrice()); } Seems reasonable...
  28. Requirements change and now $product->getPrice() needs to be renamed to

    $product->getSellingPrice()... // ProductTest.php public function test_can_get_selling_price() { $product = new Product('Shampoo', 600); $this->assertEquals(600, $product->getSellingPrice()); }
  29. Oh noes! 4 Tests still pass but app is broken!

    4 Stub product in OrderItemTest is now out of sync with the real Product 4 Our tests are lying!
  30. Solution #1 Use real collaborators

  31. // OrderItemTest.php public function test_can_get_total_price() { $product = new Product('foobar',

    500); $orderItem = new OrderItem($product, 3); $this->assertEquals(1500, $orderItem->getTotalPrice()); } Now this test will actually fail when we change the API of Product!
  32. When your tests use the same collaborators as your application,

    they always break when they should. 1 Sandi Metz
  33. The value of this cannot be underestimated. 1 Sandi Metz

  34. Pitfall #2 Implementation coupling

  35. The great irony of over- isolation is that it actually

    makes you more dependent on implementation. 1 Steve Fenton
  36. // OrderTest.php public function test_can_calculate_total_order_price() { $items = M::mock('Collection'); $items->shouldReceive('sum')->andReturn(1500);

    $order = new Order($items); $this->assertEquals(1500, $order->getTotalPrice()); }
  37. // Order.php public function getTotalPrice() { return $this->items->sum(function($item) { return

    $item->getTotalPrice(); }); } This implementation passes...
  38. // Order.php public function getTotalPrice() { $result = 0; foreach

    ($this->items as $item) { $result += $item->getTotalPrice(); } return $result; } ...but this implementation fails!
  39. // Order.php public function getTotalPrice() { return $this->items->reduce(function($carry, $item) {

    return $carry += $item->getTotalPrice(); }, 0); } ...and this one fails...
  40. // Order.php public function getTotalPrice() { $result = 0; for

    ($i = 0; $i < count($this->items); $i++) { $result += $this->items[$i]->getTotalPrice(); } return $result; } ...also fails!
  41. Solution #2 ... Use real collaborators!

  42. // OrderTest.php public function test_can_get_total_price() { $shampoo = new Product('Shampoo',

    300); $toothpaste = new Product('Toothpaste', 500); $deodorant = new Product('Deodorant', 700); $items = new Collection([ new OrderItem($shampoo, 1), new OrderItem($toothpaste, 1), new OrderItem($deodorant, 1), ]); $order = new Order($items); $this->assertEquals(1500, $order->getTotalPrice()); } All implementations are green!
  43. Pitfall #3 The Dreaded "Design Damage"

  44. We need to get a customer's unshipped orders. This would

    be a nice way to get them... $customer->getOpenOrders();
  45. Trying to test in isolation... // CustomerTest.php public function test_it_can_retrieve_open_orders()

    { $open_orders = M::mock('OrderCollection'); $customer = M::mock('Customer[orders]'); $customer->shouldReceive('orders->whereUnshipped->get') ->andReturn($open_orders); $this->assertEquals($open_orders, $customer->getOpenOrders()); }
  46. This tests nothing.

  47. Down the overarchitecture rabbit hole...

  48. // OrderRepositoryTest.php public function test_it_can_retrieve_open_orders() { $database = M::mock('DatabaseConnection'); $order_mapper

    = M::mock('OrderMapper'); $customer = M::mock('Customer') $order = M::mock('Order'); $open_orders = [$order]; $mock_db_results = M::mock(); $database->shouldReceive('table->whereNull->get') ->andReturn($mock_db_results); $order_mapper->shouldReceive('mapFromDatabase') ->with($mock_db_results) ->andReturn($open_orders); $orderRepository = new OrderRepository($database, $order_mapper); $expected = $open_orders; $actual = $orderRepository->getOpenOrdersForCustomer($customer); $this->assertEquals($expected, $actual); }
  49. // OrderRepository.php public function getOpenOrdersForCustomer($customer) { $customer_id = $customer->getId(); $data

    = $this->db->table('orders')->whereNull('date_shipped')->get(); $orders = $this->order_mapper->mapFromDatabase($data); return $orders; }
  50. // OrderMapperTest.php public function test_it_can_map_database_results_to_orders() { $database_results = [ [

    'id' => '1', 'customer_id' => '1', 'date_shipped' => '2013-04-01', 'date_ordered' => '2013-03-28', 'payment_method' => '1' ], [ 'id' => '2', 'customer_id' => '1', 'date_shipped' => '2012-03-06', 'date_ordered' => '2012-03-01', 'payment_method' => '1' ], [ 'id' => '3', 'customer_id' => '1', 'date_shipped' => '2014-01-15', 'date_ordered' => '2014-01-12', 'payment_method' => '3' ], ]; // Setup expected orders, blah blah blah... $mapper = new OrderMapper; $actual = $mapper->mapFromDatabase($database_results); $this->assertEquals($expected, $actual); }
  51. // OrderMapper.php public function mapFromDatabase($database_results) { $orders = []; foreach

    ($database_results as $row) { $orders[] = $this->mapDatabaseRow($row); } return $orders; }
  52. // OrderMapper.php public function mapDatabaseRow($row) { $order = new Order;

    $order->setId($row['id']); $order->setCustomerId($row['customer_id']); $order->setDateShipped(new DateTime($row['date_shipped'])); $order->setDateOrdered(new DateTime($row['date_ordered'])); $order->setPaymentMethod($this->convertPaymentMethod($row['payment_method'])); return $order; }
  53. $database = new DatabaseConnection($credentials); $customerMapper = new CustomerMapper; $customerRepository =

    new CustomerRepository($database, $customerMapper); $customer = $customerRepository->findById(1); $orderMapper = new OrderMapper; $orderRepository = new OrderRepository($database, $orderMapper); $orders = $orderRepository->getOpenOrdersForCustomer($customer);
  54. ...and we don't even have the order items yet!

  55. All because this...

  56. $customer->getOpenOrders();

  57. ...was too hard to test in pure isolation.

  58. Solution #3 FOR THE LOVE OF GOD USE REAL COLLABORATORS!!!!

  59. // CustomerTest.php public function test_it_can_retrieve_open_orders() { $customer = new Customer;

    $shipped_order1 = new Order(['date_shipped' => new DateTime]); $shipped_order2 = new Order(['date_shipped' => new DateTime]); $unshipped_order = new Order(['date_shipped' => null]); $customer->orders()->save([$shipped_order1, $shipped_order2, $unshipped_order]); $open_orders = $customer->getOpenOrders(); $this->assertTrue($open_orders->contains($unshipped_order)); $this->assertFalse($open_orders->contains($shipped_order1)); $this->assertFalse($open_orders->contains($shipped_order2)); }
  60. If talking to the database is stable and fast then

    there's no reason not to do it in your unit tests. 1 Martin Fowler
  61. The Database isn't Evil 4 Use an in-memory SQLite database

    that you can re- migrate for each test 4 Super fast 4 Makes it easy to test ActiveRecord-style objects
  62. Testing is an art

  63. Testing is an art 4 No magic steps or rules

    4 Everything has trade-offs 4 Everything takes practice and experience 4 Isolate because your system needs it, not for the sake of tests
  64. @adamwathan