返回

修复 Selenium PHP Verify 失败:找回错误行号与堆栈跟踪

php

Selenium PHP Verify 失败?找回丢失的错误行号和堆栈跟踪

搞自动化测试的时候,特别是用 Selenium IDE 导出的 PHPUnit 代码,你可能遇到过一个头疼的问题:verify 类型的断言(比如 verifyTextverifyTitle)失败时,错误报告里压根不告诉你具体是哪一行代码出了问题。这就很麻烦了,测试用例一长,找个错误点跟大海捞针似的。

来看看 Selenium IDE 生成的典型代码片段:

// 例子:验证页面标题
try {
    $this->assertEquals($expectedTitle, $this->getTitle());
} catch (PHPUnit_Framework_AssertionFailedError $e) {
    array_push($this->verificationErrors, $e->toString());
}

// 例子:验证页面是否存在某文本
try {
    $this->assertRegExp($pattern, $this->getBodyText());
} catch (PHPUnit_Framework_AssertionFailedError $e) {
    array_push($this->verificationErrors, $e->toString());
}

这种代码的问题在于,catch 块仅仅捕获了断言失败的异常 (PHPUnit_Framework_AssertionFailedError),然后把异常自带的、非常笼统的错误信息 ($e->toString()) 塞进了一个叫 $this->verificationErrors 的数组里。最后测试跑完,统一报告这个数组里的内容。结果就是你看到的输出,像是下面这样:

Failed asserting that '' matches PCRE pattern "/ExpectedText/".
Failed asserting that two strings are equal. // 哪个标题或文本的断言?天知道!
Failed asserting that '(Actual Text)' matches PCRE pattern "/\(Expected Pattern\)/".

这种输出只告诉你 什么 错了(比如,两个字符串不相等),但完全没提 哪里 错了。虽然你可能像提问者那样,修改代码把期望值和实际值加到错误信息里,像这样:

public function verifyTitle($text) {
    $title = $this->getTitle();
    try {
        $this->assertEquals($text, $title);
    } catch (PHPUnit_Framework_AssertionFailedError $e) {
        // 比原来好点,但还是没行号
        array_push($this->verificationErrors,
             "Title verify failed: Expected '$text' but got '$title'");
    }
}

这确实能让你通过搜索特定的文本来定位失败,但在大型测试集里,同一种验证(比如检查标题)可能在很多地方用到,光靠文本内容还是不够高效,我们真正需要的是 失败代码行的具体位置

问题根源:异常被“温柔”处理了

简单来说,问题出在 try...catch 身上。PHPUnit 的断言方法(assertEquals, assertRegExp 等)在失败时会抛出一个 PHPUnit_Framework_AssertionFailedError 异常。

  • 如果是 assert (硬断言),这个异常没被捕获的话,PHPUnit 会中断当前测试,并报告失败,报告里会包含完整的堆栈跟踪(Stack Trace),其中就包含了失败代码的行号。
  • 但是,对于 verify (软断言),我们特意用了 try...catch 把这个异常“接住”了。接住之后,代码并没有重新抛出异常,而是仅仅记录了一条信息到 $verificationErrors 数组。这就导致 PHPUnit 认为这个测试步骤本身是“通过”的(因为它没接收到未捕获的异常),自然也就不会打印出那个包含行号信息的详细堆栈跟踪了。我们只在测试末尾手动处理了 $verificationErrors,而此时原始的、包含位置信息的异常对象已经被丢弃,只剩下一段我们自己格式化的字符串。

提问者提到的 "stacktrace hack" 通常是指修改 PHPUnit 的配置或输出方式,强制让 所有 失败(即使是被捕获的)都显示堆栈跟踪,这通常针对硬断言 assert 的报告方式。但我们的问题在于 verify 的实现逻辑本身就丢失了这些信息。

解决方案:让错误信息带上“地址”

我们需要修改 verify 方法的 catch 块,不能只记录 $e->toString(),要把更有用的信息,特别是堆栈跟踪,也记录下来。

方法一:直接记录异常的堆栈跟踪

最直接的方法是在 catch 块里获取并记录异常对象的堆栈跟踪信息。

原理: PHP 的异常对象($e)自带了发生异常时的完整调用堆栈。我们可以通过 $e->getTraceAsString() 方法获取格式化好的堆栈跟踪字符串。

操作步骤:

修改你的自定义 verify 方法(或者 Selenium IDE 生成的 try...catch 块),在 catch 部分获取堆栈跟踪。

代码示例 (verifyTitle 的改进版本):

<?php

