June 20, 2007

Testing_FITを使ってみる。

PEPrの投票フェーズにいて、前から気になっていたTesting_FITを使ってみたので、簡単に書いてみる。
FITとは、Framework for Integrated Testsの略で、顧客と開発者とで協力してテストをしていくための仕組みだ。。。と書いたところで、どう考えても私が説明するよりも、よっぽどわかりやすいサイトがあるので面倒になった。そちらを読まれたし。・IBM コード品質の追求: FITで解決する

私は、Testing_FITを使用して、それに関して書くだけ。そのネタとして上記のリンク先がわかりやすかったので、そのサンプルを元に簡単なプログラムを作成した。ついでなのでソースを全部載せてみよう。つーか、金額を表すのにfloat使うな、とツッコミを受けそう。。

今回私が使ったのは、Testing_FIT_Fixture_Column。HTMLのtableの行や列に変数やメソッドの結果を入れて、実際に正しかったかを見るプログラム。こんな感じでできる。→Example: Math(もし、配布しているサンプルのコードと同じだったらなんか突っ込みたくなるが、ここでは大事なネタでなさそうなので、放置。)

で、上の「IBM コード品質の追求: FITで解決する」のTrendIndicatorから作ってみよう。私のファイル構成はこんな感じ。TrendIndicatorクラスは面倒だったので、TrendIndicatorFITクラスにぶち込んでみる。本当は、どこからのモデルかユーティリティに入るのだろうが、気にしない。

+ working/
      + fixture/TrendIndicatorFIT.php
      + in/trendindicator.html
      + web.php

簡単に説明すると、TrendIndicatorFIT.phpは、テストの対象となるTrendIndicatorクラスとFITコードのTrendIndicatorFITクラスが入っている。trendindicator.htmlは、FITを使って作られた構造化モデルである。変数とそのメソッドの結果の一覧みたいなものだと考えれば良い。web.phpは、WEBからFITを実行するためのスクリプトである。では、trendindicator.htmlの中身を見てみる。

  1. <!DOCTYPE html public "-//w3c//dtd html 4.0 transitional//en">
  2.    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  3.    <title>TrendIndicatorFIT</title></meta></head>
  4. <h1>Trend Indicator</h1>
  5. <table border cols="3" cellspacing="0" cellpadding="3">
  6. <tr><td colspan="3">TrendIndicatorFIT</td></tr>
  7. <tr><td>value1</td><td>value2</td><td>trend()</td></tr>
  8. <tr><td>84.0</td><td>71.2</td><td>decreasing</td></tr>
  9. <tr><td>67.6</td><td>89.0</td><td>increasing</td></tr>
  10. <tr><td>50.0</td><td>50.0</td><td>constant</td></tr>
  11. </table><br /><br />
  12. <table border cellspacing="0" cellpadding="3"><tr><td colspan="2">fit.Summary</td></tr></table>
  13.     This document implements an example from http://www-06.ibm.com/jp/developerworks/java/060317/j_j-cq02286.shtml
  14. </p>
  15. </body></html>

$value1、 $value2にそれぞれ、84.0、71.2を入れるとtrend()がdecreasingという文字列を返すなどのテストを三つ上げてある。つまり、$value1が$value2よりも大きければ、trend()が"increased"を返し、小さければ、"decreased"を返し、同じであれば、"constant"を返すという単純なものだ。これに基づいて書いたコードTrendIndicatorFIT.phpが次の通り。

  1. <?php
  2. require_once 'Testing/FIT/Fixture/Column.php';
  3. // テスト対象クラス
  4. class TrendIndicator
  5. {
  6.     public static function determineTrend($value1, $value2) {
  7.         $result = null;
  8.  
  9.         if (!(is_float($value1) and is_float($value2))) {
  10.             throw new Exception("TrendIndicatorException");
  11.         }
  12.  
  13.         if ($value1> $value2) {
  14.             $result = "decreasing";
  15.         } else if ($value1 <$value2) {
  16.             $result = "increasing";
  17.         } else if ($value1 == $value2) {
  18.             $result = "constant";
  19.         }
  20.  
  21.         return $result;
  22.     }
  23. }
  24.  
  25. // FITクラス
  26. class TrendIndicatorFIT extends Testing_FIT_Fixture_Column
  27. {
  28.     public $value1;
  29.     public $value2;
  30.  
  31.     protected $_typeDictionary = array(
  32.         'value1' => 'float',
  33.         'value2' => 'float',
  34.         'trend()' => 'string'
  35.         );
  36.  
  37.     public function trend() {
  38.         return TrendIndicator::determineTrend($this->value1, $this->value2);
  39.     }
  40. }
  41. ?>

