26、PHP 代码重构、单元测试与持续集成实践

PHP 代码重构、单元测试与持续集成实践

1. 代码重构的重要性与成果

在软件开发中,代码重构是提升代码质量的关键步骤。经过几次重构后,代码变得更易读、理解、修改和测试,减少了大量重复代码,同时还有进一步优化的空间。例如,以下是部分重构后的代码片段:

{
    $this->src->x += ( BIKE_STEP * cos($this->angle_in_radians));
    $this->src->y += ( BIKE_STEP * sin($this->angle_in_radians));
    ++$this->time;
    print "biking... currently at (" . round($this->src->x, 2) .
            ", " . round($this->src->y, 2) . ")<br/>\n";
}
print "Got to destination by biking<br/>";

private function walk()
{
    //walk
    while (abs($this->src->x - $this->dest->x) > WALK_STEP ||
    abs($this->src->y - $this->dest->y) > WALK_STEP)
    {
        $this->src->x += ( WALK_STEP * cos($this->angle_in_radians));
        $this->src->y += ( WALK_STEP * sin($this->angle_in_radians));
        ++$this->time;
        print "walking... currently at (" . round($this->src->x, 2) .
                ", " . round($this->src->y, 2) . ")<br/>\n";
    }
    print "Got to destination by walking<br/>";
}
2. 单元测试的必要性与原则

为确保代码正常运行,单元测试必不可少。我们期望代码以短块形式存在,减少依赖,以便隔离和测试单个功能单元。为此,应编写松耦合代码,并在必要时使用依赖注入。同时,要尽量保持函数简短,减少参数数量。
函数重构的理想长度是怎样的呢?就像一个类应代表一个对象一样,一个函数应只做一件事。若函数承担多项任务,就应拆分成更小的函数,多数函数长度在 5 到 15 行。函数越小,越易理解,也更不易出现 bug。

3. PHP 单元测试框架

PHP 中广泛使用的单元测试框架有 PHPUnit 和 Simpletest,这里我们选用 PHPUnit。它是 Sebastian Bergmann 编写的 xUnit 移植版本,熟悉 Java 的 JUnit 或 .NET 的 NUnit 的程序员能轻松上手。
- 框架获取地址
- PHPUnit:https://github.com/sebastianbergmann/phpunit/
- Simpletest:www.simpletest.org/
- PHPUnit 手册 :www.phpunit.de/manual/current/en/index.html
- 使用 PEAR 安装 PHPUnit 的命令

pear channel-discover pear.phpunit.de
pear channel-discover components.ez.no
pear channel-discover pear.symfony-project.com
pear install --alldeps phpunit/PHPUnit
4. 编写单元测试的要点

编写单元测试时,应追求快速运行、可重复的测试,以隔离小块代码的功能,这可能需要使用依赖注入和模拟对象等高级技术。PHPUnit 和 Simpletest 都支持模拟对象,模拟对象有助于隔离要测试的代码部分,通过返回模拟结果避免访问数据库、文件或网络位置等慢速资源,从而加快测试速度。

5. 示例:为 Walk 类添加单元测试

下面我们以 Walk 类为例,展示如何添加单元测试。首先创建面向对象的 Walk 类:

<?php

class Walk
{

    private $option_keys = array(
             'ownADog', 'tired', 'haveNotWalkedForDays', 'niceOutside', 'bored');
    private $options = array();

    public function __construct()
    {
        foreach ($this->option_keys as $key) {
            $this->options[$key] = true;
        }
    }

    public function move()
    {
        if ($this->shouldWalk()) {
            $this->goForAWalk();
        }
    }

    public function shouldWalk()
    {
        return ($this->timeToWalkTheDog() || $this->feelLikeWalking());
    }

    public function timeToWalkTheDog()
    {
        return ($this->options['ownADog'] &&
               (!$this->options['tired'] || $this->options['haveNotWalkedForDays']));
    }

    public function feelLikeWalking()
    {
        return (($this->options['niceOutside'] && !$this->options['tired']) ||
                 $this->options['bored']);
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->option_keys)) {
            $this->options[$name] = $value;
        }
    }

    private function goForAWalk()
    {
        echo "Going for a walk";
    }
}