use PHPUnit\Framework\TestCase; // 或者继承自 Selenium 的 TestCase 类
use PHPUnit\Framework\AssertionFailedError as PHPUnit_Framework_AssertionFailedError; // 兼容旧版命名

class MySeleniumTest extends PHPUnit_Extensions_Selenium2TestCase // 假设你用的是 Selenium2TestCase
{
    protected $verificationErrors = []; // 初始化错误数组

    // ... setup 等方法 ...

    /**
     * 验证页面标题,失败时记录包含堆栈跟踪的错误信息
     *
     * @param string $expectedText 期望的标题文本
     */
    public function verifyTitle($expectedText)
    {
        $actualTitle = ''; // 初始化
        try {
            // 模拟获取标题的操作,实际应调用 $this->title() 或类似方法
            $actualTitle = $this->getTitle(); // 确保你的测试类有 getTitle 方法
            $this->assertEquals($expectedText, $actualTitle);
        } catch (PHPUnit_Framework_AssertionFailedError $e) {
            // 获取原始错误信息
            $baseMessage = $e->getMessage(); // 更简洁的错误,比如 "Failed asserting that two strings are equal."

            // 获取堆栈跟踪字符串
            $stackTrace = $e->getTraceAsString();

            // 组合更有用的错误信息
            $errorMessage = sprintf(
                "Verification failed: Title mismatch.\nExpected: '%s'\nActual:   '%s'\n---\nFailure occurred near:\n%s\n---",
                $expectedText,
                $actualTitle,
                $this->filterStackTrace($stackTrace) // 可选:过滤堆栈,让信息更聚焦
                // 或者直接使用 $stackTrace: $stackTrace
            );

            array_push($this->verificationErrors, $errorMessage);
        } catch (\Exception $e) {
            // 也捕获其他可能的异常,例如 WebDriver 异常
             array_push($this->verificationErrors, sprintf(
                "Error during verifyTitle ('%s'): %s\n%s",
                $expectedText,
                $e->getMessage(),
                $this->filterStackTrace($e->getTraceAsString())
             ));
        }
    }

    /**
     * 可选:过滤堆栈跟踪,只显示与测试脚本相关的部分
     *
     * @param string $traceString 原始堆栈跟踪字符串
     * @return string 过滤后的堆栈跟踪字符串
     */
    protected function filterStackTrace($traceString)
    {
        $lines = explode("\n", $traceString);
        $filteredLines = [];
        $foundUseful = false;

        foreach ($lines as $line) {
            // 尝试找到第一个非 PHPUnit 或 Selenium 内部调用的堆栈帧
            // 这个路径判断需要根据你的项目结构调整
            if (strpos($line, 'vendor/phpunit/') === false &&
                strpos($line, 'vendor/facebook/webdriver/') === false && // 如果用了 facebook/webdriver
                strpos($line, 'PHPUnit/Extensions/Selenium') === false &&
                strpos($line, __CLASS__) === false) { // 排除当前类的内部调用
                 $foundUseful = true;
            }

            // 只保留从第一个有用的帧开始的部分,或者如果找不到,就保留原始的几行
             if ($foundUseful || count($filteredLines) < 5) { // 最多保留原始的前5行以防万一
                 if(trim($line)) { // 避免空行
                     $filteredLines[] = $line;
                 }
            }

            // 可以进一步限制输出的堆栈深度
            if (count($filteredLines) > 10) break;
        }
        return implode("\n", $filteredLines);
    }


    // 你的测试方法,调用 verifyTitle
    public function testPageTitle()
    {
        // ... navigate to page ...
        $this->url('https://example.com'); // 示例导航

        $this->verifyTitle("Example Domain"); // 成功的验证
        $this->verifyTitle("Some Other Title"); // 这个会失败,并记录带堆栈跟踪的错误
        // ... 其他测试步骤 ...
    }

    // 在 tearDown 中检查是否有验证错误
    protected function tearDown(): void // PHPUnit 9+ 使用 void 返回类型提示
    {
        parent::tearDown(); // 调用父类的 tearDown

        // 如果 $verificationErrors 不为空,则抛出异常或打印信息
        if (!empty($this->verificationErrors)) {
            // 为了让测试结果标记为失败,可以抛出一个异常
            // 或者,如果你希望测试结果是 "passed" 但有警告,就只打印
             $errorOutput = "Verification Errors Occurred:\n\n";
             $errorOutput .= implode("\n\n", $this->verificationErrors);
             // 你可以选择:
             // 1. 打印到控制台 (如果测试结果可以通过,但需要看错误)
             // echo "\n" . $errorOutput . "\n";

             // 2. 抛出异常,让测试结果明确失败 (推荐)
             throw new \Exception($errorOutput); // 使用通用异常,或者自定义异常

             // 清空数组,以免影响下一个测试
             $this->verificationErrors = [];
        }
    }

