单元测试 - 创建测试用例

单元测试是什么?

(老鸟可以无视下面这段话.)

hi,新同学们,咱们的PHP代码里满布着好多函数和类,经常互相调用,你改的一个函数/方法可能是"比较底层"的,通常有好多地方调用了,那么你修改它的时候可能会战战兢兢,怕这处好了那里没好是吧,然后你当时肯定是这个页面刷一刷看有错没,那个页面也刷一刷看看有错没...啊咦!?可是有几十个地方都调用了喔,刷几十个页面你肯定不会做!除非你干劲满满的,反正我不干咯...刷几个页面没问题就下班了

但是这样太笨啦~好了不怪你,咱们都是这么过来的,其实那些已经学会单元测试玩得龙飞凤舞的老鸟们也好多曾经和你一样.一般来说,使用单元测试技术,可以在你更新一个函数/方法的时候自动检测你这些改动是否安全(但不能保证绝对安全,只能说安全系数高了,你可以不用老刷各个页面,可以放心把代码发布上线,放心下班鸟~_~),以我经验总结,单元测试就是解决这些问题的,单元测试可以确保代码的变更没有影响你原来对程序的预期,特别是核心代码部分


创建一个测试用例

单元测试一般是划分成很很多个很多个用例的,叫做测试用例,每个负责测不同的东西,在cmd里切换到E盘,再执行cd project1-tests切换到这个目录里面,执行

php E:\codecept.phar generate:test unit HelloWorld

然后命令行会提示你

Test was created in E:\project1-tests\tests\unit\HelloWorldTest.php

就是说在E:\project1-tests\tests\unit目录下创建了一个叫HelloWorldTest.php的文件,这个文件就是一个测试用例了,接下来我们要进去编辑掉它!

(那个cmd会话窗口别关掉哦!接下来陆续要用到!)

单元测试 - 编写测试代码

那么打开E:\project1-tests\tests\unit\HelloWorldTest.php吧,通过之前的命令生成出来的测试用例呢,里面是已经自带了一些代码的,大概如下:

其中有一个testMe的公共方法,我们在这个方法里执行$this->assertTrue(2 == 2);

然后运行测试用例,怎么运行?命令如下:

php E:\codecept.phar run unit HelloWorldTest

cmd给关掉了?过来,我保证不打死你!说了多少遍?

运行后它会输出很多行信息,当然也不超过半个屏幕,最后一句话是这样的:

Codeception PHP Testing Framework v2.0.9
Powered by PHPUnit 4.4.0 by Sebastian Bergmann. Project1_tests.unit Tests (1) -------------------------------------------------
Trying to test m1 (project1_tests\HelloWorldTest::testM1) Ok
------------------------------------------------------------------------------- Time: 455 ms, Memory: 4.25Mb OK (1 test, 1 assertion)

这些信息中说明了当前的单元测试框架版本,运行了哪个项目的哪个单元测试,是否成功和时间/内存使用统计等,以后你运行单元测试也会看到这样的输出

重点是看最后一行OK (1 test, 1 assertion),表示运行了1个测试用例和执行了1个断言,这是测试正常的表现特征

*以后引术运行后输出测试结果时我都只会引述最后输出的部分,前面那些版本信息呀,运行哪个用例呀那些就忽略掉了哦


让命令简短一点吧

啊哦...这里我们暂停一下,你说吧,老是敲php E:\codecept.phar run这个东西是不是很烦?教你,在设置环境变量的窗口里点击当前用户变量区域下的"新建"啥的(没必要设置成系统变量,所以不用去系统变量里新建,除非你真的喜欢),,,,看下图我已经没力打字了...

然后重启cmd会话,还是要切换到测试项目目录你就可以这样玩命令了:

%ceptRun% unit HelloWorldTest

就是利用%ceptRun%这个变量来代替之前那个命令的一部分吧,用linux的同学就创建命令别名就行了你懂的


言归正转

然后我们将上面testMe方法里的2 == 2表达式改成1 == 2,重新运行测试后就会输出

FAILURES!
Tests: 1, Assertions: 1, Failures: 1.

表示测试不通过,运行了1个测试脚本,1次断言,并且产生了1次失败断言,如果你是第一次接触单元测试的话可能还没感觉到什么

首先致了解单元测试的同学们:这里的代码$this->assertTrue方法跟PHPUnit一样,当然还有$this->assertEqual,$this->assertInstanceof等方法了,因为这是完全兼容PHPUnit的

接下来要跟第一次接触单元测试的同学们讲讲这个,比如assertTrue方法,这是一个断言方法(只要是assert开头的方法都是),根据单元测试的API说明,我们必须至少传递一个结果为true的变量或者表达式进去,如果不是true的话,它就会在测试结果的报告里标注上累加1次失败的断言记录

接下来试试,删除测试用例里的testMe方法,把以下几个方法粘贴进去(顺便你先看看代码)

public function testM1(){
$this->assertTrue(false); //传的参数不是true,断言失败
} public function testM2(){
$isOK = false;
$this->assertTrue($isOK); //你换汤不换药咋行?不还是传了个false进去?失败!
} public function testM3(){
$isOK = time() < 0; //呵呵,当前时间戳肯定不小于0,不成立,于是这个比较运算得到结果是false
$this->assertTrue($isOK); //你又换汤不换药了亲!
} public function testM4(){
$this->assertTrue(time() < 0); //后果你懂的
} public function testM5(){
$this->assertTrue(time() > 0); //好,恭喜你这次成功了!
$this->assertTrue(false); //又失败了
}

准备好了吧,跑一趟,得出以下结果:

FAILURES!
Tests: 5, Assertions: 6, Failures: 5.

意思是:

  • Tests: 5运行了5次测试(分别是5个测试方法)

  • Assertions: 6运行了6个断言(就是执行了6次名字是assert开头的方法)

  • Failures: 5有5次断言是失败的

好了,到此你已经知道,我们必须将一个最终结果为true的东西传给assertTrue方法

单元测试 - 断言

提示:已经了解单元测试知道断言是咋回事的老鸟请直奔下一节

任何编程的单元测试里会频繁地出现一个词语叫assert,它就是断言的意思,就像刚才的代码$this->assertTrue(返回true/false的表达式或变量/函数)方法一样,它的意思是说我敢断言传进来的值是个true!,就像在说我敢断言他就是凶手!,若一旦他不是凶手,那么结果就是断言失败

