Day26 - 铁人付外挂测试验收(二) - 导入单元测试

先来回顾一下目前铁人付外挂的资料夹结构:

iron-pay
├── composer.json
├── composer.lock
├── iron-pay.php
├── src
│   ├── Gateways
│   │   ├── CreditCard.php
│   │   ├── Request.php
│   │   ├── Response.php
│   │   └── VirtualAccount.php
│   ├── Options.php
│   ├── Posts
│   │   └── ShopOrder
│   │       └── Metabox.php
│   └── Utility.php
└── vendor

通常我们会新增一个 tests 资料夹来放入测试的类别,并且其目录结构会完全比照 src 里面的所有内容,以及在既有的档名後面加入 Test 的後缀,具体的结构如下:

iron-pay
├── composer.json
├── composer.lock
├── iron-pay.php
├── src
│   ├── Gateways
│   │   ├── CreditCard.php
│   │   ├── Request.php
│   │   ├── Response.php
│   │   └── VirtualAccount.php
│   ├── Options.php
│   ├── Posts
│   │   └── ShopOrder
│   │       └── Metabox.php
│   └── Utility.php
├── tests
│   ├── Gateways
│   │   ├── CreditCardTest.php
│   │   ├── RequestTest.php
│   │   ├── ResponseTest.php
│   │   └── VirtualAccountTest.php
│   ├── OptionsTest.php
│   ├── Posts
│   │   └── ShopOrder
│   │       └── MetaboxTest.php
│   └── UtilityTest.php
└── vendor

在本文中会专注於测试 Utility 这个类别,这个类别主要有两个方法,第一个是测试产生金流商验证演算法的计算结果是否有符合预期,第二个是测试取得商店代号的格式是否正确。

首先看到 Utility.php 的内容:

<?php

namespace Irp;

class Utility {
	// 略

	/**
	 * Build pass code for api usage.
	 *
	 * @param string $args args.
	 * @param string $secret API secret.
	 * @return string
	 */
	public static function generate_sign( $args, $secret ) {
		$post_datastr = '';
		foreach ( $args as $name => $value ) {
			if ( ! is_array( $value ) ) {
				if ( $value !== null && $value !== '' ) {
					$post_datastr = $post_datastr . $name . '=' . $value . '&';
				}
			}
		}
		$tmp_array  = explode( '&', trim( $post_datastr, '&' ) );
		sort( $tmp_array, SORT_STRING );
		$source     = implode( '&', $tmp_array );
		$source_key = $source . '&key=' . $secret;
		$sign       = strtoupper( md5( $source_key ) );
		return $sign;
	}

	/**
	 * Get stord id
	 *
	 * @return string
	 */
	public static function get_store_id() {
		return get_option( 'irp_store_id' );
	}
}


这篇示范测试其中两个静态方法: generate_sign()get_store_id()

为了要能够测试,把程序码写得易於测试是非常重要的,易於测试的其中一个原则就是要有返回结果,有这个结果才能在测试时进行比对,同时在使用这个方法时也能预先处理没有取得预期结果的情况。

准备好测试对象已经测试档案後,接下来我们透过 Composer 安装测试套件 PHPUnit,可以把它安装在专案的目录之下,或是可以装在全域的环境下,这样以後每个专案都可以直接使用 PHPUnit 的指令而无需再个别安装,这边介绍全域的安装方式。

$ composer global require phpunit/phpunit

接下来切换到专案资料夹,执行 phpunit,看到以下讯息即代表安装成功:

iron-pay$ phpunit

PHPUnit 9.5.4 by Sebastian Bergmann and contributors.

Usage:
  phpunit [options] UnitTest.php
  phpunit [options] <directory>

Code Coverage Options:
  --coverage-clover <file>    Generate code coverage report in Clover XML format
  --coverage-cobertura <file> Generate code coverage report in Cobertura XML format
  --coverage-crap4j <file>    Generate code coverage report in Crap4J XML format

首先进行 PHPUnit 的初始化:

iron-pay$ phpunit --generate-configuration

会看到以下 PHPUnit 询问的问题:

Bootstrap script (relative to path shown above; default: vendor/autoload.php): 
Tests directory (relative to path shown above; default: tests): 
Source directory (relative to path shown above; default: src): 
Cache directory (relative to path shown above; default: .phpunit.cache): 