     // 模拟 SeleniumTestCase 的 getTitle 方法
     protected function getTitle(){
         // 这里应该是 $this->driver->getTitle();
         return "Some Other Title"; // 模拟一个不匹配的标题
     }
     // 模拟 url 方法
     protected function url($url){
         // 实际是 $this->driver->get($url);
         echo "Navigating to $url\n";
     }
      // ... 需要一个 setup 方法来启动浏览器等 ...
     protected function setUp(): void
     {
          parent::setUp();
         //  $this->setBrowserUrl('http://localhost:4444/wd/hub'); // Example setup
         //  $this->setBrowser('chrome');
          $this->verificationErrors = []; // 确保每个测试开始时错误列表是空的
     }

}

// 注意:为了独立运行此示例,你需要有 PHPUnit 环境和对应的 Selenium/WebDriver 设置。
// 上面的代码主要演示 verifyTitle 和 tearDown 的逻辑。

改进后的错误输出可能看起来像这样 (在tearDown中打印时):

Verification Errors Occurred:

Verification failed: Title mismatch.
Expected: 'Some Other Title'
Actual:   'Actual Page Title From Browser'
---
Failure occurred near:
#0 /path/to/your/project/tests/MySeleniumTest.php(85): MySeleniumTest->verifyTitle('Some Other Title')
#1 [internal function]: MySeleniumTest->testPageTitle()
#2 phar:///usr/local/bin/phpunit/phpunit/Framework/TestCase.php(1152): ReflectionMethod->invokeArgs(Object(MySeleniumTest), Array)
... (更多堆栈信息,可被 filterStackTrace 清理) ...
---

现在,输出清楚地指向了 MySeleniumTest.php 文件的第 85 行,也就是调用 verifyTitle('Some Other Title') 的地方,正是我们需要的!那个可选的 filterStackTrace 函数可以帮你过滤掉 PHPUnit 或 WebDriver 内部的调用细节,让关键信息(你的测试代码调用处)更突出。

安全建议:

堆栈跟踪可能包含文件路径、函数参数等敏感信息。确保这些详细的错误报告只在开发和测试环境中使用,不要暴露在生产环境的日志或用户界面上。

方法二:使用 Trait 或 Base Class 统一处理 Verify 逻辑

如果你的测试项目中有很多自定义的 verifySomething 方法,你会发现每个方法里都有类似的 try...catch 逻辑,这有点重复。可以把这个逻辑抽取出来,放到一个 Trait(PHP 5.4+)或者一个共享的基类 (Base Test Case Class) 中。

原理: 将通用的异常捕获、堆栈跟踪记录逻辑封装到一个可复用的单元里。各个具体的 verify 方法调用这个通用逻辑。

使用 Trait 的代码示例:

<?php

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\AssertionFailedError as PHPUnit_Framework_AssertionFailedError;

trait VerificationErrorHandler
{
    protected $verificationErrors = [];

    /**
     * 执行一个验证闭包,并在失败时记录带堆栈跟踪的错误
     *
     * @param callable $assertion callable
     * @param string $failureMessage 失败时的信息
     */
    protected function performVerification(callable $assertion, $failureMessage = "Verification failed")
    {
        try {
            $assertion(); // 执行传入的断言操作
        } catch (PHPUnit_Framework_AssertionFailedError $e) {
            $errorMessage = sprintf(
                "%s:\n%s\n---\n%s\n---",
                $failureMessage,
                $e->getMessage(), // PHPUnit的原始失败信息
                $this->getFilteredStackTrace($e) // 获取并过滤堆栈跟踪
            );
            array_push($this->verificationErrors, $errorMessage);
        } catch (\Exception $e) {
            // 处理其他WebDriver等异常
            $errorMessage = sprintf(
                "Error during verification: %s\n---\n%s\n---",
                $e->getMessage(),
                $this->getFilteredStackTrace($e)
            );
             array_push($this->verificationErrors, $errorMessage);
        }
    }

    /**
     * 获取并可能过滤异常的堆栈跟踪
     *
     * @param \Throwable $e Exception or Error
     * @return string Stack trace string
     */
    protected function getFilteredStackTrace(\Throwable $e)
    {
        // 实现类似之前的 filterStackTrace 逻辑,或者直接返回 $e->getTraceAsString()
         // 为了简洁,这里直接返回完整跟踪
         return $e->getTraceAsString();
    }