$_typeDictionaryの動作は実は問題があるのだが、float, string, integerは動くようなので、ここでは、float, stringを使ってみた。これで完成。web.phpから見てみると、サンプルにあるようなテーブル形式にexpectedとactualとしてテストに合格したかどうかを表してくれる。実は、これPHPUnitでも書いてもいいのだけど、書く量が増えることと、プログラムのことを知らない顧客がわからないという点からFITというアプローチはどう?ってものだ。ちなみにweb.phpはこんな感じ。

  1. <?php
  2. /**
  3. * start fut runner
  4. */
  5.  
  6. // tell where to find project fixtures
  7. $fitDir =   dirname( __FILE__ ) . '/fixture';
  8. define( 'TESTING_FIT_FIXTURE_DIR', $fitDir );
  9.  
  10. $baseDir = realpath( dirname( __FILE__ ) . '/../..' );
  11. $incPath = get_include_path() . PATH_SEPARATOR  . realpath( $baseDir . '/..' );
  12. set_include_path( $incPath );
  13.  
  14. $in = 'in/trendindicator.html';
  15.  
  16. include_once 'Testing/FIT/Runner.php';
  17. $fr =   new Testing_FIT_Runner();
  18. $fr->run( $in, '-' );
  19. ?>


しかし、これだけでは何がウレシイのかよくわからないので、もう少し長いプログラムを書いてみよう。これも、上記のサイトから。Javaで書いてあるものをPHPに移植してみることにする。仕様はこんなところらしい。

皆さんが、あるビール会社の注文処理システムを構築するように依頼されたと考えてみてください。このビール会社は様々なタイプの飲料を販売していますが、大きく分けると、季節商品(seasonal)と通年商品(year-round)、という2つのカテゴリーに分類することができます。このビール会社は卸売りとして操業しているため、すべての飲料はケース単位で販売されます。小売店への販売奨励策として、複数ケース買いに対する値引きサービスがあります。値引きの仕組みは、ケースの数と、季節商品か通年商品かによって変化します。

こうした要求の管理は、なかなか面倒です。例えば、ある小売店が季節商品を50ケース買う場合には値引きは適用されません。しかし、この50ケースが季節商品『ではない』場合には、12%の値引きが適用されます。ある小売店が季節商品を100ケース買う場合には、値引きは適用されますが、値引率は 5%だけです。季節商品ではない飲料を100ケース買う場合には、17%の値引きです。200ケース買う場合にも、似たルールがあります。

確かに面倒だ。数やその商品によって割引率が違うというのはよくあるが、テストが面倒そう。と、いったところで、FITの登場ですよ!上記の記事には、単体テストはJUnitでを使用し、ビジネス・ルールのテストや値の組み合わせに関わるテストにはFITを使用するのがいいのではないか、と書いてある。

そこで、記事に倣って、単体テストはPHPUnitで行い、値の組み合わせに関わるテストにはTesting_FITを使う。まずはファイル構成から。

+ working/
      + fixture/DiscountStructureFIT.php
      + unit/MoneyTest.php
      + unit/WholesaleOrderTest.php
      + in/discountstructure.html
      + web.php