//$walk = new Walk();
//$walk->move();
?>

然后创建单元测试骨架:

<?php

require_once dirname(__FILE__) . '/../walk.php';

/**
 * Test class for Walk.
 * Generated by PHPUnit on 2011-05-31 at 19:57:43.
 */
class WalkTest extends PHPUnit_Framework_TestCase
{

    /**
     * @var Walk
     */
    protected $object;

    /**
     * Sets up the fixture, for example, opens a network connection.
     * This method is called before a test is executed.
     */
    protected function setUp()
    {
        $this->object = new Walk;
    }

    /**
     * Tears down the fixture, for example, closes a network connection.
     * This method is called after a test is executed.
     */
    protected function tearDown()
    {

    }
}
?>

接下来添加一个会失败的单元测试:

<?php

require_once dirname(__FILE__) . '/../walk.php';

class WalkTest extends PHPUnit_Framework_TestCase
{

    protected $object;

    protected function setUp()
    {
        $this->object = new Walk;
    }

    protected function tearDown()
    {

    }

    public function testTimeToWalkTheDog_default()
    {
        print "testTimeToWalkTheDog_default";
        $this->assertTrue(!$this->object->timeToWalkTheDog());
    }
}

?>

由于默认选项中 ownADog haveNotWalkedForDays 都为 true ,所以 $this->object->timeToWalkTheDog() 应返回 true ,上述测试会失败。我们调整测试使其通过,并添加第二个测试:

<?php

require_once dirname(__FILE__) . '/../walk.php';

class WalkTest extends PHPUnit_Framework_TestCase
{

    protected $object;

    protected function setUp()
    {
        $this->object = new Walk;
    }

    protected function tearDown()
    {

    }

    public function testTimeToWalkTheDog_default_shouldReturnTrue()
    {
        print "testTimeToWalkTheDog_default";
        $this->assertTrue($this->object->timeToWalkTheDog());
    }

    public function testTimeToWalkTheDog_haveNoDog_shouldReturnFalse()
    {
        print "testTimeToWalkTheDog_default";
        $this->object->ownADog = false;
        $this->assertTrue(!$this->object->timeToWalkTheDog());
    }
}

?>

通过这些测试,我们可以看到单元测试能帮助我们发现代码中的问题。

6. 代码覆盖率与测试类型

代码覆盖率是指已测试代码的百分比,但即使代码覆盖率达到 100%,程序仍可能失败,因为各个部分可能正常工作,但整体程序可能存在问题。为测试整个程序,需要进行功能测试。单元测试和功能测试都属于回归测试,回归测试会定期运行,以确保在功能增强、修复 bug 或更改配置后不会引入新错误。

7. 为 TravelMath 类添加单元测试

下面是为 TravelMath 类添加的完整单元测试:

<?php

require_once dirname(__FILE__) . '/../TravelMath.php';
require_once 'PHPUnit/Autoload.php';

/**
 * TravelMath test case.
 */
class TravelMathTest extends PHPUnit_Framework_TestCase {

    /**
     * Prepares the environment before running a test.
     */
    protected function setUp() {
        parent::setUp ();
    }

    /**
     * Cleans up the environment after running a test.
     */
    protected function tearDown() {
        parent::tearDown ();
    }

    /**
     * Constructs the test case.
     */
    public function __construct() {
        // TODO Auto-generated constructor
    }

    public function testCalculateDistance_no_difference() {
        $src = new Location(3, 7);

        $expected = 0;
        $actual = TravelMath::calculateDistance($src, $src);
        $this->assertEquals($expected, $actual);
    }

    public function testCalculateDistance_no_y_change() {
        $src = new Location(5, 7);
        $dest = new Location(3, 7);

        $expected = 2;
        $actual = TravelMath::calculateDistance($src, $dest);
        $this->assertEquals($expected, $actual);
    }

