實作練習

當確認設計與規格後,我們就要按照規格來實作了。而 TDD 強調先寫出測試,然後針對測試寫出我們的程式碼,以求讓測試通過。當測試都通過了,我們的程式就完成了。

它的流程如下:

  1. 針對規格撰寫測試。
  2. 執行測試,先讓測試失敗。
  3. 撰寫能讓測試通過的程式碼。
  4. 通過測試後,繼續下一個規格。

程式架構

為了簡化說明,專注在重點上,我們只實作類別與它們的測試類別。

.
├── Cart.php
├── Product.php
└── tests
│   ├── CartTest.php
│   └── ProductTest.php
└── phpunit.xml

程式碼骨架

Cart.php

<?php
class Cart
{
    public function addProduct(Product $product)
    {
    }

    public function checkout()
    {
    }
}

class CartException extends Exception
{
}

Product.php

<?php

class Product
{
    public function __construct($name, $price, $tag)
    {
    }

    public function getName()
    {
    }

    public function getPrice()
    {
    }

    public function getTag()
    {
    }
}

tests/CartTest.php

<?php
require_once __DIR__ . '/../Cart.php';
require_once __DIR__ . '/../Product.php';

class CartTest extends PHPUnit_Framework_TestCase
{
}

tests/ProductTest.php

<?php

require_once __DIR__ . '/../Product.php';

class ProductTest extends PHPUnit_Framework_TestCase
{
}

第一個 TDD 循環

接下來挑選以下使用案例來寫測試:

購物車加入一個紅標商品 1 後結帳,出現「無法結帳」的錯誤訊息。

首先我們要在 CartTest 中加入 fixtures :

class CartTest extends PHPUnit_Framework_TestCase
{
    private $cart = null;

    private $products = [];

    public function setUp()
    {
        $this->cart = new Cart();
        $this->products = [
            new Product('紅標商品 1', 200, 'R'),
            new Product('紅標商品 2', 160, 'R'),
            new Product('綠標商品 1',  80, 'G'),
            new Product('綠標商品 2', 100, 'G'),
        ];
    }

    public function tearDown()
    {
        $this->cart = null;
        $this->products = [];
    }
}

接著我們來加入第一個測試:

public function testAddOneRedProudct()
{
    $this->setExpectedException('CartException', '商品配對錯誤');
    $this->cart->addProduct($this->products[0]); // 紅標商品 1
    $this->cart->checkout();
}

寫好後執行它:

C:\project> phpunit
PHPUnit 4.2.5 by Sebastian Bergmann.

Configuration read from C:\project\phpunit.xml

FF

Time: 33 ms, Memory: 3.00Mb

There were 2 failures:

1) Warning
No tests found in class "ProductTest".

2) CartTest::testAddOneRedProudct
Failed asserting that exception of type "CartException" is thrown.

FAILURES!
Tests: 2, Assertions: 1, Failures: 2.

可以發現測試是不通過的。別擔心,這是 TDD 的起手式。接著我們要想想看如何讓測試通過,先試試作弊法:

class Cart
{
    // ...

    public function checkout()
    {
        throw new CartException('商品配對錯誤');
    }

    // ...
}

我們在 Cart::checkout 方法中直接丟出了 CartException ,然後執行測試:

phpunit
PHPUnit 4.2.5 by Sebastian Bergmann.

Configuration read from C:\project\phpunit.xml

.

Time: 30 ms, Memory: 2.75Mb

OK (1 test, 2 assertions)

測試通過了!雖然看起來很蠢,但至少我們確認了測試有偵測到 CartException 的發生。

第二個 TDD 循環

在進行下一個使用案例前,我們先來完成 Product 類別。

tests/ProductTest.php 加入以下測試:

public function testProductConstructorAndGetter()
{
    $product = new Product('商品1', 245, 'R');
    $this->assertEquals('商品1', $product->getName());
    $this->assertEquals(245, $product->getPrice());
    $this->assertEquals('R', $product->getTag());
}

測試後出現紅燈,所以我們把 Product 類別的 constructor 和 getter 補完:

<?php

class Product
{
    private $name = '', $price = 0, $tag = '';

    public function __construct($name, $price, $tag)
    {
        $this->name = $name;
        $this->price = $price;
        $this->tag = $tag;
    }