DiscountStrucureFIT.phpは、面倒だったので、Moneyクラス、WholesaleOrderクラス、ProductTypeクラス、PricingEngineクラスといった本来はここに入ることはないクラスまで突っ込んである。そして、本来入るべきFITコードのDiscountStructureFITクラスも入っている。MoneyTest.phpとWholesaleOrderTest.phpは、それぞれMoneyクラスとWholesaleOrderクラスの単体テストコードが入っている。discountstructure.htmlは、FITを使って作られた構造化モデルである。web.phpは、先ほどと同じだけどもdiscountstructure.htmlを見るように変更しただけ。

  1. <!DOCTYPE html public "-//w3c//dtd html 4.0 transitional//en">
  2.    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  3.    <title>DiscountStructureFIT</title></meta></head>
  4. <h1>Discount Structure</h1>
  5. 皆さんが、あるビール会社の注文処理システムを構築するように依頼されたと考えてみてください。このビール会社は様々なタイプの飲料を販売していますが、大きく分けると、季節商品(seasonal)と通年商品(year-round)、という1つのカテゴリーに分類することができます。このビール会社は卸売りとして操業しているため、すべての飲料はケース単位で販売されます。小売店への販売奨励策として、複数ケース買いに対する値引きサービスがあります。値引きの仕組みは、ケースの数と、季節商品か通年商品かによって変化します。
  6.  
  7. こうした要求の管理は、なかなか面倒です。例えば、ある小売店が季節商品を50ケース買う場合には値引きは適用されません。しかし、この50ケースが季節商品『ではない』場合には、12%の値引きが適用されます。ある小売店が季節商品を100ケース買う場合には、値引きは適用されますが、値引率は 5%だけです。季節商品ではない飲料を100ケース買う場合には、17%の値引きです。200ケース買う場合にも、似たルールがあります。
  8.  
  9. <cite>http://www-06.ibm.com/jp/developerworks/java/060317/j_j-cq02286.shtml</cite>
  10. <div>* 注意 10個でかつ、季節商品『ではない』場合には、5%の値引き適用されることは書いてないので、私が追加。 by shin</div>
  11. <table border cols="5" cellspacing="0" cellpadding="3">
  12. <tr><td colspan="5">DiscountStructureFIT</td></tr>
  13. <tr><td>listPricePerCase</td><td>numberOfCases</td><td>isSeasonal</td><td>discountPrice()</td><td>discountAmount()</td></tr>
  14.     <tr><td>10.00</td><td>10</td><td>true</td><td>$100.00</td><td>$0.00</td></tr>
  15.     <tr><td>10.00</td><td>10</td><td>false</td><td>$95.00</td><td>$5.00</td></tr>
  16.     <tr><td>10.00</td><td>50</td><td>true</td><td>$500.00</td><td>$0.00</td></tr>
  17.     <tr><td>10.00</td><td>50</td><td>false</td><td>$440.00</td><td>$60.00</td></tr>
  18.     <tr><td>10.00</td><td>100</td><td>true</td><td>$950.00</td><td>$50.00</td></tr>
  19.     <tr><td>10.00</td><td>100</td><td>false</td><td>$830.00</td><td>$170.00</td></tr>
  20.     <tr><td>10.00</td><td>200</td><td>true</td><td>$1800.00</td><td>$200.00</td></tr>
  21.     <tr><td>10.00</td><td>200</td><td>false</td><td>$1600.00</td><td>$400.00</td></tr>
  22.   </table><br /><br />
  23. <table border cellspacing="0" cellpadding="3"><tr><td colspan="2">fit.Summary</td></tr></table>
  24.     This document implements an example from http://www-06.ibm.com/jp/developerworks/java/060317/j_j-cq02286.shtml
  25. </p>
  26. </blockquote></body></html>

