PHP:CSI 以便士计价
前几天我正在查看一些故障代码,其中价格从一个API服务中提取并发送到另一个API。问题源于这样一个事实,即第一个API的值是一个字符串,而第二个API要求将便士价格作为一个整数。
此处的格式差异意味着必须将数字从一种格式转换为另一种格式。在此过程中,发现有时会超出一便士的价值。
例如,当第一个API发送的价值为20.40英镑时,第二个API收到的值为2039,即一分钱。这个类确实有一些单元测试,但是这些测试并没有考虑到这个舍入错误。
事实证明,这并不是所讨论的类的唯一问题,所以我想我会写一个快速的PHP:CSI来展示这些问题以及我是如何解决它们的。
这是类,我已经更改了类的名称并删除了其他一些细节,但基本功能已经到位。
price = $price; } /** * Get the account price. * * @return string * Price as a string with decimal point but without £ prefix. */ public function getPrice() { return $this->price; } /** * Get the account price in pence. * * @return int * Price as an integer without £ prefix. */ public function getPriceInPence() { //如果存在,请去除任何千位分隔符。 if (is_string($this->price)) { $this->price = (float) str_replace(',', '', $this->price); } //将英镑值乘以100便士。 $this->price *= 100; return (int) $this->price; } }
这门课对我说的第一件事是“我有一段漫长而丰富多彩的历史”。果然,查看git注释显示,这个类(尤其是getPriceInPence()方法)自第一次编写以来至少被四个不同的开发人员更改过。每个解决一个问题,确保单元测试仍然有效并继续进行。
解决舍入误差
我在开头谈到的舍入误差是由浮点精度问题引起的,该问题通过将数字转换为整数而暴露出来。当字符串乘以100时,PHP将其存储为浮点值。在我们将浮点数转换为整数后,我们丢失了一些信息,并且数字少了一位。
我发现可以通过将PHP中的精度设置设置为一个高值来提高浮点打印的精度,比如17。有了他,当我们打印出浮点使用var_dump()并查看值的完整精度时。由此我们可以清楚地看到问题是从哪里引入的。
这是上面数字的演示,其中我们认为是2040的数字会导致2039。
ini_set('precision', 17); $val = '20.40' * 100; var_dump($val); //2039.9999999999998 var_dump((int) $val); //2039
由于班级有一些单元测试,我决定看看他们在测试什么。单元测试有几个这样的例子,其中一个值被设置然后被检索。
public function testAccountPriceInPenceFromPriceWithCommas() { $account = new Amount(); $account->setPrice('1,000,000'); $this->assertEquals('1,000,000', $account->getPrice()); $this->assertEquals(100000000, $account->getPriceInPence()); }
不幸的是,被测试的值没有暴露舍入错误,所以一切似乎都有效。由于有几个测试看起来像上面的例子,我决定删除它们并用数据提供者替换它们。
要在PHPUnit中设置数据提供者,我们在doc块区域中使用@dataProvider批注。this的值是将成为数据提供者的函数,在本例中为getAmounts()。发送到测试方法的参数在数据提供程序函数中设置。
这是新的测试方法,重写为数据提供者。
/** * Test different prices against Amounts. * * @dataProvider getAmounts */ public function testPriceAmounts($price, $priceInPence) { $amount = new Amount(); $amount->setPrice($price); $this->assertEquals($price, $amount->getPrice()); $this->assertEquals($priceInPence, $amount->getPriceInPence()); }
数据提供程序函数需要返回一个数组,然后将数组中的每一项映射到被测试方法的参数。在testPriceAmount()上面的方法中,$price的第一组参数为“0.01”,$priceInPence的参数为1。我用我在测试类中找到的测试值填充了数组,并添加了一些我知道会因舍入错误而中断的示例。
public function getAmounts() { return [ ['0.01', 1], ['0.10', 10], ['1.00', 100], ['1,000.00', 100000], ['20.40', 2040], ['132.14', 13214], ['516.80', 51680], ['143.14', 14314], ['155.92', 15592], ['80.60', 8060], ['280.40', 28040], ['80.35', 8035], ['40.48', 4048], ['140.67', 14067], ['39.91', 3991], ]; }
事实上,此时运行测试意味着前4个项目通过了,但其他一切都失败了。这很好,因为它为我提供了一个能够解决问题并确保我没有后退的平台。
为了纠正Amount类中的舍入错误,我们只需要在将值转换为整数之前对其进行舍入。这可能看起来不直观,但这会从浮点数中删除精度,以便在将其转换为整数时不会丢失数据。这是上面带有解决方案的代码,表明我们没有更改数字。
ini_set('precision', 17); $val = '20.40' * 100; var_dump($val); //2039.9999999999998 var_dump((int) $val); //2039 var_dump((int) round($val)); //2040
考虑到这一点,我们现在可以更改原始方法以更正舍入问题。
public function getPriceInPence() { //如果存在,请去除任何千位分隔符。 if (is_string($this->price)) { $this->price = (float) str_replace(',', '', $this->price); } //将英镑值乘以100便士。 $this->price *= 100; return (int) round($this->price); }
现在重新运行测试表明一切都通过了!好的。
修复危险代码
现在我们可以继续修复Amount类中一些更危险的代码。
你们中的一些人可能已经发现了这一点,但是当该getPriceInPence()方法被调用时,它实际上改变了price$price变量的值。这意味着如果我们getPrice()在调用之后调用该方法,getPriceInPence()我们将获得与预期不同的值。此外,我们称之为getPriceInPence()$price值越大。
下面是一个实际问题的例子。
$amount = new Amount(); $amount->setPrice('2040'); echo "Price :" . $amount->getPrice(); //2040 echo "Price In Pence :" . $amount->getPriceInPence(); //204000 echo "Price :" . $amount->getPrice(); //204000 echo "Price In Pence :" . $amount->getPriceInPence(); //20400000 echo "Price :" . $amount->getPrice(); //20400000
查看代码库,我找不到以这种方式使用这些函数的任何地方,但我确信问题出现只是时间问题。这里的问题是函数的名称是getPriceInPence(),因此它应该只返回以便士为单位的价格值,而不改变类中的任何内容。因此,我决定增加测试覆盖率并尝试在它出现之前解决这个问题。
我们上面添加的单元测试可以在这里更新,以确保我们在调用之前和之后测试价格的值getPriceInPence()。由于原始价格值是一个可能用逗号格式化的字符串,我们还需要向该方法添加一个额外的参数。
/** * Test different prices against Amounts. * * @dataProvider getAmounts */ public function testPriceAmounts($price, $storedPrice, $priceInPence) { $amount = new Amount(); $amount->setPrice($price); $this->assertEquals($storedPrice, $amount->getPrice()); $this->assertEquals($priceInPence, $amount->getPriceInPence()); $this->assertEquals($storedPrice, $amount->getPrice()); }
我们现在可以更新getAmounts()数据提供者以显示转换后的价格值。
public function getAmounts() { return [ ['0.01', 0.01, 1], ['0.10', 0.10, 10], ['1.00', 1.00, 100], ['1,000.00', 1000.00, 100000], ['20.40', 20.40, 2040], //剪断 ]; }
这会导致所有测试至少失败一次,但由于我们仅在getPriceInPence()方法中格式化价格,因此价格值'1,000'的测试失败。
处理传入价格格式的更好方法是将值从字符串更改为浮点数。这意味着每次类接受价格值时,我们都可以确保它符合正确的数据类型。我们可以通过将str_replace()函数从getPriceInPence()方法移动到setPrice()方法中,然后将我们收到的值转换为浮点数来实现。
public function setPrice($price) { $price = str_replace(',', '', $price); return $this->price = (float) $price; }
解决了这个问题后,我们现在可以重新审视该getPriceInPence()方法并将其缩减为一行。
public function getPriceInPence() { //将英镑值乘以100便士。 return (int)round($this->price * 100); }
有了它,现在所有的巢都通过了。这意味着在使用该类时,价格值不会意外地发生变化。
格式化价格
这里的最后一部分是确保我们可以取回传递给类的原始值,因为它在代码库的其他地方使用过。我对代码库进行了快速扫描,发现在几个实例中,这个类被用来向用户显示价格,但每次都复制代码以正确打印数字。
使用number_format()PHP中的函数可以轻松完成数字的格式化。添加到类中的新方法允许我们执行此操作。
public function getFormattedPrice() { return number_format($this->price, 2); }
可以修改单元测试以测试此方法以及所做的其余更改。
/** * Test different prices against Amounts. * * @dataProvider getAmounts */ public function testPriceAmounts($price, $storedPrice, $priceInPence) { $amount = new Amount(); $amount->setPrice($price); $this->assertEquals($price, $amount->getFormattedPrice()); $this->assertEquals($storedPrice, $amount->getPrice()); $this->assertEquals($priceInPence, $amount->getPriceInPence()); $this->assertEquals($storedPrice, $amount->getPrice()); }
运行后,我们可以看到所有测试都通过了。这意味着我们现在可以以标准化的方式向用户显示原始价格。通过重写打印价格的方式,我能够稍微减少项目其余部分的重复代码级别。
教训
虽然这里有很多东西要解开,但我希望我在这篇文章中展示的步骤表明我并没有直接进入并开始改变事物。在更改之前,我首先确保我正在为即将更改的内容编写测试。生成的代码现在比原始代码更健壮和可预测。
然而,我们可以从这个练习中得到一些关键点。
不要假设因为一个类有测试,那么该类就完全有效。
不要更改名为“get”的函数中的类属性。或者,请注意您命名方法的名称,因为它们会向开发人员传达该方法的作用。
不要只是孤立地看待类。它们应该被视为它们所包含的生态系统的一部分,因此对类所做的任何更改都可能会产生连锁反应。幸运的是,在我的例子中,使用这个类的上游代码也有单元测试。
留意许多开发人员已经进行了大量更改的代码。它往往指向糟糕的单元测试或难以理解的东西。
在单元测试类中尽可能多地使用数据提供程序。这允许您插入更多边缘情况而无需复制和粘贴测试方法,因为它们只是数据提供者上的额外行。
最后,在开发过程中让事情变得比你发现的更好是一种很好的做法。我按照上述步骤简化了单个类,从而减少了其他开发人员的困惑,并减少了维护开销。