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.

Adam Wathan

May 29, 2014
Tweet

More Decks by Adam Wathan

Other Decks in Programming

Transcript

  1. TDD
    The Good Parts
    @adamwathan

    View full-size slide

  2. Close, it's actually God.

    View full-size slide

  3. Code that's hard to test in
    isolation is poorly designed
    1
    Test Driven Evangelists

    View full-size slide

  4. Isolated testing has given
    birth to some truly
    horrendous monstrosities
    of architecture.
    1
    David Heinemeier Hansson

    View full-size slide

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

    View full-size slide

  6. What makes good code?

    View full-size slide

  7. 1. Easy to change

    View full-size slide

  8. What makes good code?
    Easy to change
    4 No duplication
    4 Low coupling
    4 Separation of concerns

    View full-size slide

  9. 2. Simple to understand

    View full-size slide

  10. What makes good code?
    Simple to understand
    4 Expressive names
    4 Short methods
    4 Minimal indirection

    View full-size slide

  11. 3. Enjoyable to use

    View full-size slide

  12. What makes good code?
    Enjoyable to use
    4 Intuitive public API
    4 Forgiving and flexible
    4 It just works

    View full-size slide

  13. What am I doing wrong?!

    View full-size slide

  14. Why test first?

    View full-size slide

  15. 1. Confident refactoring

    View full-size slide

  16. 2. Emphasize public API

    View full-size slide

  17. 3. Identify task at hand

    View full-size slide

  18. Isolation is
    overrated

    View full-size slide

  19. Pitfall #1
    Lying tests

    View full-size slide

  20. // 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...

    View full-size slide

  21. 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());
    }

    View full-size slide

  22. 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!

    View full-size slide

  23. Solution #1
    Use real collaborators

    View full-size slide

  24. // 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!

    View full-size slide

  25. When your tests use the
    same collaborators as your
    application, they always
    break when they should.
    1
    Sandi Metz

    View full-size slide

  26. The value of this cannot be
    underestimated.
    1
    Sandi Metz

    View full-size slide

  27. Pitfall #2
    Implementation coupling

    View full-size slide

  28. The great irony of over-
    isolation is that it actually
    makes you more dependent
    on implementation.
    1
    Steve Fenton

    View full-size slide

  29. // 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());
    }

    View full-size slide

  30. // Order.php
    public function getTotalPrice()
    {
    return $this->items->sum(function($item) {
    return $item->getTotalPrice();
    });
    }
    This implementation passes...

    View full-size slide

  31. // Order.php
    public function getTotalPrice()
    {
    $result = 0;
    foreach ($this->items as $item) {
    $result += $item->getTotalPrice();
    }
    return $result;
    }
    ...but this implementation fails!

    View full-size slide

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

    View full-size slide

  33. // Order.php
    public function getTotalPrice()
    {
    $result = 0;
    for ($i = 0; $i < count($this->items); $i++) {
    $result += $this->items[$i]->getTotalPrice();
    }
    return $result;
    }
    ...also fails!

    View full-size slide

  34. Solution #2
    ... Use real collaborators!

    View full-size slide

  35. // 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!

    View full-size slide

  36. Pitfall #3
    The Dreaded "Design Damage"

    View full-size slide

  37. We need to get a customer's unshipped orders.
    This would be a nice way to get them...
    $customer->getOpenOrders();

    View full-size slide

  38. 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());
    }

    View full-size slide

  39. This tests nothing.

    View full-size slide

  40. Down the overarchitecture
    rabbit hole...

    View full-size slide

  41. // 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);
    }

    View full-size slide

  42. // 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;
    }

    View full-size slide

  43. // 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);
    }

    View full-size slide

  44. // OrderMapper.php
    public function mapFromDatabase($database_results)
    {
    $orders = [];
    foreach ($database_results as $row) {
    $orders[] = $this->mapDatabaseRow($row);
    }
    return $orders;
    }

    View full-size slide

  45. // 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;
    }

    View full-size slide

  46. $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);

    View full-size slide

  47. ...and we don't even have
    the order items yet!

    View full-size slide

  48. All because this...

    View full-size slide

  49. $customer->getOpenOrders();

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  52. // 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));
    }

    View full-size slide

  53. 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

    View full-size slide

  54. 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

    View full-size slide

  55. Testing is an art

    View full-size slide

  56. 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

    View full-size slide