「値段」や「季節ものかどうか」で組み合わせた8つのパターンでいくら値引きがあるかを想定しておく。
Moneyクラス、WholesaleOrderクラス、ProductTypeクラス、PricingEngineクラスも適当に実装してみる。そして、MoneyクラスとWholesaleOrderクラスはPHPUnitで書いてみる。MoneyTest.phpは以下の通り。

  1. <?php
  2. require_once 'PHPUnit/Framework/TestSuite.php';
  3. require_once 'PHPUnit/TextUI/TestRunner.php';
  4.  
  5. require_once '../fixture/DiscountStructure.php';
  6.  
  7. class MoneyTest  extends PHPUnit_Framework_TestCase
  8. {
  9.  
  10.     public function testToString()
  11.     {
  12.         $money = new Money(10.00);
  13.         $total = $money->mpy(10);
  14.         $this->assertEquals("$100.00", $total->toString());
  15.     }
  16.  
  17.     public function testEquals()
  18.     {
  19.         $money = Money::parse("$10.00");
  20.         $total = new Money(10);
  21.         $this->assertEquals($money, $total);
  22.     }
  23.  
  24.     public function testMultiply()
  25.     {
  26.         $money = new Money(10.00);
  27.         $total = $money->mpy(10);
  28.  
  29.         $discountAmount = $total->mpy(0.05);
  30.         $this->assertEquals("$5.00", $discountAmount->toString());
  31.     }
  32.  
  33.     public function testSubtract()
  34.     {
  35.         $money = new Money(10.00);
  36.         $total = $money->mpy(10);
  37.         $discountAmount = $total->mpy(0.05);
  38.         $discountedPrice = $total->sub($discountAmount);
  39.  
  40.         $this->assertEquals("$95.00", $discountedPrice->toString());
  41.     }
  42.  
  43.     public function testParse()
  44.     {
  45.         $money = Money::parse("$10.00");
  46.         $this->assertEquals("$10.00", $money->toString());
  47.     }
  48. }
  49. ?>

WholesaleOrderTest.phpは次の通り

  1. <?php
  2. require_once 'PHPUnit/Framework/TestSuite.php';
  3. require_once 'PHPUnit/TextUI/TestRunner.php';
  4.  
  5. require_once '../fixture/DiscountStructure.php';
  6.  
  7. class WholesaleOrderTest  extends PHPUnit_Framework_TestCase
  8. {
  9.     public function testGetCalculatedPrice()
  10.     {
  11.         $order = new WholesaleOrder();
  12.         $order->setDiscount(0.05);
  13.         $order->setNumberOfCases(10);
  14.         $order->setPricePerCase(new Money(10.00));
  15.         $this->assertEquals("$95.00", $order->getCalculatedPrice()->toString());
  16.     }
  17.  
  18.     public function testGetDiscountedDifference()
  19.     {
  20.         $order = new WholesaleOrder();
  21.         $order->setDiscount(0.05);
  22.         $order->setNumberOfCases(10);
  23.         $order->setPricePerCase(new Money(10.00));
  24.  
  25.         $this->assertEquals("$5.00", $order->getDiscountedDifference()->toString());
  26.     }
  27. }
  28. ?>

私は、中途半端な仕様よりもテストの方が期待している動作がわかるので、テストの方がうれしかったりする。本当は、ユニットテストにはどんどんコメントを書いていくのがいいのだが、それも今回は割愛。。