一个测试方法里面可以有好多次断言,然而一般情况下只要有其中一次不符合断言的话整个测试方法都会停下来,咱试试下面的代码:

public function testMe(){
$this->assertTrue(1 + 2 == 3); //第1次断言,等式成立,断言成功,继续往下运行
file_put_contents('E:\1.txt', 11); //不信你看看,这个文件存在喔,说明这里被运行了
$this->assertTrue(1 + 2 == 4); //第2次断言,等式不成立,断言失败,下一句将无法运行,运行到这里就会退出这个测试方法,如果有其它测试方法就会再运行另一个测试方法
file_put_contents('E:\2.txt', 22); //为了证明不会运行到这里,特设此代码,让你去找找2.txt是否真的不会被创建
$this->assertTrue(2 + 2 == 4); //第3次断言,等式成立,但上一句已经断言失败,这里不会被运行 //最终你就是能看到运行结果提示只运行了2次断言,1次成功1次失败,没有提到第3次断言,因为这个测试方法在第二句断言时就中断了,你也看不到 E:\2.txt 文件的存在
}

上面的代码我在备注里都说明了它的情况,相信你也看懂了,我的意思就是说当一个测试方法里只要有一次断言失败,整个方法就会中断运行不会再往下跑,这是测试的特性,包括未来接触的其它测试也是这样的,至于为什么要有这样的运行规则呢?我觉得不需要解释,你写着写着就会领悟得到


断言的方式

最基本最简单的断言莫过于assertTrue了,比如下面这样的代码:

$this->assertTrue(is_string($name));	//断言名字是字符串来的
$this->assertTrue(is_numerice($age)); //断言年龄是数值来的
$this->assertTrue(is_numerice($age) && $age >= 18); //并且年龄还是18岁以上
$this->assertTrue($action != '删除'); //断言一个操作标记不会是某个字符
$this->assertTrue(mb_strlen($name) >= 6); //断言一个名字是至少6个字的
$this->assertTrue($xxx->getYYY() == $qqq->ccc()); //断言两个方法的运行结果应该是相同的
$this->assertTrue(isset($arr['xxx'])); //断言一个数组是有xxx这个下标的

等等,看上去用assertTrue再配合判断表达式,所有测试都能做出来了呀!好了这下先不说这个,先介绍一下别的断言,断言方式其实有好多种,assertTrue只是最基础的,下面列出几个其它断言:

$this->assertEqual(5, count($arr));	//断言一个数组count后的个数是否等于5
$this->assertFalse($user->isLogin()); //断言一个用户对象是未登陆的
$this->assertInstanceOf('app\model\User', $user); //断言一个用户变量是 common\model\User 这个类的实例或子类实例
$this->assertGreaterThan (10, $age); //断言年龄是大于10岁
$this->assertContains(3, $arr); //断言数组里是包含了数字3这个值的
$this->assertArrayHasKey('choose_count', $aResult); //断言一个数组里会有一个叫choose_count的下标

不要老是使用assertTrue

好了,断言的方式有很多,真的很多,结合起一般的测试用例,经常会用到的起码有30多个.加上不常用的估计也有50多

既然有了assertTrue这个几乎万能的断言,为什么还会有那些别的断言呢?--其实是为了提高测试报告的精准性.

按照测试行业的规范,测试用例是不提倡用assertTrue来取代所有断言的,因为测试失败的时候,测试报告就会说"测试不通过,assertTrue失败",这下坑爹了!哪个assertTrue呀?幸好,PHP还报了个行号,打开测试脚本找到那一行就知道是哪个assertTrue了,这个在你能接触代码的时候就好说,在规范化运作里,不可能每次测试完都能去看代码的,比如远程上线的时候,报告说测试失败,可是人不在公司碰不到代码怎么办,而且如果这份代码是测试工程师独有的呢?你能碰得到吗?那么就要靠别的东西来定位

观察下图,这里都是用了assertTrue的断言,它们都是失败的,你能快速定位出它们都是哪里出了问题吗

到下图,用了更精准的方法去断言

那有时候直接看上图就知道testTwo那个断言失败的报错就知道原来类的某个方法返回的数组中没包含orange这样一个值

既然快速定位到了问题所在,接下来你要么哗啦啦地去改相关的类修正返回值,要么去找相关负责人调整这个类,反正不用专门打开测试脚本找到那一行了

测试用例以后都会频繁运行,过程中肯定会因为某个类改了一些代码会断言失败,就需要反复跟进问题,如果总是要打开测试脚本找到那一行才知道具体问题,那么这个工作方式的效率就太低下了.

但其实光是这么说其实也未必能完全精准定位断言的失败位置,只是通常可以这么认为而已,那你看我这个代码

$id = 999;	//实际上可能是什么$db->get('id')的代码赋值得来的嘛
$this->assertTrue(in_array($id, [1, 2, 3]), '无效的ID'); //这里传了第2个参数,一个用于表达错误的消息字符串

然后运行结果会变成这样

发现没有,断言失败的时候,它把那个错误消息的文字报出来了,这样你就能更清楚地知道是哪里出错了

单元测试 - 常用断言大全

这里提供一些通常我们会用得到的断言列表,其实也是我从网上收集整理下来的,但你不要全部记下,而是你要用到这相关的判断时才来查这个字典就好了