     // 确保在 tearDown 中处理 $verificationErrors
     abstract protected function tearDown(): void;

    // 可能还需要一个 setUp 来初始化 $verificationErrors
     abstract protected function setUp(): void;

}

// ---- 在你的测试类中使用 Trait ----
class MySeleniumTestWithTrait extends PHPUnit_Extensions_Selenium2TestCase // 假设继承 Selenium TestCase
{
    use VerificationErrorHandler; // 引入 Trait

    protected function setUp(): void
    {
         parent::setUp();
         $this->verificationErrors = []; // 初始化错误数组
        // ... 其他 setup ...
    }


    /**
     * 使用 Trait 的 performVerification 来实现 verifyTitle
     */
    public function verifyTitle($expectedText)
    {
        $actualTitle = $this->getTitle(); // 获取实际标题
        $message = sprintf("Title mismatch. Expected: '%s', Actual: '%s'", $expectedText, $actualTitle);

        $this->performVerification(function() use ($expectedText, $actualTitle) {
            $this->assertEquals($expectedText, $actualTitle);
        }, $message);
    }

     /**
     * 使用 Trait 的 performVerification 来实现 verifyTextPresent
     */
     public function verifyTextPresent($expectedPattern) {
         $bodyText = $this->getBodyText(); // 假设这个方法获取页面文本
         $message = sprintf("Text pattern '%s' not found in body.", $expectedPattern);

         $this->performVerification(function() use ($expectedPattern, $bodyText) {
             $this->assertMatchesRegularExpression("/".preg_quote($expectedPattern, '/')."/", $bodyText); // 注意:PHPUnit 9+ 使用 assertMatchesRegularExpression
            // 或者旧版: $this->assertRegExp("/".preg_quote($expectedPattern, '/')."/", $bodyText);
         }, $message);
     }

    public function testPageContent()
    {
         $this->url('https://example.com');
        $this->verifyTitle("Example Domain");
        $this->verifyTextPresent("This domain is for use in illustrative examples");
        $this->verifyTitle("Wrong Title"); // Fails
        $this->verifyTextPresent("NonExistentText"); // Fails
    }


    protected function tearDown(): void
    {
         parent::tearDown();
        if (!empty($this->verificationErrors)) {
             $errorOutput = "Verification Errors Occurred:\n\n" . implode("\n\n", $this->verificationErrors);
             // echo "\n" . $errorOutput . "\n"; // 打印方式
             throw new \Exception($errorOutput); // 抛出异常让测试失败
             $this->verificationErrors = [];
        }
    }

    // --- Mock/Helper methods for demonstration ---
     protected function getTitle(){ return "Example Domain"; }
     protected function getBodyText(){ return "This domain is for use in illustrative examples in documents."; }
     protected function url($url){ /* echo "Navigating to $url\n"; */ }
    // 假设 assertMatchesRegularExpression 存在 (或者根据你的 PHPUnit 版本使用 assertRegExp)
    // 你可能需要继承自一个已经定义了 assertMatchesRegularExpression 的 TestCase,或者自己实现 polyfill
    // 以下仅为示例
     public function assertMatchesRegularExpression(string $pattern, string $string, string $message = ''): void {
         if (!preg_match($pattern, $string)) {
             throw new PHPUnit_Framework_AssertionFailedError($message ?: sprintf('Failed asserting that \'%s\' matches pattern \'%s\'.', $string, $pattern));
         }
     }


}

这种方式让你的具体 verify 方法变得更简洁,只关注断言逻辑本身和失败时的上下文信息,通用的错误处理和堆栈跟踪获取交给了 performVerification

进阶使用技巧:

  • 堆栈过滤(Stack Trace Filtering): 前面的 filterStackTrace 示例比较基础。你可以根据项目结构和依赖库,更精确地过滤掉不相关的堆栈帧,比如只显示第一个属于你的 tests 目录下的调用点。
  • 自定义报告: tearDown 里的处理方式可以更复杂。你可以不直接抛出异常,而是生成一个结构化的报告(比如 JSON 或 HTML),然后通过 PHPUnit 的测试监听器 (Test Listener) 或自定义打印机 (Printer) 来输出这个报告,实现更友好的展示效果。这与提问者提到的 "stacktrace hack" 的思路更接近,都是在测试运行结束后对结果进行处理和展示。

通过捕获异常并提取堆栈跟踪信息,或者通过重构代码将错误处理逻辑集中化,就能有效地解决 Selenium PHP verify 失败时缺少行号的问题,大大提升调试效率。选择哪种方法取决于你的项目规模和个人偏好,但核心都是确保不丢失宝贵的失败位置信息。