    public function getName()
    {
        return $this->name;
    }

    public function getPrice()
    {
        return $this->price;
    }

    public function getTag()
    {
        return $this->tag;
    }
}

測試通過!現在我們有個最簡單的 Product 類別了。

第三個 TDD 循環

接著看第二個使用案例:

購物車加入一個紅標商品 1 及綠標商品 1 ,總金額應為 ($200 + $80) * 0.75 = $210 。

寫出它的測試:

public function testAddOneRedProudctAndOneGreenProduct()
{
    $this->cart->addProduct($this->products[0]); // 紅標商品 1
    $this->cart->addProduct($this->products[2]); // 綠標商品 1
    $this->cart->checkout();
    $this->assertEquals(210, $this->cart->getTotal());
}

執行測試:

C:\project> phpunit
PHPUnit 4.2.5 by Sebastian Bergmann.

Configuration read from C:\project\phpunit.xml

.E

Time: 55 ms, Memory: 3.00Mb

There was 1 error:

1) CartTest::testAddOneRedProudctAndOneGreenProduct
CartException: 商品配對錯誤

C:\project\Cart.php:19
C:\project\tests\CartTest.php:47

FAILURES!
Tests: 2, Assertions: 2, Errors: 1.

沒意外地出現非預期的異常。

接著就是重點,我們的目標是要讓這兩個測試都能通過。

我們來思考一下怎麼寫出可以通過的程式內容:

  1. addProduct 將紅標商品和綠標商品分組存放。

  2. checkout 方法比對紅標商品數是否等於綠標商品數,相等則是可以結帳,不相等則丟出「商品配對錯誤」的異常。

  3. 當可以結帳時, checkout 計算出所有商品的金額總和,並將其乘以 0.75 。

接下來我們依據這些規則來完成程式碼:

<?php

class Cart
{
    // 商品分標籤存放
    private $products = [
        'R' => [],
        'G' => [],
    ];

    // 總計
    private $total = 0;

    public function addProduct(Product $product)
    {
        // 加入商品時先看標籤
        $this->products[$product->getTag()][] = $product;
    }

    public function checkout()
    {
        // 找出商品數
        $redCount = count($this->products['R']);
        $greenCount = count($this->products['G']);

        // 比對紅標商品數是否等於綠標商品數
        if ($redCount !== $greenCount) {
            throw new CartException('商品配對錯誤');
        }
    }

    public function getTotal()
    {
        // 回傳總計
        return $this->total;
    }
}
// ...

我們先處理加入商品的部份,這時候再執行測試,結果會變成:

C:\project> phpunit tests/CartTest.php
PHPUnit 4.2.5 by Sebastian Bergmann.

Configuration read from C:\project\phpunit.xml

.F

Time: 45 ms, Memory: 3.00Mb

There was 1 failure:

1) CartTest::testAddOneRedProudctAndOneGreenProduct
Failed asserting that 0 matches expected 210.

C:\project\tests\CartTest.php:48

FAILURES!
Tests: 2, Assertions: 3, Failures: 1.

現在錯誤訊息已經是比對總計的預期值和實際值,這表示我們確實成功進入商品配對成功的路徑了。

註:如果是使用 Git 的話,可以先把修改放在 staging area 中,再繼續下一步的修改。

接著做計算金額的部份:


public function checkout()
{
    // 比對紅標商品數是否等於綠標商品數
    $redCount = count($this->products['R']);
    $greenCount = count($this->products['G']);

    if ($redCount !== $greenCount) {
        throw new CartException('商品配對錯誤');
    }

    // 計算紅標商品金額小計
    foreach ($this->products['R'] as $product) {
        $this->total += $product->getPrice();
    }

    // 計算綠標商品金額小計
    foreach ($this->products['G'] as $product) {
        $this->total += $product->getPrice();
    }

    // 打七五折
    $this->total *= 0.75;
}

執行測試:成功!

練習

  • 思考看看,如果 Product 類別是由其他人開發,但尚未完成時,該怎麼做?

  • 試著用先前教的 PHPUnit 知識並搭配 TDD 完成剩下的使用案例。

  • Cart 類別有些重複的程式碼,試著重構它。