    public function testCalculateDistance_no_x_change() {
        $src = new Location(3, 10);
        $dest = new Location(3, 7);

        $expected = 3;
        $actual = TravelMath::calculateDistance($src, $dest);
        $this->assertEquals($expected, $actual);
    }

    public function testCalculateDistance_x_and_y_change() {
        $src = new Location(6, 7);
        $dest = new Location(3, 11);

        $expected = 5;
        $actual = TravelMath::calculateDistance($src, $dest);
        $this->assertEquals($expected, $actual, '', 0.01);
    }

    public function testCalculateAngleInDegrees_moving_nowhere() {
        $src = new Location(3, 7);

        $expected = null;
        $actual = TravelMath::calculateAngleInDegrees($src, $src);
        $this->assertEquals($expected, $actual);
    }

    public function testCalculateAngleInDegrees_moving_straight_up() {
        $src = new Location(3, 7);
        $dest = new Location(3, 12);

        $expected = 90.0;
        $actual = TravelMath::calculateAngleInDegrees($src, $dest);
        $this->assertEquals($expected, $actual);
    }

    public function testCalculateAngleInDegrees_moving_straight_down() {
        $src = new Location(3, 12);
        $dest = new Location(3, 7);

        $expected = -90.0;
        $actual = TravelMath::calculateAngleInDegrees($src, $dest);
        $this->assertEquals($expected, $actual);
    }

    public function testCalculateAngleInDegrees_moving_straight_left() {
        $src = new Location(6, 7);
        $dest = new Location(3, 7);

        $expected = 180.0;
        $actual = TravelMath::calculateAngleInDegrees($src, $dest);
        $this->assertEquals($expected, $actual);
    }
    public function testCalculateAngleInDegrees_moving_straight_right() {
        $src = new Location(3, 7);
        $dest = new Location(6, 7);

        $expected = 0.0;
        $actual = TravelMath::calculateAngleInDegrees($src, $dest);
        $this->assertEquals($expected, $actual);
    }

    public function testCalculateAngleInDegrees_moving_northeast() {
        //random values where both $x2 != $x1 and $y2 != $y1
        $x1 = rand(-25, 15);
        $y1 = rand(-25, 25);
        $x2 = rand(-25, 25);
        $y2 = rand(-25, 25);

        while ($x2 == $x1) {
            $x2 = rand(-25, 25);
        }
        while ($y2 == $y1) {
            $y2 = rand(-25, 25);
        }

        $src = new Location($x1, $y1);
        $dest = new Location($x2, $y2);

        $expected = rad2deg(atan(($y2 - $y1) / ($x2 - $x1)));
        $actual = TravelMath::calculateAngleInDegrees($src, $dest);
        $this->assertEquals($expected, $actual,  '', 0.01);
    }

    public function testIsCloseToDest_x_too_far_should_fail() {
        $src = new Location(3, 9);
        $dest = new Location(3.5, 7);
        $step = 1.0;

        $expected = false;
        $actual = TravelMath::isCloseToDest($src, $dest, $step);
        $this->assertEquals($expected, $actual);
    }

    public function testIsCloseToDest_y_too_far_should_fail() {
        $src = new Location(4.5, 7.5);
        $dest = new Location(3.5, 7);
        $step = 1.0;

        $expected = false;
        $actual = TravelMath::isCloseToDest($src, $dest, $step);
        $this->assertEquals($expected, $actual);
    }

    public function testIsCloseToDest_should_pass() {
        $src = new Location(3, 7.5);
        $dest = new Location(3.5, 7);
        $step = 1.0;

        $expected = true;
        $actual = TravelMath::isCloseToDest($src, $dest, $step);
        $this->assertEquals($expected, $actual);
    }
}
?>

运行这些测试后,可能会发现一些意外错误。通过检查失败的方法,我们可以找出问题所在并进行修复。例如,前两个错误是由于未返回一维距离的绝对值,可通过以下修改修复:

if ($dest->y == $src->y) {
    $distance = abs($dest->x - $src->x);
} else if ($dest->x == $src->x) {
    $distance = abs($dest->y - $src->y);

第三个错误是因为 atan 函数返回的是弧度,而我们期望的是度数,可使用 rad2deg 函数修复:

$angle = rad2deg(atan($distance_y / $distance_x));

修改后重新运行测试,可验证问题是否解决。

8. 最终代码与测试套件

经过不断重构和测试,我们得到了最终的代码。以下是 TravelView 类和 Travel 类的最终版本:

<?php

error_reporting(E_ALL);
require_once ('config.php');
require_once ('location.php');

class TravelView {

    public static function displayOurIntendedPath( $angle, $distance,  
                                                   Location $src, Location $dest) {
        print "Trying to go from " . $src->toString() . " to " .  
              $dest->toString() . "<br/>\n";
        if (IN_A_RUSH) {
            print "<strong>In a rush</strong><br/>\n";
        }
        print "Distance is " . $distance . " in the direction of " .  
              $angle . " degrees<br/>";
    }

    public static function displaySummary($time) {
        print "Total time was: " . date("i:s", $time);
    }

    public static function displayError($error){
            print "ERROR: ".$error. "<br/>";
    }

    public static function displayLocationStatusMessage($method, $x, $y){
            print $method . “… currently at (" .  
                  round($x, 2). "  " .  
                  round($y, 2). ")<br/>\n";
    }
     public static function displayArrived($message){
         print "Got to destination by " . strtolower($message). "<br/>";
     }

}
?>
<?php

error_reporting(E_ALL);
require_once('config.php');
require_once('location.php');
require_once('travelView.php');
require_once('travelMath.php');

class Travel {

    private $distance = null;
    private $angle = 0.0;
    private $angle_in_radians = 0.0;
    private $time = 0.0;
    private $src = 0.0;
    private $dest = 0.0;

    public function __construct() {
        $this->distance = new Location(0, 0);
    }

    public function execute(Location $src, Location $dest) {
        $this->src = $src;
        $this->dest = $dest;

        $this->calculateAngleAndDistance();
        TravelView::displayOurIntendedPath( $this->angle, $this->distance,  
                                            $this->src, $this->dest);

        if ($this->doWeHaveOptions ()) {
            $this->pickBestOption ();
        } else {
            $this->tryToWalkThere ();
        }
        TravelView::displaySummary($this->time);
    }

    public function calculateAngleAndDistance() {
        $this->angle = TravelMath::calculateAngleInDegrees($this->src, $this->dest);
        $this->angle_in_radians = deg2rad($this->angle);
        $this->distance = TravelMath::calculateDistance($this->src, $this->dest);
    }

    public function tryToWalkThere() {
        if (STORMY_WEATHER) {
            TravelView::displayError("Storming");
        } else if ($this->distance < WALKING_MAX_DISTANCE) {
            $this->walk ();
        } else {
            TravelView::displayError("Too far to walk");
        }
    }

    public function pickBestOption() {
        if (STORMY_WEATHER) {
            $this->takeFastestVehicle ();
        } else {
            if ($this->distance < WALKING_MAX_DISTANCE && !IN_A_RUSH) {
                $this->walk();
            } else {
                $this->takeFastestVehicle ();
            }
        }
    }

    private function takeFastestVehicle() {
        if (HAS_CAR) {
            $this->driveCar ();
        } else if (HAS_MONEY && ON_BUS_ROUTE) {
            $this->rideBus ();
        } else {
            $this->rideBike ();
        }
    }

    private function doWehaveOptions() {

        $has_options = false;
        if (HAS_CAR || (HAS_MONEY && ON_BUS_ROUTE) || HAS_BIKE) {
            $has_options = true;
        }
        return $has_options;
    }

    private function move($step, $message) {
        while (!TravelMath::isCloseToDest($this->src, $this->dest, $step)) {
            $this->moveCloserToDestination($step, $message);
        }
        TravelView::displayArrived($message);
    }

    private function driveCar() {
        $this->time = CAR_DELAY;
        $this->move(CAR_STEP, "Driving a Car");
    }

    private function rideBus() {
        $this->time = BUS_DELAY;
        $this->move(BUS_STEP, "On the Bus");
    }

    private function rideBike() {
        $this->move(BIKE_STEP, "Biking");
    }

    private function walk() {
        $this->move(WALK_STEP, "Walking");
    }

    private function moveCloserToDestination($step, $method) {
        $this->src->x += ( $step * cos($this->angle_in_radians));
        $this->src->y += ( $step * sin($this->angle_in_radians));
        ++$this->time;
        TravelView::displayLocationStatusMessage($method, $this->src->x, $this->src->y);
    }

}
?>

如果要为 Travel 类添加测试,可以将所有测试添加到一个测试套件中:

<?php

error_reporting(E_ALL ^ ~E_NOTICE);
require_once 'PHPUnit/Autoload.php';
require_once 'travelMathTest.php';
require_once 'travelTest.php';

class AllTests
{
    public static function suite()
    {
        $suite = new PHPUnit_Framework_TestSuite('Travel Test Suite');
        $suite->addTestSuite('TravelTest');
        $suite->addTestSuite('TravelMathTest');
        return $suite;
    }
}

?>
9. 示例代码调用

最后,我们可以调用修改后的代码进行测试:

<?php

error_reporting(E_ALL);
require_once ('travel.php');

$travel = new Travel();
$travel->execute(new Location(1, 3), new Location(4,7));

?>

运行上述代码(将 IN_A_RUSH 配置标志设置为 false ),输出如下:

Trying to go from (1, 3) to (4, 7)
Distance is 5 in the direction of 53.130102354156 degrees
Walking... currently at (1.15, 3.2)
Walking... currently at (1.3, 3.4)
Walking... currently at (1.45, 3.6)
Walking... currently at (1.6, 3.8)
Walking... currently at (1.75, 4)
Walking... currently at (1.9, 4.2)
Walking... currently at (2.05, 4.4)
Walking... currently at (2.2, 4.6)
Walking... currently at (2.35, 4.8)
Walking... currently at (2.5, 5)
Walking... currently at (2.65, 5.2)
Walking... currently at (2.8, 5.4)
Walking... currently at (2.95, 5.6)
Walking... currently at (3.1, 5.8)
Walking... currently at (3.25, 6)
Walking... currently at (3.4, 6.2)
Walking... currently at (3.55, 6.4)
Walking... currently at (3.7, 6.6)
Walking... currently at (3.85, 6.8)
Got to destination by walking
Total time was: 00:19
10. 测试驱动开发(TDD)与项目状态

单元测试和重构相辅相成,测试驱动开发(TDD)更是进一步要求在编写新代码前先编写单元测试。TDD 的基本原则如下:
1. 编写测试。
2. 由于尚未编写满足测试的代码,测试失败。
3. 实现使测试通过的最少功能。
4. 重复上述步骤。

在开发中,我们可能遇到两种项目状态:
1. 启动新项目或为已有 100% 测试覆盖率的项目添加功能,此时可安全使用 TDD 并持续进行测试和重构。
2. 处理遗留代码,这可能是未测试的开源项目、继承的公司代码或自己未测试的代码。虽然修改遗留代码可能会带来意外行为,但应尽早进行小幅度的频繁重构。即使只有少量单元测试,也比没有要好,随着测试覆盖率的提高,代码会变得更加稳定,进而促进更多的重构和测试创建。

PHP 开发者正逐渐采用更严格的“企业级”代码标准,包括更强的测试和开发规范。通过不断实践代码重构和单元测试,我们能提高代码质量,降低维护成本,提升开发效率。

PHP 代码重构、单元测试与持续集成实践

11. 单元测试在不同环境下的输出差异

当我们使用 PHPUnit 时,在不同的环境(如 IDE、命令行或浏览器)中运行测试,输出结果会有所不同。例如,在 Netbeans IDE、Zend Studio 以及命令行中运行 PHPUnit 测试,其输出的样式和信息侧重点各有特点。
- Netbeans IDE :通常会以直观的红/绿条形式展示测试结果,清晰地表明测试的成功或失败。还能显示代码覆盖率的详细信息,以突出显示哪些代码行已被测试覆盖。
- Zend Studio :提供了特定的输出界面,方便开发者查看测试结果和相关信息。
- 命令行 :以文本形式输出测试结果,适合自动化脚本或在没有图形界面的环境中使用。

下面通过图表展示不同环境下的输出示例:
| 环境 | 输出特点 | 示例 |
| — | — | — |
| Netbeans IDE | 红/绿条显示结果,代码覆盖率可视化 | 如图 13 - 1、13 - 2、13 - 3、13 - 4 所示 |
| Zend Studio | 特定输出界面 | 如图 13 - 5 所示 |
| 命令行 | 文本形式输出 | 如图 13 - 6 所示 |

12. 遗留代码处理的挑战与策略

处理遗留代码是许多开发者面临的常见问题。遗留代码往往缺乏测试,依赖关系复杂,修改时容易引发意外行为。但不能因此而害怕修改,以下是一些处理遗留代码的策略:
- 逐步重构 :不要一次性进行大规模的修改,而是频繁进行小幅度的重构。每次只处理一小部分代码,降低引入新问题的风险。
- 添加单元测试 :即使是少量的单元测试也能带来很大的帮助。从简单的功能开始,逐步增加测试覆盖率,为后续的重构提供保障。
- 打破依赖 :分析代码中的依赖关系,尝试将紧密耦合的部分解耦,以便更方便地进行测试和修改。

13. 持续集成与代码稳定性

持续集成(CI)是一种软件开发实践,通过频繁地将代码集成到共享仓库,并自动运行测试,及时发现和解决问题,确保代码的稳定性。结合单元测试和持续集成,可以实现以下优势:
- 快速反馈 :每次代码提交后,自动运行测试,开发者能迅速得知代码是否引入了新的问题。
- 问题预防 :在代码集成到主分支之前,发现并修复潜在的问题,避免问题积累。
- 提高协作效率 :团队成员可以更放心地进行代码修改,因为有自动化测试的保障。

以下是一个简单的持续集成流程 mermaid 流程图:

graph LR
    A[代码提交] --> B[触发 CI 流程]
    B --> C[拉取代码]
    C --> D[安装依赖]
    D --> E[运行单元测试]
    E --> F{测试是否通过}
    F -- 是 --> G[部署或合并代码]
    F -- 否 --> H[通知开发者修复问题]
    H --> A
14. 代码重构与测试的长期收益

代码重构和单元测试虽然在短期内可能会增加开发时间,但从长期来看,能带来显著的收益:
- 提高代码可维护性 :重构后的代码结构更清晰,函数和类的职责更单一,便于后续的修改和扩展。
- 降低维护成本 :通过单元测试及时发现和修复问题,减少了后期调试和维护的工作量。
- 增强团队协作 :清晰的代码和完善的测试有助于团队成员更好地理解和协作开发。

15. 实际项目中的应用建议

在实际项目中,要有效地应用代码重构和单元测试,可以参考以下建议:
- 制定计划 :在项目开始时,制定代码重构和测试的计划,明确各个阶段的目标和任务。
- 培训团队成员 :确保团队成员掌握代码重构和单元测试的技能,理解其重要性。
- 持续改进 :随着项目的进展,不断评估和调整重构和测试策略,以适应项目的变化。

16. 总结

代码重构、单元测试和持续集成是提高 PHP 代码质量的重要手段。通过合理的代码重构,我们可以使代码更易读、易维护;单元测试能帮助我们及时发现和解决代码中的问题,确保代码的正确性;持续集成则为我们提供了快速反馈和问题预防的机制。

无论是处理新的项目还是遗留代码,都应该重视代码重构和单元测试。遵循测试驱动开发(TDD)的原则,逐步提高代码的测试覆盖率,不断优化代码结构。随着 PHP 开发者对“企业级”代码标准的重视,我们有理由相信,通过不断实践和改进,我们的代码将更加稳定、可靠,开发效率也将得到显著提升。

希望本文能为 PHP 开发者在代码重构和单元测试方面提供有益的参考,帮助大家在实际项目中更好地应用这些技术,提升代码质量和开发效率。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值