Generated phpunit.xml in /Users/oberonlai/Sites/woocommerce/wp-content/plugins/iron-pay.
Make sure to exclude the .phpunit.cache directory from version control.

第一个问题会需要指定 Bootstrap 的档案路径,这个档案是用来做自动载入的设定以及存放一些全域变数的档案,预设的话会是 Composer 的 autoload.php 这个档案。

其他问题会问你是否要修改测试目录、原始档目录以及快取目录的路径,这些都采用预设值即可,完成後记得把 .phpunit.cache 目录在版控中忽略掉,设定完成後可以在外挂根目录看到一个 phpunit.xml 的设定档,之後要修改 PHPUnit 的设定都可以在这个档案调整。

通常我们要执行测试时,需要到终端机下指令输入 phpunit,但毕竟还要切换介面会稍微增加进行测试的阻力,如果这时候你是使用 VSCode 的话,建议可以安装由 Recca Tsai 开发的 PHPUnit Test Explorer 测试套件。

它可以直接在 VSCode 的面板中管理已经写好的测试项目,透过介面点选要执行的测试,更方便之处在於可以透过快捷键来进行测试,这样可以大幅增加执行测试的效率。

VScode 还有另外一个写测试超好用的提示工具,它是由 Winnie Lin 开发的 PHPUnit Snippets 套件,只要输入关键字 pu:,就能自动带入写测试时的框架,完全不需要再自己手动输入,尤其像我这种手残党特别适用。

安装好两款测试套件後,我们还需要更新 compose.json 档,加入 autoload 的指定路径,并且新增 autoload-dev 的 tests 载入,这让 PHPUnit 可以抓到 src 里面的类别:

"autoload": {
	"psr-4": {
		"Irp\\": "src"
	}
},
"autoload-dev": {
	"psr-4": {
		"Irp\\Tests\\": "tests/"
	}
}

路径是根据命名空间指定的,更新完後记得要执行 dump 指令才会重新产生自动载入的档案:

iron-pay$ composer dump

准备好以上所有写测试的环境後,我们就来开始实作 UtilityTest.php,测试的基本架构如下:

<?php

namespace Irp\Tests;

use PHPUnit\Framework\TestCase;
use Irp\Utility;

/**
 * UtilityTest
 *
 * @group group
 */
class UtilityTest extends TestCase {

	protected function setUp(): void {
		parent::setUp();

		// code
	}

	protected function tearDown(): void {
		// code
	}


	public function test_generate_sign() {
		// Test
	}

	public function test_get_store_id() {
		// Test
	}

}

测试类别从 TestCase 继承而来,setUp() 会在执行测试方法前触发,因此可以把要测试的类别建立以及相关变数放在这边,那麽在测试中就可以取得这些内容。

相反的 tearDown() 则是在每一个测试跑完的当下会被触发,如果需要在测试完後还原某些资料或是重设设定,就可以放在这个方法之中。

所有的测试方法要加入 test 前缀,并且尽量遵循 Arrange、Act、Assert 的 3A 架构:

  • Arrange 指的是测试前的初始资料准备,像是预期的结果、变数的初始值等等。
  • Act 是呼叫测试对象的方法,藉此取得执行该方法後的返回结果。
  • Assert 断言,也就是检查 Act 中的返回结果跟 Arrange 里面的预期结果是否吻合。

测试加密演算法

接下来我们实作 test_generate_sign() ,这边我想测试的是当参数传入之後,是否有产出我们所期待的演算结果。铁人付的加密演算逻辑为传入参数经过整理後与店家金钥组合做 MD5 杂凑,因此金钥应为必填栏位,测试程序码如下:

public function test_generate_sign() {
	// Arrange.
	$args = array(
		'nonce_str'       => 71669965,
		'orgno'           => 1265,
		'secondtimestamp' => 1489215551,
		'total_fee'       => 8888,
	);

	$secret   = '12345';
	$expected = 'B59951E5AA2E6FCA1596253E2978ED2B';

	// Act.
	$actual = Utility::generate_sign( $args, $secret );

	// Assert.
	$this->assertEquals( $expected, $actual );
}

Arrange 的部分先设定要传入的变数,以及预期的演算结果,Act 则是执行 generate_sign() 并将结果存在 $actual 变数中,最後的 Assert 则是检查这两个变数是否有符合预期结果,assertEquals() 是判断传入的两个变数值是否相等。