そして、Moneyクラス、WholesaleOrderクラス、ProductTypeクラス、PricingEngineクラス、そして、DiscountStructureFITクラスまで一気にいく。つか、ベタって貼っただけ。。。はい。手抜きですいません。

  1. <?php
  2. // Following site is used as reference
  3. // http://www-06.ibm.com/jp/developerworks/java/060317/j_j-cq02286.shtml
  4.  
  5. require_once 'Testing/FIT/Fixture/Column.php';
  6.  
  7. // {{{ Money Class
  8. class Money
  9. {
  10.     public $money;
  11.     public function __construct($money = 0)
  12.     {
  13.         $this->money = floatval($money);
  14.     }
  15.  
  16.     public function __toString()
  17.     {
  18.         return sprintf("$%.2f", $this->money);
  19.     }
  20.  
  21.     // バージョンによって、echoとprintのときだけとか。なので、一応。
  22.     public function toString()
  23.     {
  24.         return $this->__toString();
  25.     }
  26.  
  27.     public static function parse($value)
  28.     {
  29.         return new Money(str_replace("$", "", $value));
  30.     }
  31.  
  32.     public function mpy($value)
  33.     {
  34.         $result = null;
  35.         // Moneyオブジェクトの場合
  36.         if ($value instanceof Money) {
  37.             $result = $this->money * $value->money;
  38.         } else if(is_numeric($value)) {
  39.             $result = $this->money * $value;
  40.         } else {
  41.             // TODO
  42.         }
  43.         return new Money($result);
  44.     }
  45.  
  46.     public function sub($value)
  47.     {
  48.         $result = null;
  49.         // Moneyオブジェクトの場合
  50.         if ($value instanceof Money) {
  51.             $result = $this->money - $value->money;
  52.         } else if(is_numeric($value)) {
  53.             $reslut = $this->money - $value;
  54.         } else {
  55.             // TODO
  56.         }
  57.         return new Money($result);
  58.     }
  59. }
  60. // }}}
  61.  
  62. // {{{ WholesaleOrder Class
  63. class WholesaleOrder
  64. {
  65.     private $numberOfCases;
  66.     private $productType;
  67.     private $pricePerCase;
  68.     private $discount;
  69.  
  70.     public function getDiscount()
  71.     {
  72.         return floatval($this->discount);
  73.     }
  74.  
  75.     public function setDiscount($discount)
  76.     {
  77.         $this->discount = floatval($discount);
  78.     }
  79.  
  80.     public function getCalculatedPrice()
  81.     {
  82.         $money = new Money($this->pricePerCase);
  83.         $totalPrice = $money->mpy($this->numberOfCases);
  84. //        $totalPrice = $this->pricePerCase->mpy($this->numberOfCases);
  85.         $tmpPrice = $totalPrice->mpy($this->discount);
  86.         return $totalPrice->sub($tmpPrice);
  87.     }
  88.  
  89.     public function getDiscountedDifference()
  90.     {
  91.         $money = new Money($this->pricePerCase);
  92.         $totalPrice = $money->mpy($this->numberOfCases);
  93. //        $totalPrice = $this->pricePerCase->mpy($this->numberOfCases);
  94.         return $totalPrice->sub($this->getCalculatedPrice());
  95.     }
  96.  
  97.     public function getNumberOfCases()
  98.     {
  99.         return $this->numberOfCases;
  100.     }
  101.  
  102.     public function setNumberOfCases($numberOfCases)
  103.     {
  104.         $this->numberOfCases = $numberOfCases;
  105.     }
  106.  
  107.     public function setPricePerCase($pricePerCase)
  108.     {
  109.         $this->pricePerCase = $pricePerCase;
  110.     }
  111.  
  112.     public function getPricePerCase()
  113.     {
  114.         return $this->pricePerCase;
  115.     }
  116.  
  117.     public function setProductType($productType)
  118.     {
  119.         $this->productType = $productType;
  120.     }
  121.  
  122.     public function getProductType()
  123.     {
  124.         return $this->productType;
  125.     }
  126. }
  127. // }}}
  128.  
  129. // {{{ ProductType
  130. class ProductType
  131. {
  132.     const SEASONAL = "0";
  133.     const YEAR_ROUND = "1";
  134. }
  135.  
  136. // }}}
  137.  
  138. // {{{ PricingEngine Class
  139. class PricingEngine
  140. {
  141.     // ルールは省略
  142.     public static function applyDiscount($order)
  143.     {
  144.         $discount = 0;
  145.         if ($order->getNumberOfCases() <= 10) {
  146.             if ($order->getProductType() === ProductType::YEAR_ROUND) {
  147.                 $discount = 0.05;
  148.             }
  149.         } else if ($order->getNumberOfCases() <= 50) {
  150.             if ($order->getProductType() === ProductType::YEAR_ROUND) {
  151.                 $discount = 0.12;
  152.             }
  153.         } else if ($order->getNumberOfCases() <= 100) {
  154.             if ($order->getProductType() === ProductType::YEAR_ROUND) {
  155.                 $discount = 0.17;
  156.             } else {
  157.                 $discount = 0.05;
  158.             }
  159.         } else if ($order->getNumberOfCases() <= 200) {
  160.             if ($order->getProductType() === ProductType::YEAR_ROUND) {
  161.                 $discount = 0.20;
  162.             } else {
  163.                 $discount = 0.10;
  164.             }
  165.         }
  166.         $order->setDiscount($discount);
  167.     }
  168. }
  169. // }}}
  170.  
  171. // {{{ DiscountStructureFIT
  172. class DiscountStructureFIT extends Testing_FIT_Fixture_Column
  173. {
  174.     public $listPricePerCase;
  175.     public $numberOfCases;
  176.     public $isSeasonal;
  177.  
  178.     protected $_typeDictionary = array(
  179.         'listPricePerCase' => 'float',
  180.         'numberOfCases' => 'integer',
  181. //        'isSeasonal' => 'boolean', // XXX boolean is NOT IMPLEMENTED
  182.         'isSeasonal' => 'string',
  183.         'discountPrice()' => 'float',
  184.         'discountAmount()' => 'float'
  185.     );
  186.  
  187.     public function discountPrice()
  188.     {
  189.         $order = $this->doOrderCalculation();
  190.         return $order->getCalculatedPrice()->toString();
  191.     }
  192.  
  193.     public function discountAmount()
  194.     {
  195.         $order = $this->doOrderCalculation();
  196.         return $order->getDiscountedDifference()->toString();
  197.     }
  198.  
  199.     public function parse($value)
  200.     {
  201.         return Money::parse($value);
  202.     }
  203.  
  204.     private function doOrderCalculation()
  205.     {
  206.         $order = new WholesaleOrder();
  207.         $order->setNumberOfCases($this->numberOfCases);
  208.         $order->setPricePerCase($this->listPricePerCase);
  209.  
  210.         if ($this->isSeasonal === 'true') {
  211.             $order->setProductType(ProductType::SEASONAL);
  212.         } else {
  213.             $order->setProductType(ProductType::YEAR_ROUND);
  214.         }
  215.  
  216.         PricingEngine::applyDiscount($order);
  217.         return $order;
  218.     }
  219. }
  220.  
  221. //}}}
  222. ?>