这些方法后面的$message参数就是前面提到的断言失败的消息了,有的没有参数说明或比较含糊,可以自行用关键词phpunit assertXXXX上网搜索一下,但必要用上的都有清楚说明


  • assertFalse(bool $condition, string $message = '') : 断言$condition的结果为false,assertTrue 与之相反

  • assertInternalType($expected, $actual, string $message = '') : 断言变量类型为$expected,相当于is_string,is_bool,is_numeric等类型判断,例:

    $this->assertInternalType('string', $var);
    $this->assertInternalType('numeric', $var);
    $this->assertInternalType('bool', $var);
    $this->assertInternalType('bool', $var);
  • assertNotInternalType($expected, $actual, string $message = '') : 与上一条相反,断言变量的类型不为$expected,例:

    $this->assertInternalType('string', $var)	//$var的类型不为string
  • assertEquals(mixed $expected, mixed $actual, string $message = '') : 断言$actual与$expected相同,类似 == 比较,例:

    $this->assertEquals(5, $age)
    $this->assertEquals($obj1, $obj2)
  • assertNotEquals() : 与上条相反,类似于 !=

  • assertInstanceOf($expected, $actual, string $message = '') : 断言$actual为$expected的实例,相当于 instanceof 关键字判断,例:

    $this->assertInstanceOf('common\model\Article', $model)
  • assertEmpty(mixed $actual, string $message = '') : 断言$actual变量为空,相当于empty

  • assertNotEmpty($variable, string $message = '') : 断言$variable变量不为空,相当于 !empty

  • assertNull(mixed $variable, string $message = '') : 断言$variable的值为null,相当于 is_null

  • assertNotNull() : 与上条相反

  • assertArrayHasKey(mixed $key, array $array, string $message = '') : 断言数组$array含有索引$key, 相当于 isset或者array_key_exists

  • assertGreaterThan(mixed $expected, mixed $actual, string $message = '') : 断言$actual比$expected大,相当于 > 号比较

  • assertGreaterThanOrEqual(mixed $expected, mixed $actual, string $message = '') : 断言$actual大于等于$expected,相当于 >=

  • assertAttributeGreaterThan() : 同上,只是用于断言类的属性

  • assertAttributeInternalType() and assertAttributeNotInternalType() : 断言类属性用

  • assertRegExp(string $pattern, string $string, string $message = '') : 断言字符串$string符合正则表达式$pattern,相当于preg_match

  • assertNotRegExp() : 与上条相反

  • assertLessThan(mixed $expected, mixed $actual, string $message = '') : 断言$actual小于$expected,相当于 < 号比较

  • assertAttributeLessThan() : 断言类属性小于$expected

  • assertLessThanOrEqual(mixed $expected, mixed $actual, string $message = '') : 断言$actual小于等于$expected,相当于 <= 号比较

  • assertAttributeLessThanOrEqual() : 断言类属性小于等于$expected

  • assertAttributeGreaterThanOrEqual() : 断言类的属性

  • assertObjectHasAttribute(string $attributeName, object $object, string $message = '') : 断言$object含有属性$attributeName,相当于 isset($obj->attr)

  • assertObjectNotHasAttribute(…) : 与上条相反

  • assertContainsOnly(string $type, Iterator|array $haystack, boolean $isNativeType = NULL, string $message = '') : 断言迭代器对象/数组$haystack中只有$type类型的值, $isNativeType 设定为PHP原生类型,$message同上,相当于遍历一个数组再判断每一个元素的类型,例:

    $this->assertContainsOnly('string', $userNames);	//断言一个所谓用户名称集合的数组中全部item都是字符串类型
  • assertContains(mixed $needle, Iterator|array $haystack, string $message = '') : 断言迭代器对象$haystack/数组$haystack含有$needle ,相当于in_array,相当于array_search或者in_array

  • assertNotContains(mixed $needle, Iterator|array $haystack, string $message = '') : 与上条相反

  • assertAttributeEquals($actual, $expected) 以及 assertAttributeNotEquals($actual, $expected) : 断言类属性名称$actual的值是否与$expected相同/不同

  • assertClassHasAttribute(string $attributeName, string $className, string $message = '') : 断言类$className含有属性$attributeName,例:

    $this->assertClassHasAttribute('name', 'app\role\User', 'User类没有name属性')
  • assertClassHasStaticAttribute(string $attributeName, string $className, string $message = '') : 断言类$className含有静态属性$attributeName

  • assertFileEquals(string $expected, string $actual, string $message = '') : 断言文件$actual和$expected所指的数据类型相同,例:

    $this->assertFileEquals('jpeg', '/www/web/1.jpg')
  • assertFileExists(string $filename, string $message = '') : 断言文件$filename存在

  • assertFileNotExists() : 与上条相反

  • assertStringEqualsFile(string $expectedFile, string $actualString, string $message = '') : 断言$actualString包含在文件$expectedFile的内容中,例:

    $this->assertFileEquals('E:\1.log', 'db_error')
  • assertStringNotEqualsFile() : 与上条相反

  • assertStringStartsWith(string $prefix, string $string, string $message = '') : 断言$string的开头为$suffix,例:

    $this->assertStringStartsWith('match', Url::to(['match/showHome']))	//断言生成的URL是以match开头的
  • assertStringStartsNotWith() : 与上条相反

  • assertStringEndsWith(string $suffix, string $string, string $message = '') : 断言$string的末尾为$suffix结束

  • assertStringEndsNotWith() : 与上条相反


以上断言无法满足测试代码的判断时,上网搜索关键词phpunit断言大全即可

单元测试 - 失败提示

有些同学依然没懂得每个断言方法最后附加的$message这个可选参数的意义,在断言大全里可以看到每一个断言方法的最后一个参数都叫做$message,并且是一个可选参数

它的作用就是为了在测试失败的时候,给出相关提示以方便咱们快速定位到是哪里出错

比如$this->assertTrue($user->add(), '注册失败')这句代码,如果断言成功那就不会有相关提示,但如果断言失败的话就会提示“注册失败”了