接下来切换到 VSCode 左侧面板找到烧杯的图案,就能看到一个测试项目,这时候点选上面的播放图示或是用快捷键输入 Cmd + t + s 就能执行测试,记得 t 跟 s 不用同时,只要按住 Cmd 先按 t 再按 s 即可,也可以点击下图中间箭头处的 Run 来执行:

顺利的话就能在下方的 Output 视窗看到测试结果,当然,你也可以在终端机输入 phpunit 来执行测试。

PHPUnit 9.5.9 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.4.13
Configuration: /Users/oberonlai/Sites/woocommerce/wp-content/plugins/iron-pay/phpunit.xml

R                                                                   1 / 1 (100%)

Time: 00:00.016, Memory: 6.00 MB

There was 1 risky test:

1) Irp\Tests\UtilityTest::test_generate_sign
This test does not have a @covers annotation but is expected to have one

OK, but incomplete, skipped, or risky tests!
Tests: 1, Assertions: 1, Risky: 1.

这边我们会发现虽然测试通过了,但出现一个提示讯息说:This test does not have a @covers annotation but is expected to have one,这代表我们没有这个方法没有标记要测试哪些项目,更多关於 annotation 的用法可以参考官方文件。

这边我们先忽略它,要关闭这个提示的话开启 phpunit.xml,找到 forceCoversAnnotation 设定为 false 即可:

forceCoversAnnotation="false"

再执行一次测试就可以看到通过的讯息:

PHPUnit 9.5.9 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.4.13
Configuration: /Users/oberonlai/Sites/woocommerce/wp-content/plugins/iron-pay/phpunit.xml

.                                                                   1 / 1 (100%)

Time: 00:00.018, Memory: 6.00 MB

OK (1 test, 1 assertion)

测试取得商店代号

第二个我们要测试的是 get_store_id(),这方法我们要测试的是从资料库取得的商店代号的位数是否为 8 码,以及是否皆整数。先回到这个方法的实作:

public function get_store_id(){
	return get_option( 'irp_payment_orgno' );
}

可以看到里面只有一个 get_option(),这个方法是 WordPress 取得 wp_options 资料表的函式,但在这边我们只是要验证 get_store_id() 返回的资料是否正确,而非 get_option() 是否能正确的从资料库取得资料,再加上我们要隔离外部环境也就是资料库的操作,因此我们不能直接呼叫 WordPress 的函式,而是要用模拟的方式来做出假的结果,藉此来确保我们自己写出来的程序是正确的。

而模拟的方法叫做测试分身 - Test Doubles,我们这边使用 PHPMock 来模拟 get_option(),先透过 Composer 来进行安装,记得要加 --dev 来指定该套件只用开发环境:

iron-pay$ composer require --dev php-mock/php-mock-phpunit

然後使用 PHPMock 来模拟 get_option()

namespace UnitTestDemo;

use phpmock\phpunit\PHPMock;

class UtilityTest extends TestCase {

    use PHPMock;
	
	// 略

    public function test_get_store_id() {
		// Arrange.
		$get_option = $this->getFunctionMock( 'Irp', 'get_option' );
		$get_option->expects( $this->once() )
					->with( $this->equalTo( 'irp_payment_orgno' ) )
					->willReturn( 'mystoreid' );

		$expected = 'mystoreid';

		// Act.
		$actual = Utility::get_store_id();

		// Assert.
		$this->assertEquals( $expected, $actual );
	}
}

变数 $get_option 就是模拟出来的对象,它透过 getFunctionMock() 来建立分身,第一个参数传的是命名空间,第二个是要模拟的函式名称,接下来呼叫 expects、will、willReturn 三个方法,第一个 expects 的参数为 $this->once(),代表在这个测试中只会呼叫一次我们自己做出来的 get_option()

第二个 with 代表要传入这个假方法的参数,也就是原本方法的里面的 return get_option( 'irp_store_id' )irp_store_id 栏位名称,第三个 willReturn 代表的是这个假方法会回传的结果,也就是商店代号,最後就可以检查是否吻合。

测试写到这边,会发现到测试对象 get_store_id() 并不完整,它既没有检查资料是否有正确取得以及格式是否正确,而这也是写测试的最大好处,为了要能够写测试,会思考到当下没有考虑到的情境,我们把 get_store_id() 重构如下:

public function get_store_id(){
	$store_id = get_option( 'irp_store_id' );
	if ( empty( $store_id ) ) {
		return '未输入商店代号';
	}

	if ( ! is_numeric( $stord_id ) ) {
		return '商店代号限定数字';
	}

	if ( strlen( $stord_id ) !== 8 ) {
		return '商店代号须为 8 码';
	}
	
	return $store_id;
}

回到测试里面,将原本的 $get_option 修改为执行四次,并且每次都回传不同的结果:

class DemoTest extends \PHPUnit_Framework_TestCase
{
    use PHPMock;

    public function test_get_store_id()
    {
        $get_option = $this->getFunctionMock( 'Irp', 'get_option' );
		$get_option->expects( $this->exactly( 4 ) )
					->with( $this->equalTo( 'irp_payment_orgno' ) )
					->willReturnOnConsecutiveCalls(
						'12345678',
						'',
						'abc123',
						'123'
					);
		$expected        = '12345678';
		$expected_empty  = '未输入商店代号';
		$expected_number = '商店代号限定数字';
		$expected_length = '商店代号须为 8 码';

		// Act.
		$actual        = Utility::get_store_id();
		$actual_empty  = Utility::get_store_id();
		$actual_number = Utility::get_store_id();
		$actual_length = Utility::get_store_id();

		// Assert.
		$this->assertEquals( $expected, $actual );
		$this->assertEquals( $expected_empty, $actual_empty );
		$this->assertEquals( $expected_number, $actual_number );
		$this->assertEquals( $expected_length, $actual_length );
    }
}

原本使用 willReturn() 来模拟传回一个结果,现在改用 willReturnOnConsecutiveCalls() 传回四个,第一个是正确的格式,另外三个是例外情况,然後分别对应到在例外情况下 test_get_store_id() 会回传的结果。

除了使用 PHPMock 来模拟内建的函式外,在社群中也有提供针对 WordPress 使用的测试替身,像是 WP_Mock 以及 Brain Mockey,PHP 也有一套专门的测试框架叫做 CodeCeption,而用在 WordPress 上面的叫做 wp-browser,另外还有针对 WooCommerce 的 wp-browser-woocommerce,这些工具让我们可以更无痛的导入测试流程。

写测试的价值在於让我们想到原本没有想到的状况,进而来改善原始程序码对於例外情况的处理,这也是为什麽写了测试反而可以省下除错时间,因为当例外状况都在我们的掌控之中,要追查问题出在哪里就会很快,省去很多摸索的时间。

在业界流行一种测试驱动开发 ( Test-Driven Development ) 的流程,先把测试写完再去真正实作功能的开发,写测试的当下就能先思考这个类别该如何使用以及可能会有的例外情况,把一切都考虑进去想清楚後再来动手,就能减少很多修改程序的时间。

但要测试开发的功能能否在真实世界正常运作,我们还需要整合测试与端对端测试,下一篇我们先介绍端对端测试,也就是透过模拟使用者的操作行为来进行测。

本文同步发表於:https://oberonlai.blog/tw/woocommerce-payment-unit-test/


<<:  【从零开始的Swift开发心路历程-Day14】打造自己的私房美食名单Part3(完)

>>:  [Day 12] -『 GO语言学习笔记』- for range 回圈

Day 1 [Python ML] 30天内容介绍

简介 之前在kaggle上面学习到了很多Python应用在Machine Learning的方法 对...

Day 3 : HTML - 快速打出HTML的代码,超好用的Emmet语法!

想必大家都有这个困扰 如果今天要叫出10个div、再宣告不同的class 就要一直反覆的输入div、...

视觉化当日趋势图(1)-client端架设&&工具篇

昨天我们完成了用Flask撰写ticks API, API端好了之後,接下来我们要开始架设我们的cl...

[第18天]理财达人Mx. Ada-证券扣款帐户银行余额资讯

前言 本文说明查询证券扣款帐户银行余额资讯。 程序实作 程序 # 扣款帐户余额资讯 balance ...

【Day23】导航元件 - Pagination

元件介绍 Pagination 是一个分页元件,当页面中一次要载入过多的资料时,载入及渲染将会花费更...