ビジネスロジックのPricingEngineのapplyDiscountは、そのままゴリゴリ書いちゃった。また、無理矢理テストが通るように作成してあるので、ツッコミどころ満載なのだが、使って気になるところがいくつか。

  • 現状では、FixtureのTypeにクラスを指定することができない。booleanも指定することができない。stringとintegerとfloatのみ。おそらく将来的にはその辺を対応していないときつそうだ。今回参考にしたIBMの記事で使用しているJavaでは、その辺対応しているようだ。さすが、Java。カタイね。
  • $_typeDictionaryは、冗長な気がする。まぁ、しゃーないんだろうけど。。
  • __toStringがechoもしくは、printのとき以外は、stringで認識してくれないので、自作のtoStringを作成。噂では、5.2.1から認識するとか? って、Testing_FITとは全く関係ないがw
  • 実際にMS WordやらMS Excelからインポートしたわけではないからなんとも言えないが、顧客とテストを協力して行うといっても、顧客に教育する必要がありで、ちゃんとその辺をわかってくれる顧客でないと無理かなー、と。

長々と、ソースを貼り付けただけの記事だったが、こんだけ調べた結果気に入ったので、投票しておいた。 今日が〆切だったのね。無事に通ったようで何より。

しかし、久しぶりにPHPを書いてみたら、変数の書き方とか忘れてしまっていた。。。型を宣言したり、$を書かなかったりと、最初は戸惑ったけど、少し感覚が戻ってきた気がする。

Leave a comment

Bloglines feedburner