因为一个测试方法里面可以存在很多断言,比如我个人一般会在测试方法里平均写6到10个断言左右(附(样例代码)[https://github.com/kk8686/xoa/blob/master/server/tests/codeception/unit/WorkerTest.php#L37])

那么运行这些测试代码的期间如果有一个断言失败了,不加message的话是不容易知道哪个失败的,而有了message就很好办了


也为了让测试报告更加清晰

不仅仅是为了调试,如果公司有持续集成的话还可以在测试失败时将这些message打印到集成的输出信息里,这样更容易解读集成失败的原因以及方便其他人复查集成情况

再是如果在一个很规范项目里,还可能要将测试报告归档,并且严格要求程序员按照报告的情况去修复+补充相关测试,这时候测试报告的可读性就变得很重要了。不过当然我认为在PHP领域的测试里是极少有这种需求的,真要那么规范的话那项目当然是很有商业价值的了,这种项目的核心部分多数已经用JAVA或其他C++等实现,相关测试也不需要PHP写

但至少也是为了让自己调试、持续集成的报告变得好看可读吧,我还是很提倡加message参数的

单元测试 - 调试

开发过程中你可能发现有些东西老是断言不对,想print_r,echo啥的将数据打印出来看看是什么模样

但实际上你发现如果你写了句echo 'aaaaaaaaaaaa';的代码然后运行一下却发现没输出在控制台,怎么办?

在Codeception里你要使用这个函数codecept_debug来输出哦,并且它跟print_r一样既支持标量也支持数组和对象输出的,比如

codecept_debug(123);
codecept_debug('abc');
codecept_debug(['x', 'y', 'z']);
codecept_debug(new stdClass());

可是其实你抄了以上代码后运行都不会看得到输出,因为还要在命令里加入参数,声明为debug模式才能输出调试数据,比如:

php E:\codecept.phar run unit --debug
php E:\codecept.phar run unit -d #这样简写也可以喔!

可是你觉得这个函数名太长又有下划线符号,输入挺麻烦的,真不太乐意敲它,怎么办呢?我的解决办法有2个:

  1. 加一个扩展函数文件,定义一个叫db的函数,用这个函数调用codecept_debug,当然函数的参数表要基本一样啦,然后在写代码时只要输入db($data)就方便多了,另外记得在_bootstrap.php里引入扩展函数文件才能调用

  2. 我使用的IDE可以设置输入宏,只要我按一下db两个字母然后再按一下tab键就可以快速构造codecept_debug(光标默认在此闪烁);的快速编码功能,你可以摸索一下你的IDE或编辑器有没有类似这样的快速输入功能^-^


另外呢其实如果用exit('string....')也行的,仅限输出string,呵呵

单元测试 - 运行机制

哪些测试代码才被运行

就像HelloWorld这个测试用例里的testMe方法一样,如果你再复制一份这个方法改名为testXXX,也会被运行,下面是运行规则说明:

测试框架只会运行所有publictest开头的方法,只要你的方法名称前面有test四个字母就会被运行

当然你还可以在这些公共方法里调用私有方法


预启动脚本

可以看到测试项目目录tests\unit下有一个叫_bootstrap.php的文件,每次运行单元测试代码之前都会运行这个文件的,我们可以在这里做一些初始化工作,比如本来你在测试用例里编写$this->assertTrue(APP_NAME == 'test')然后运行时就会告诉你APP_NAME这个常量不存在,接着在_bootstrap.php里补充代码define('APP_NAME', 'test')然后测试用例就能正常调用这个常量了

但还要告诉你的就是在上一层目录tests也有一个_bootstrap.php,这个才是最先被运行的,然后再运行unit目录里面的,简单地说就是所有测试共同的预启动文件,接着里面就是单元测试才会跑的预启动文件,又比如tests\acceptance\_bootstrap.php这是验收测试运行时才会跑的预启动文件,但它也会运行上一层的公共预启动文件,这个后面学其它测试的时候再专门说吧


运行前后

每运行一个名字为test开头的测试方法,都会运行一次_before方法而测试方法跑完后就会运行_after方法,如果有N个testXXX的方法,那么每个testXXX之前都会跑一次_before_after,这里简单讲一下而已,后面会有详解,因为这里有坑

单元测试 - 项目例子

要点速读

  1. 实际项目中就是在test方法里写下针对项目的代码调用做各种不同的参数运行看看断言的结果

  2. 测试代码不应该关注类的加载,所以尽量别在new之前include代码,应该让被测试的项目自带autoload加载,测试框架bootstrap的过程中把被测试项目的autoload一起注册

  3. 本文章下面提供了一个叫xoa的开源项目提供给大家做参考


正文

上面你基本知道单元测试的知识和大概怎么编写了,接下来要应用到我们实际的生产中

假设我们有一个项目,项目里有个类叫A,希望修改它之后能方便快速地确认它能不出错,首先我们要准备一下这个A

为了模拟这些,我们先创建E:\project1目录并假设这里是某个软件产品的项目目录,接着创建E:\project1\A.php,里面编写如下代码:

class A{
public function getXX(){
return 1;
}
}

然后在预启动脚本tests\unit\_bootstrap.php里写下

define('TEST_PROJECT_PATH', 'E:/project1');	//定义被测试的项目目录常量,未来可能经常用到
include(TEST_PROJECT_PATH . '/A.php');

接着我们在HelloWorld测试用例的testMe方法里编写:

$a = new A();	//在预启动脚本里加载了
$this->assertGreaterThan(0, $a->getXX(), 'getXX的值居然不大于0!');

运行后应该是有1次成功的断言

接着修改A类里面的代码变成return -1,再运行,当然就会提示失败了,这样的话,当A类未来变大了,有好多个方法了,有互相调用的情况,比如getXX方法和getYY方法都调用了私有的_getZZ方法,你一旦修改了_getZZ方法的话,以前可能要手动测试getXX方法和getYY方法是否都运行正常,但现在只要跑一趟单元测试,针对getXX方法和getYY方法的运行结果写下断言就好了


加载问题

既然要使用单元测试去测定一些类的方法是否运行正常,那要new这些类当然要加载进来,一个项目有好多类,如果照上面所说在预启动脚本里include的话,那要写多少行?另外,项目增加一个类,又要在这里补一句include?

才没人做那么笨的事啦,其实只要在预启动脚本里加载你要测试的项目的初始化文件,这个初始化过程中必须注册一个autoload函数,实现自动加载,然后使得单元测试代码里new的时候能自动加载到相应的类就可以,关于自动加载这块建议使用PHP的psr4标准,未来我会出相关文章讲psr4标准但现在没有,而另外会尽快给出简单的样本代码给大家下载参照


项目例子

有些有在疑惑学会这些后,在实际项目中到底应该如何落实这些测试代码,怎么写,对哪些进行测试

为了解决这些疑惑,我顺便也做了一个叫xoa的开源项目,测试代码在server/tests目录中

这是一个针对Yii2框架的应用项目做测试的例子,实际上当然还能针对其它任何框架或自定义开发的东西做测试了

要将这个项目的测试代码跑起来,务必先看看项目的文档里面的运行部署运行测试用例两个章节

简单的日常应用就是,当xoa的代码被开发人员修改后,通过test-tools.bat程序运行一下全部测试,server\tests\unit目录里面的单元测试代码就会按照预期调用相关的class,以不同的方式执行它们的各种方法传递不同的参数,断言一下执行的结果是否符合

如果全部断言通过,说明本次对xoa的修改安全度起码有85%以上,因为一般要测试的问题都已经写在单元测试里了;测试代码并不能将所有角度都全部测试,因为程序员可能会写漏一些角度的测试代码,比如数据搜索的测试,搜索条件组合花样百出,如果1000个商品分类里有其中3个有问题,那为了准确找出这些问题,程序员是不是要写1000种不同商品分类的测试代码?

这不现实,而且分类还在增加,所以测试代码确实无法完美地测试所有问题,但大部分问题已经写好测试代码后,一旦修改业务逻辑,你不再需要人工访问这里那里的页面测试刷新测试刷新……而只需要按一下bat的命令就可以自动跑一次,甚至设定系统定时程序跑命令

单元测试 - _before和_after

  • 补充了关于after删除测试数据的说明,引导读者了解Db模块来使用数据库镜像实现恢复数据状态


生成的单元测试都会自带一个protected function _before()的方法以及protected function _after()的方法

每一个testXXX运行之前都会跑一次_before以及运行之后都会跑一次_after

如果在这里写初始化数据的代码,那么假设运行2个测试方法就会初始化2次数据

但一般情况下这些数据只为一个测试方法做初始化准备而已,所以应该通过getName方法来获取当前要运行的测试方法名称来判断要初始化什么数据

假设测试用例有testA,testB,testC三个测试方法,那么运行流程如下:

  1. 运行_before

  2. 运行testA

  3. 运行_after

  4. 运行_before

  5. 运行testB

  6. 运行_after

  7. 运行_before

  8. 运行testC

  9. 运行_after

如果不声明这两个方法,父类也会有(那么其实声明了就是重写父类的而已),只是不做什么事情,在父类里是一个空方法来的,那什么时候才需要定义这两个方法呢?

假设A,B方法在没有_before的情况下都要先$user = new User($用户ID)或者通过其它更复杂的代码逻辑获得数据/对象,比如随机抽取数据库一条记录这样

这时候要考虑缩减重复代码,所以就将获取数据的方法封装成私有的方法,再A B两个方法共同调这个私有方法来获取数据,这是一个办法


然而还有一种情况,假设要测试一场比赛是否能正常进行,那么先要生成一场比赛,再进行测试断言,测试完毕后还需要将这场测试的比赛删除掉,不然每跑一次单元测试你的数据库就多一条比赛记录是不是能预见某天数据库有大量你不想要的数据?而且还只是测试的,所以就要删除掉咯

生成一场比赛可以写在私有方法里给A B方法共同调用,但是删除一场比赛呢?你要注意到一个细节,当断言失败时,测试不会再运行,所以如果你的代码如下:

public function testA(){
$match = $this->_生成一场测试比赛();
$this->assertTrue($match->start());
$this->assertTrue($match->isPlaying);
$this->assertTrue($match->addMember($this->user));
$this->assertEquals(1, $match->getMemberCount());
$this->_删除测试比赛();
}

如果中间$this->assertTrue($match->isPlaying)断言失败,根据单元测试的运行特性,会导致整个测试方法停下来,不再跑后面的代码,所以你无法执行到$this->_删除测试比赛()这里,可是数据产生了,可能会影响测试服务器的,其他同事会在测试服务器是莫名奇妙地见到些多余的数据,或者重复运行测试比赛,会累积越来越多的比赛数据,如果是别的测试场合,更加会遗留下不应该遗留的数据!

So,How to 办? --- 那可以告诉你,无论断言成功与否,跳出跳试方法后,_after依然会被运行,所以删除比赛数据的代码写在_after里就能确保它被运行了!

可问题是,根据上面列出的运行顺序,testC运行后也会跑一次_after,可是testC没有调用生成测试比赛的数据,那如果在_after里编写了删除数据的代码,A和B之后是正常能操作删除了,可是C的时候又运行就没必要了吧,而且删除时又报错说找不到要删除的数据咋办

也有解决办法————获取当前测试的方法可以用$this->getName(),那判断这个值是不是testA,testB,是的话就运行删除代码,不是的话就不做事就好了

提示:特殊情况下,当$this->getName()返回的不是一个字符串的方法名称时,请尝试改成$this->getName(false),详细不解释了,专业测试工程师才有必要掌握这个知识点

而又为了统一,我们也应该将初始化数据的调用代码放在_before里面,反正你真的有心想做好测试工作的话总会往这个方向走的


关于删除测试数据

其实在after里删除测试数据虽然可行,但是这样实际上一个项目中要写不少的删除测试数据代码,这挺麻烦的

其实我们可以省掉这一部分的工作,未来学到验收测试 - 基础 - 自动恢复测试数据的时候就行了(要先学会模块的配置控制)

既然不通过after删除数据,那用来做什么事,你自己发挥吧

单元测试 - 依赖声明

比如有两个测试方法,通过test组件里取得一个测试学生ID来找学生实例

/**
* 测试模型能否正常找到学生
*/
public function testCreateInstance(){
$student = Student::createStudent(8282);
$this->assertInstanceOf('app\model\Student', $student);
} /**
* 测试添加金币是否会成功
*/
public function testBusiness(){
$student = Student::createStudent(8282);
$this->assertTrue($student->addCurrency(9));
}

接下来运行整个测试用例,但是testCreateInstance方法里如果createStudent后返回的模型并不是app\model\Student的实例,那么根本就没必要运行testBusiness,但测试框架会都运行,因为它不知道依赖关系.

那么告诉测试框架你的依赖关系,办法就是用phpdoc规范,@depends然后空格,写上你依赖的方法

/**
* 测试模型能否正常找到学生
*/
public function testCreateInstance(){
$student = Student::createStudent(8282);
$this->assertInstanceOf('app\model\Student', $student);
} /**
* (↓↓↓↓↓注意下面↓↓↓↓↓)测试添加金币是否会成功
* @depends testCreateInstance
*/
public function testBusiness(){
$student = Student::createStudent(8282);
$this->assertTrue($student->addCurrency(9));
}

这样测试框架在运行这个之前就会运行依赖的方法看看是否成功,成功才会运行这个 如果依赖多个测试方法,就要多行@depends,比如

/**
* (↓↓↓↓↓注意下面↓↓↓↓↓)多个depends声明
* @depends testCreateInstance
* @depends testGetXXX
*/
public function testBusiness(){
$student = Student::createStudent(8282);
$this->assertTrue($student->addCurrency(9));
}
  • *PS:

    依赖关系只会在运行整个测试用例的时候生效,如果你单独运行testBusiness这个测试方法,是不会先运行testCreateFindInstance的

    默认情况下,运行整个测试用例时,A方法测试失败,B和C方法依然会被运行,但如果B和C的运行前提是A方法测试成功,那就要使用依赖,使A方法失败时B和C都被跳过,运行D,E,F这些方法去了

    另外还要注意的是,B和C方法虽然是依赖于A的成功,但不能依赖于A的数据,比如A方法将测试用例的私有属性数据从1改成2后,B和C都基于2开始进行计算是不行的,因为每次运行一个测试方法,测试用例都会被重新new一次,那么私有属性就变回默认值了

单元测试 - 创建和运行命令

为什么现在才讲这个呢?其实我写教程的风格是这样的:就像前面几章的内容一样,我是先引导大家一步步手把手跟着做把程序跑起来用起来看看效果再说,什么概念呀规范呀的慢慢后面再讲,不像书面教程那样开门见山就说安装,说明,创建命令大全,运行命令大全,方法大全什么的每一环节都讲N多...我都会讲那些环节但只是把实际上我们用的讲出来,先把它用起来再说,后面有必要再开专门章节详解,等大家都基本了解这个东西了,要深度专门学习时你们自己去找官方文档看这一环节的详解看看有什么你们不知道的少用的知识

创建测试用例

之前引导大家入门那里使用过E:\codecept.phar generate:test unit HelloWorld这样的命令来生成一个叫HelloWorld的测试用例,并且它生成的测试用例文件名里会自动带上Test这个词E:\codecept.phar这里我就省略一下了,后面参数generate:test unit 测试用例名称就是用来创建单元测试的,只要修改测试用例的名称即可

运行后它将会在测试项目的tests/unit目录下产生一个叫测试用例名称Test.php这样的文件


将测试用例归类

测试用例会随着项目类库的增多而越写越多,一大坨代码写在一个测试用例的文件里你肯定不想啦,于是你肯定会想着分开多几个文件来写,但是文件多了咋办呀?哈哈这里其实还可以创建目录的,不难,就是创建测试用例时在用例名称前面加多个分类目录/这样,比如分类目录/测试用例名称,我们试试php E:\codecept.phar generate:test unit game/Abc,于是它就会先在unit目录下创建game目录再在里面创建Abc测试用例了,而且这里还能三层,比如game/role/Abc,其它你自己探索一下吧!


运行指定测试用例

最简单的运行命令就是之前我们用到臭了的php E:\codecept.phar run unit HelloWorldTest

它很简单就是说运行unit目录下HelloWorld这个测试用例,不用加上.php这样的文件后缀

其实你可以一次性运行所有测试用例:php E:\codecept.phar run unit,就是这样不指定用例名称就好了

如果你有对测试用例做分类,也是在前面加个目录名就行,比如php E:\codecept.phar run unit game/AbcTest

另外其实经常还要运行全部单元测试用例的,上面的命令都指定了测试用例的名称.于是其实大家都可以想到,不写名称应该就是运行全部单元测试了,php E:\codecept.phar run unit,因为你修改了一个东西后,希望全部都测试一下,确定全部类都不被影响之类的吧,毕竟它们会互相调用


运行某用例的某测试方法

php E:\codecept.phar run unit HelloWorldTest:testXyz

就是在用例后面加:测试方法名称即可

因为有时候我们在开发测试用例,写一点调试一点,就会只需要运行这个方法

又或者以后出了些什么问题时我们只需要确认这个测试方法有没有错,其它不需要确认

但如果指定了这个方法,这个方法的@depends(依赖声明)就会被忽略不会运行

单元测试 - Specify

通常断言失败时就会退出这个测试方法,使用specify这个特性可以使得断言失败时不会退出,先看例子:

class HelloWorldTest extends \Codeception\TestCase\Test{
use \Codeception\Specify; //要先在这里use一下 public function testDemo(){
$this->specify('环节1:测试time函数', function(){
//断言失败了不会往这个函数的代码下面跑而已,但外面还是会继续往下跑的
$this->assertInternalType('string', time()); //失败
$this->assertInternalType('string', md5(time())); //成功,但上面失败,跑不到这里,退出这一次的specify
}); //无论上面的specify里成还是败都还会继续跑这里
$this->assertTrue(true);
$this->specify('环节2:测试date函数', function(){
$testTimeStamp = 1447171200;
$this->assertNotEquals(1990, date('Y', $testTimeStamp));
$this->assertEquals(2015, date('Y', $testTimeStamp));
$this->assertEquals(5, count(explode('-', date('Y-m-d')))); //失败
}); $this->assertTrue(false, '外面也失败了');
}
}

使用$this->specify('测试目标的说明', $具体的测试工作代码函数)

可以实现一块specify中断不会导致specify外面的测试代码中断而只会中断specify里面的代码而已,这种情况在复杂业务测试时,当一个测试方法中的两个测试逻辑没有依赖关系时比较有用,而且这里还有一个说明的作用,将一大块代码进行一个说明,但这样说不是算完美,不过specify是比较有用的,只是小规模测试时一般用不上

另外顺带一提,使用specify方法前先要在类里面的顶部use \Codeception\Specify;这个Trait

class HelloWorldTest extends \Codeception\TestCase\Test{
use \Codeception\Specify; //看这里看这里 //...
}

单元测试 - 开发建议

  1. 不能一直用assertTrue

    断言取代所有断言,该用哪种就用哪种,必要时增加最后一个参数填写断言说明,那么你想用的判断该用哪个断言呢,就要在本教程的断言大全里面找了,或者百度,比如要断言大于小于的,在断方大全里Ctrl+F搜索大于一般就能找到,常用的多搞几遍就熟悉了,就像平时用别的类的方法一样


  2. 测试用例要分门别类

    测试方法会越来越多,你的测试用例代码会变胖,到时候记得将各种测试目的分门别类,分成多个测试用例,也要分一下目录,创建测试用例时可以声明目录的嘛


  3. 恢复测试产生的数据变化

    你做着做会遇到这样的问题:测试get的东西还好,get个变量断言是不是预期值,但如果要测试添加或更新数据呢?这里会引发一个问题,假设你要测试一个活动模型,为这个模型不断add了一些模拟的积分用户,然后判断get第一名是否有预期的数据,好了预期到了,可是下次跑测试的时候,再add就不对了,因为第一名已经判断出来并存在数据库了,怎么办?那所以这种测试就要有模拟数据场景和销毁模拟数据的行为了

    很简单,Codeception可以让你设置一个数据镜像,每次启动测试时都将数据库恢复到这个镜像的数据状态,所以重复运行测试是不会累积添加的数据的


    但有些项目不方便做数据镜像,那就要靠自己写数据恢复代码了

    测试用例生成的时候都有 _before和_after方法,在_before里将数据库里的东西该删就删,该添就添,模拟成你要测试的起始状态,然后测试完了就在_after里将刚才添加的数据删光,把该恢复的数据恢复回去就行了,模拟数据场景是一个麻烦事,但这个不可避免


  4. 标记哪些方法已经被测试

    单元测试的主要责任就是测试一个类的各个方法是否工作正常,我们的项目会增加越来越多的类和方法,那也要一一增加测试方法,还有旧的类库也不是都有测试用例的,日后要慢慢一一补充的,于是我们需要识别哪些类的哪些方法是不是已经测试了,所以你需要想一个办法来管理各个类的方法是否已经被测试。

    我的方法就是在被测试的方法中添加@test的注释(相关文章


  5. 判断哪些类和方法要编写单元测试

    你可能会问是不是项目每增加一个类都要写单元测试呀?这个看项目情况的,如果观念非常严谨要求丝毫不能出错,那肯定要最大限度保证所有类都能有测试代码。但多数项目都不是非常严谨的,那就建议只针对核心类和被很多地方调用的类来写测试,临时的/少调用的/非核心的类就可以不必为它写测试方法,尽管它们其实也可能会在修改中出错,但影响面会小一点,你觉得能接受就行,一切其实都是看性价比,你觉得值了就写,不值了就别写,但如果你一丁点都不写我认为真的不好~


  6. 欠缺的断言方法

    有时我们要断言一个值是不是数字,我之前并不提倡大家用assertTrue去取代所有断言,就像你要断言一个值是字符串应该用assertInternalType('string', $str),因为单元测试模块自带了针对字符串类型的断言方法,可是却没有提供针对数字的断言,于是只能通过assertTrue(is_numerice($number))来实现了,变通点即可,规则死人是活你懂


  7. 要断言什么内容

    测试开发的过程中,你会遇到“到底要断言什么,断言多少东西?"的问题,比如一个模型的getXXX获取数据方法,完整地说

    • 要先断言返回值是不是数组

    • 再断言数组里的key都有哪些

    • 还得断言每个key的值是什么类型。..值的范围 ...

    哇这不是要写很多很多断言代码?这样比程序业务代码还多的样子哦。

    在这方面我也没有完整的答案给到你,因为我没专门研究过测试开发的理论和概念内容,个人总结的工作经验就是:看性价比,你觉得哪些容易出错,后期修改容易造成误差的内容嘛,就测那些好了。不然你测得再多,有的本身就很稳定,永远不出错,你这个测试代码可能就白写了,自己学会把握哦

    可以看看我这个测试用例都在断言些什么:例子

模块 - 介绍

介绍

为了方便后面的学习,这里大家先来初步学习一下模块的知识

在Codeception里,除了单元测试之外,其它测试几乎都是通过模块来实现的.但单元测试也可以使用模块,模块可以理解为一种扩展,有了新的模块,你就get到了新的技能!可以做新的测试功能了!

它有一些自带模块给大家使用,自带的模块其实比较多,但我这里也不给大家介绍这些模块,只教你模块的一些配置和操作.

tests/unit.suite.yml这个单元测试的配置文件中,大家可以看到modules - enabled这个配置的值是[Asserts, UnitHelper],这其实就像PHP的数组,有Asserts和UnitHelper两个元素,这个配置是指定了单元测试使用了Asserts和UnitHelper两个模块,但默认情况下这两个模块对于单元测试来说基本是不被调用的.

而如果我说要增加一个叫XXX的模块,当然是将配置值改成[Asserts, UnitHelper, XXX]

模块 - 测试器

介绍

每种测试都有一个测试器,测试器是一个类,有a,b,c...好多方法,但是它不是一个固定的类,其实它是将各个模块合并成了一个类,这样理解就好了,比如A模块有a,b,q方法,而B模块有x,z方法,则合并后的测试器就拥有了a,b,q,x,z这五个方法!

测试器在哪里?分别就是每个测试类型目录下的UnitTester.php,FunctionalTester.php,AcceptanceTester.php这三个文件咯.


重构测试器

每当有模块变更时,或者包括模块里的方法有添加/删除/重命名方法时,我们都必须重构测试器,比如我们删除了单元测试的UnitHelper模块后,就需要重构测试器.(虽然你暂时不重构不会出错)

重构测试器的方法是cmd到项目根目录下之后,运行build命令,比如

php E:\codecept.phar build

然后就会有一堆success字样的提示告诉你一步成功,二步成功,三步成功...全部成功了,于是我们就成功重构了测试器.

关于重构测试器这一步请务必学会和记住,不然不会遭雷霹,而是遭遇一个个报错哦!!

模块 - 扩展单元测试

为什么要扩展

比如有个数组我们要断言它包含了哪些key,像下面这样的代码:

$urlInfo = parse_url('http://aa.com/bb/cc/dd.html');
$this->assertArrayHasKey('scheme', $urlInfo);
$this->assertArrayHasKey('host', $urlInfo);
$this->assertArrayHasKey('path', $urlInfo);

要断言parse_url解析出来的数组会包含scheme,host,path三个key于是用了三行代码去断言,但有些人肯定觉得这样写代码很烦了,虽然能复制修改一下就行,我个人也想优化这个东西,我设想比较方便的断言方法可以这样写:$this->assertArrayHasKeys('scheme,host,path', $urlInfo),这样多方便啊呵呵,哪要写三句代码那么多的?可是Codeception和PHPUnit都没有提供这样的断言方法,怎么办呢?嗯,本节内容教你增加自己的断言方法来扩展它!


实践

tests/_support目录下有几个Helper类,其实我们针对单元测试添加扩展就编辑那个已经存在的UnitHelper.php(单元测试配置中的模块)就行了,打开这个类,它是继承了Codeception\Module的,然后是一个空的类,我们增加方法代码:

use \PHPUnit_Framework_Assert;

class UnitHelper extends \Codeception\Module{
/**
* 这是我们扩展的方法
*/
public function assertArrayHasKeys($keys, $array, $message = ''){
foreach(explode(',', $keys) as $key){
PHPUnit_Framework_Assert::assertArrayHasKey($key, $array, '断言' . $key . '时失败,外部消息:' . $message);
}
}
}

好了,可是你觉得就真的能在测试用例里$this->assertArrayHasKeys这样调用新扩展的方法了吗?不行哦,因为测试用例并不继承Helper,那怎么调用法呢?

记不记得我们生成的测试用例都有一个protected $tester这样的$tester属性声明,是它!是它!就是它!它就是Helper,来来聪明的小孩

//在测试用例里调用
$this->tester->assertArrayHasKeys('scheme,host,path', $urlInfo);

可是运行还是出错了,说assertArrayHasKeys这个方法不存在,怎么可能呢?不是说通过tester来调用吗?

调用是这样调没错,但是其实我们还少了一个重要的步骤,在测试器“重构测试器“小节中已经提到过,一旦模块发生变动就要重构,所以我们需要执行以下命令:

E:
cd project1-tests #注意要切换到测试项目目录
php E:\codecept.phar build #就是这句代码!使用build命令使新的扩展方法生效

实际上我们这个扩展只是为UnitHelper模块增加了方法,扩展了方法,但我们并没有添加模块来扩展,接下来才是通过添加新的模块来实现扩展↓↓↓↓↓


增加自己的模块(Helper)

(这个其实不急着学,你可以先学后面的,以后真要增加自己的模块了再回来学这个小节吧^-^)

我们修改unit.suite.yml这个属于单元测试的配置文件

modules - enabled默认定义了Asserts断言模块和UnitHelper单元助手模块,一个模块就是一个类,除了自带的模块,自定义模块的开发默认情况下都在tests/_support目录下,Assert属于自带模块,UnitHelper则不属于所以你会发现_support目录下有UnitHelper.php却没有Assert.php这个类

接下来我们在_support目录下新建一个Command.php以表示我们企图增加一个叫Command的模块-_-

类名叫Command,这里顺带一提,Codeception也是遵守PSR4标准的,所以默认情况下文件前缀名请跟类名一致

记得要继承Codeception\Module这个类,这个类在codecept.phar这个压缩包里,不用管,照写就是,然后在里面写一些测试方法

回到unit.suite.yml配置文件中,在modules - enabled配置项中增加Command这个模块名称,再重构测试器即可

单元测试 2 & 初识模块3的更多相关文章

  1. 循序渐进Python3(五) -- 初识模块

    什么是模块? 模块,用一组代码实现了某个功能的代码集合. 类似于函数式编程和面向过程编程,函数式编程则完成一个功能,其他代码用来调用即可,提供了代码的重用性和代码间的耦合.而对于一个复杂的功能来,可能 ...

  2. python 之 初识模块

    什么是模块 什么是模块 一个.py文件 就是一个模块 我们使用import加载的模块分为4个通用类别 1.py文件 2.包好一组模块的包(带__init__.py文件的文件夹) 3.内置模块 4.已被 ...

  3. .NET Core 2.0 单元测试中初识 IOptionsMonitor<T>

    在针对下面设置 CookieAuthenticationOptions 的扩展方法写单元测试时遇到了问题. public static IServiceCollection AddCnblogsAut ...

  4. python基础(18):初识模块、re模块

    1. 认识模块 常见的场景:一个模块就是一个包含了python定义和声明的文件,文件名就是模块名字加上.py的后缀. 但其实import加载的模块分为四个通用类别: 1.使用python编写的代码(. ...

  5. py02_01:初识模块

    模块的定义:模块是一个包含所有你定义的函数和变量的文件,其后缀名是.py.模块可以被别的程序引入,以使用该模块中的函数等功能.(可以理解为:库) 模块分为三类 ( 1. 标准库:     直接导入使用 ...

  6. python初识模块

    sys import sys   print(sys.argv)     #输出 $ python test.py helo world ['test.py', 'helo', 'world']  # ...

  7. Python 基础-python环境变量、模块初识及字符类型

    (1).模块内置模块.第三方模块.自定义模块初识模块:sys \ os一般标准库存放路径 C:\Users\Administrator\AppData\Local\Programs\Python\Py ...

  8. python之路:模块初识

    python王者开发之路:模块初识 模块初识我现在讲的确有点早.不过没关系,后面我会详细说模块. 模块,也就是库,是python三剑客之一.这三剑客,函数.库和类,都是由程序编写而成的.之所以我先说模 ...

  9. Python模块初识

    目录 一 模块初识 二 模块分类 三 导入模块 四 Python文件的两种用途 五 模板查找顺序 六 软件开发目录规范 一.模块初识 模块是自我包含并且有组织的代码片段,是一系列功能的集合体,一个py ...

随机推荐

  1. [Leetcode Week9]Word Break II

    Word Break II 题解 题目来源:https://leetcode.com/problems/word-break-ii/description/ Description Given a n ...

  2. AOP相关

    静态代理.动态代理与AOP: 简单易懂:http://blog.csdn.net/hejingyuan6/article/details/36203505 补充:http://layznet.itey ...

  3. Oracle基础 10 表 table

    --查看表的结构 desc ygb; select * from user_tab_columnswhere table_name='YGB'; --新建表ygb create table ygb(  ...

  4. XGBOOST/GBDT,RandomForest/Bagging的比较

    原创文章:http://blog.csdn.net/qccc_dm/article/details/63684453 首先XGBOOST,GBDT,RF都是集成算法,RF是Bagging的变体,与Ba ...

  5. python几个重要的函数(lambda,filter,reduce,map,zip)

    一.匿名函数lambda lambda argument1,argument2,...argumentN :expression using arguments 1.lambda是一个表达式,而不是一 ...

  6. docker从零开始 存储(三)bind mounts

    使用bind mounts 自Docker早期以来bind mounts 一直存在.与volumes相比,绑定挂载具有有限的功能.使用bind mounts时,主机上的文件或目录将装入容器中.文件或目 ...

  7. 解决Unknown host 'd29vzk4ow07wi7.cloudfront.net'. You may need to adjust the proxy settings in Gradle.

    有时候打开AndroidStudio项目,没问题啊,昨天还打开没事的,今天打不开了或者你同步了一下项目,报错了.很无辜有没有.有时候多开机几次,多关几次AS,又莫名好了. 尝试过很多方法无效,这个文章 ...

  8. EA(Enterprise Architect) UML 建模之活动图

    一.活动图的概念作用 活动图本质上是一种流程图,它描述活动的序列,即系统从一个活动到另一个活动的控制流. 活动图的作用:描述用例  .   描述类的操作.描述算法(单独使用) 二. 活动图的基本符号 ...

  9. yii2.0在model里自定义数据表

    无需多言,直接撸代码 class Zhuanjia extends \yii\db\ActiveRecord { public static function tableName() { return ...

  10. [ThinkPHP] 独立分组配置,坑!!!

    'APP_GROUP_LIST'=>'Index,Admin', //逗号后面别跟空格啊,真是逗